一个关于单例的 Block 回调设计

关于这个设计思路在一年前就有了,只是最近一年都在写 CI,所以一直没填这个坑。关于如何传值,如何回调的思路能找到很多,但是关于单例如何用 Block 回调,没细心去找,所以不知道有没有和我想到一块儿的。

环境信息

iOS 11.3
Xcode 9.3


在写单例的时候,难免会遇上一些状态回调。比如蓝牙 SDK 需要回调蓝牙的状态,又比如网络 SDK 需要回调网络状态等等。

AFNetworking 中的网络回调

之所以思考 Block 回调的问题,是因为发现 AFNetworking 的 AFNetworkReachabilityManagersetReachabilityStatusChangeBlock 网络状态回调设置以后有被覆盖的风险:

AFNetworkReachabilityManager *manager = [AFNetworkReachabilityManager sharedManager];

[manager setReachabilityStatusChangeBlock:^(AFNetworkReachabilityStatus status) {
BOOL isReachable = (status == AFNetworkReachabilityStatusReachableViaWiFi);
if (isReachable) {
// do something...
}
}];

上面的代码中,manager 是一个单例,对 Block 进行赋值以后,可在网络状态发生改变时,在当前类中做些处理。而在 setReachabilityStatusChangeBlock 实现中发现,仅仅是将 Block 赋值给了普通变量,而不是集合类型的变量:

- (void)setReachabilityStatusChangeBlock:(void (^)(AFNetworkReachabilityStatus status))block {
self.networkReachabilityStatusBlock = block;
}

这个问题在 AFNetworking 的 issue 上也有人提出(#3014),但是解决方案很蜜汁。一种是在回调当中发起通知,另一种已经合入 3.0.0 版本,做法是…给 AFNetworkReachabilityManager 新增了一个创建普通实例对象的方法(MR #3111):

/**
Creates and returns a network reachability manager with the default socket address.

@return An initialized network reachability manager, actively monitoring the default socket address.
*/
+ (instancetype)manager;

看了下这部分代码关联的 issue,都是和 Block 被覆盖相关的,除了 “保证原有 API 不变,向下兼容” 这个理由外,我实在是找不出其他的解释了。

我有个大胆的想法

基于上面的问题,是否能设计一个不被覆盖的 Block 回调呢?

集合

覆盖的根本问题是因为 AFNetworkReachabilityManagershareManager 是个单例,而接收 Block 又是普通对象,如果换成集合类型,这个问题就解决了。集合选择主要关注两个问题:

  • 有序?无序?
  • strong?weak?

有序无序可以根据业务需求来选择。而对于是否持有,可以到实际场景里面看下:

- (void)addNetworkStatusCallback:(dispatch_block_t)callback {
// NSMutableDictionary
[self.dict setObject:callback forKey:@"UUID"];
}

假如选择持有,也就是用 NSMutableDictionary,会引入新问题:callback 何时释放?显然,当注册回调的实例释放以后,callback 也应该被移除,选择 NSMutableDictionary 在此并不是好的方案。

那么选择不持有,用 NSMapTable + NSPointerFunctionsWeakMemory 这个组合:

- (void)addNetworkStatusCallback:(dispatch_block_t)callback {
// NSMapTable key:weak value:weak
[self.table setObject:callback forKey:@"UUID"];
}

结果 block 刚 set 就被释放了。

到此,【集合的选用问题】变成了【应该由谁持有 Block 的问题】。

Associated Object

在我看来,由谁注册回调,就由谁管理,单例仅负责维护映射关系,所以代码变成了:

- (void)addObserver:(id)observer callback:(dispatch_block_t)callback {
[self.table setObject:callback forKey:observer];
objc_setAssociatedObject(observer, @"UUID", callback, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

通过 objc_setAssociatedObjectcallback 绑定给 observer,而单例的 NSMapTable 中,Key 与 Value 属性都是 weak,仅维护 observercallback 的映射,谁也不持有。比如在 ViewController 中:

[[ObserverSingleton shareInstance] addObserver:self callback:^{
NSLog(@"status callback");
}];

此时,callbackself 持有,单例中的 self.table 结构为:

{
<viewcontroller>: <callback>
}

这样处理,降低了调用者不 remove 就会造成内存泄漏或者野指针的风险,但是依然不完美:在调用者看来,self 并没有持有 callback,即使 callback 中直接使用 self 也不会造成循环引用。但这是一个误区,在单例中,我们实际将 callback 绑定给了 self。所以 callback 中还是必须使用 weakSelf 才行。目前没想到好的方案。

移除

移除分为两步,一个是重置绑定,一个是移除映射关系:

- (void)removeObserver:(id)observer {
[self.table removeObjectForKey:observer];
objc_setAssociatedObject(observer, @"UUID", nil, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

关于代码中的 @"UUID",在实现的时候实际上用的是地址:

[[NSString stringWithFormat:@"%p", &observer] UTF8String]

绑定的对象需要手动释放吗?

objc 源码中,能依次找到 NSObjectdealloc_objc_rootDeallocrootDeallocobject_disposeobjc_destructInstance。其中 objc_destructInstance 的实现如下:

void *objc_destructInstance(id obj) 
{
if (obj) {
// Read all of the flags at once for performance.
// 判断是否插入过 ARC 相关代码
bool cxx = obj->hasCxxDtor();
// 判断是否有关联对象
bool assoc = obj->hasAssociatedObjects();

// This order is important.
// 执行 .cxx_destruct,释放实例变量
if (cxx) object_cxxDestruct(obj);
// 移除关联对象
if (assoc) _object_remove_assocations(obj);
// 清空引用计数与弱引用表
obj->clearDeallocating();
}

return obj;
}

其中调用的移除关联对象的方法 _object_remove_assocations 中最后一行为:

for_each(elements.begin(), elements.end(), ReleaseValue());

最终调用 objc_release 释放对象:

static void releaseValue(id value, uintptr_t policy) {
if (policy & OBJC_ASSOCIATION_SETTER_RETAIN) {
return objc_release(value);
}
}

得益于 dealloc 时调用的 _object_remove_assocations 函数,所以关联的对象,是不需要手动释放的。

最后

关于一对多的消息发送,是否适合用 Block,这里不做讨论。仅从 Block 是否可以一对多的角度来看,这种做法是可行的。