在处理异步过程中,我们经常会碰到这种情况,需要异步处理并异步回调completionHandler,但是有些场景下,如果你在处理完异步逻辑,而不回调completion的时候,会产生逻辑上的bug或者内存泄露问题,那么我们就需要知道调用方是否调用了completion。
这里举几个比较典型的例子,比如WKUIDelegate
中的回调:
1 | - (void)webView:(WKWebView *)webView |
如果不回调其completionHandler
,会导致其逻辑上的错误,那么这里我们来看看如何动态监测completionHandler
是否被调用过。
这里说一下,WK是通过WTF
的C++模板来实现的,我这里采用C语言来实现,其思路是大致相同的。
Block
首先我们来看看Block是什么。虽然我们平时可以像OC对象那样去使用它,但它严格意义上来说并不是一个OC对象,或者说它是一中极为特殊的OC对象。
1 | struct Block_layout { |
上面就是Block的内存布局,其中Block_layout
是一个不定长的结构体,我们平时看到的捕获变量都会存在结构尾部。这里我们看到和OC对象一样,也有isa
指针,但是这里的指针永远只会指向几个地方,这个之后会说。
其实我们在调用Block的时候,实际上调用的是block->invoke()
,第一个参数是Block本身,然后是入参按顺序排下去,这一部分编译器都会给我们做好,所以一个block调用实际是这样的:
1 | block->invoke(block, arg1, arg2, arg3); |
可以看到和OC的objc_msgSend
方法相同的是第一个参数是对象本身,但是不同的是第二个参数不再是SEL
。
既然知道了Block的结构,那么我们就可以自定义block了。
Block类型
Block定义的类型有:
1 | BLOCK_EXPORT void * _NSConcreteGlobalBlock[32] |
其中只有前2种是公开的,而我们平时会碰到的基本都是前3种类型,其中Global是永远不会被释放的,Stack是在栈上,所以只要栈销毁了就会被释放,Malloc和普通OC对象一样,采用引用计数来决定生命周期的。
那么我们回到最初的目的,如何判断是否被调用了呢?因为这个调用有可能是异步的,所以不可能通过__block bool called
这样的临时对象来判断,也不能通过其是否由Stack拷贝成Malloc来判断,因为copy了并不一定会被调用。
Block Wrap
这里要判断Block是否被调用,肯定是需要在原始Block基础上包裹一层可以计数调用次数的Block。C++会方便的多,可以直接通过模板来构造一个签名一样的Block。
这里我们利用了MallocBlock在未被任何人引用的时候会销毁的特性,在其被释放之前,来监测计数是否为0。如果是0则说明从来没有被调用过,不是0则说明被调用了。
那么接下来我们来看看如何动态构建这样一个Block,以及如果去包裹其实现体。
动态构建Block
1 | struct Block_layout { |
首先我们将我们所需要的几个参数定义在Block末尾,分别是原始的Block,调用计数,以及错误信息(这个在报错的时候使用,和该方案关系不大)。
然后,我们需要定义自己的descriptor。这里重写了dispose方法,我们需要在这里判断是否计数为0,同时也要在这里将对象释放掉(由于在C环境中,所以block也需要手动将其释放)。
1 | void block_call_assert_wrap_dispose(const void * ptr) { |
接下来就是将我们的所有数据内容填入Block_layout,来合成一个Block对象。
1 | void *block_call_assert_wrap_block(void *orig_blk, char *message) { |
其中invoke方法被我们的新方法block_call_assert_wrap_invoke
所替换,在这个方法里面,会更新计数,并且调用原始block的invoke方法。
block_call_assert_wrap_invoke的实现
block的方法是非常灵活的,参数个数以及返回值不一样的时候,经过前几篇内容,我们知道不能简单的通过方法调用来实现参数的传递,而且在这里我们也无法知道参数的个数以及类型。那么我们要怎么做才能简单而又实用呢?
这时候,我们想到objc_msgSend
方法,它就实现了非常技巧的实现了arguments forward
的功能(其功能特性可以参考C++模板的多参传递template <typename Args...>
)。
由于这里找不到i386的系统和arm32的系统了,所以只给出x86_64和arm64的实现方案。
1 | #if __x86_64__ |
1 | #ifdef __arm64__ |
这里简单的说明一下段汇编的逻辑。
- 取出
block->called
,并置为1
(可能改为真正的计数会比较好)。 - 取出原始block
block->block
,并放到第一个参数位置。 - 调用原始block的invoke
call block->block->invoke
。
这样我们就非常简单的包裹了原始invoke方法,并且插入了自己的逻辑。
使用
首先我们需要设置上述的exception_handler
。
1 | void exception_log(const char *str) { |
这里我只是让他打印出错误,更好的应该是直接抛出异常[NSException raise:]
。
在此基础上,定义一个宏以方便使用,以及可以加入#if DEBUG
,来禁用线上环境的该功能,并且把当前的位置传递给exception_message
:
1 |
|
bridge
,恩我们是支持的ARC,所以在此为了防止类型转换的warning和error,在此使用宏来定义。(好像Objc++会有警告)
那么在使用的时候就是这样:
1 | - (void)doAsyncWithCompletion:(block_t)completionBlock { |
那么在此时,如果被调用者没有调用过completionBlock()
时,就会触发exception_handler
。这样我们就可以检测到是否出现可能的逻辑错误和内存泄露了。
1 | ERROR: Block must be called at (BlockCallAssert/BlockCallAssert/BlockCallAssert/ViewController.mm:41 -[ViewController test2])! |
最后
一般来说,我们一旦设计了包含completionBlock
这样的接口,基本是需要回调方100%
的回调的,如果可以不用回调,那么我们为什么不改变设计方案呢。
当我们的调用方是自己的时候,我们可以确保,而如果是SDK,我们就很难确保,文档这个东西是不靠谱的,那么我们就让调用方在忽略了回调的时候给他一个重拳吧(exception)。
这个方案的实现我放在github,和cocoaPods BlockCallAssert
。