Block 调用检查
前言
如何确保一个传递给别人的 Block 被调用过,是一个一直困扰我的问题,因为 Block 作为 iOS 的一种回调机制,它可以像函数一样马上被调用,也可以像对象一样被持有、被传递、被释放,并在将来的某个时候被调用。有些时候我们传出去的 Block 必须被调用一次,否则会处于一种不确定的状态而导致程序无法继续,或者出错。例如,之前一篇文章《一种 App 内路由系统的设计》中的路由注册方式,如果使用 Block 方式,那么在路由完成后需要调用一次 complete
以通知路由系统已经完成,否则无法处理新的路由。而在实际开发过程中,确实会遇到条件过多以后,在某些条件下忘记调用 complete
的情况。
WebKit 的实现
如何让系统自动检测出来?这个问题一直没有思路,直到某一天在处理 WebKit 的 delegate
时突发奇想,我不调用它的 handler 会怎么样?
我们知道,相比于 UIWebview 的直接返回布尔值的方式,WKWebview 把决定是否切换导航做成了异步回调的方式。
// UIWebview
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType;
// WKWebview
- (void)webView:(WKWebView *)webView
decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
这样的改动,使得我们甚至可以在请求一次网络后,根据返回值再决定导航动作。这个 decisionHandler
可以被传递到其他对像上,并把决定权交给它。Block 被传递时像普通对象一样会被引用(拷贝),最终会被释放。但是一旦 decisionHandler
释放前没有被调用过,WebKit 会抛出一个异常:
2018-07-02 18:01:06.625 [13522:489767] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Completion handler passed to -[RTViewController webView:decidePolicyForNavigationAction:decisionHandler:] was not called'
*** First throw call stack:
(
0 CoreFoundation 0x0000000106c2ef35 __exceptionPreprocess + 165
1 libobjc.A.dylib 0x00000001068c9bb7 objc_exception_throw + 45
2 CoreFoundation 0x0000000106c2ee6d +[NSException raise:format:] + 205
3 WebKit 0x00000001071327da _ZN6WebKit28CompletionHandlerCallCheckerD2Ev + 130
4 WebKit 0x000000010720a180 _ZN3WTF20ThreadSafeRefCountedIN6WebKit28CompletionHandlerCallCheckerEE5derefEv + 36
5 WebKit 0x0000000107207cb9 _ZN6WebKit15NavigationState12PolicyClient31decidePolicyForNavigationActionEPNS_12WebPageProxyEPNS_13WebFrameProxyERKNS_20NavigationActionDataES5_RKN7WebCore15ResourceRequestESC_N3WTF6RefPtrINS_27WebFramePolicyListenerProxyEEEPN3API6ObjectE + 935
...
)
libc++abi.dylib: terminating with uncaught exception of type NSException
很神奇!从堆栈上看,这个异常是一个名为 _ZN6WebKit28CompletionHandlerCallCheckerD2Ev
的东西抛出的(名字为什么会这样?见名字修饰)。好在 Apple 早就开源了 WebKit 的源码(https://webkit.org/),下载查阅后确实找到了一个 CompletionHandlerCallChecker:
namespace WebKit {
Ref<CompletionHandlerCallChecker> CompletionHandlerCallChecker::create(id delegate, SEL delegateMethodSelector)
{
return adoptRef(*new CompletionHandlerCallChecker(object_getClass(delegate), delegateMethodSelector));
}
CompletionHandlerCallChecker::CompletionHandlerCallChecker(Class delegateClass, SEL delegateMethodSelector)
: m_delegateClass(delegateClass)
, m_delegateMethodSelector(delegateMethodSelector)
, m_didCallCompletionHandler(false)
{
}
CompletionHandlerCallChecker::~CompletionHandlerCallChecker()
{
if (m_didCallCompletionHandler)
return;
Class delegateClass = classImplementingDelegateMethod();
[NSException raise:NSInternalInconsistencyException format:@"Completion handler passed to %c[%@ %@] was not called", class_isMetaClass(delegateClass) ? '+' : '-', NSStringFromClass(delegateClass), NSStringFromSelector(m_delegateMethodSelector)];
}
void CompletionHandlerCallChecker::didCallCompletionHandler()
{
ASSERT(!m_didCallCompletionHandler);
m_didCallCompletionHandler = true;
}
Class CompletionHandlerCallChecker::classImplementingDelegateMethod() const
{
Class delegateClass = m_delegateClass;
Method delegateMethod = class_getInstanceMethod(delegateClass, m_delegateMethodSelector);
for (Class superclass = class_getSuperclass(delegateClass); superclass; superclass = class_getSuperclass(superclass)) {
if (class_getInstanceMethod(superclass, m_delegateMethodSelector) != delegateMethod)
break;
delegateClass = superclass;
}
return delegateClass;
}
} // namespace WebKit
#endif // WK_API_ENABLED
最关键的代码在析构方法上,当 m_didCallCompletionHandler
为 false
时,直接抛异常。而这个 m_didCallCompletionHandler
什么时候会设为 true
呢?查看源码 NavigationState.mm 第 354 行左右:
// 为了方便理解,去掉了不相关的代码
...
RefPtr<CompletionHandlerCallChecker> checker = CompletionHandlerCallChecker::create(navigationDelegate.get(), @selector(webView:decidePolicyForNavigationAction:decisionHandler:));
auto decisionHandlerWithPolicies = [localListener = RefPtr<WebFramePolicyListenerProxy>(WTFMove(listener)), localNavigationAction = RefPtr<API::NavigationAction>(&navigationAction), checker = WTFMove(checker), mainFrameURLString](WKNavigationActionPolicy actionPolicy, _WKWebsitePolicies *websitePolicies) mutable {
if (checker->completionHandlerHasBeenCalled())
return;
checker->didCallCompletionHandler();
...
};
...
else {
auto decisionHandlerWithoutPolicies = [decisionHandlerWithPolicies] (WKNavigationActionPolicy actionPolicy) mutable {
decisionHandlerWithPolicies(actionPolicy, nil);
};
[navigationDelegate webView:m_navigationState.m_webView decidePolicyForNavigationAction:wrapper(navigationAction) decisionHandler:decisionHandlerWithoutPolicies];
}
}
WebKit 传给开发者的 decisionHandlerWithoutPolicies
实际上是 CPP 闭包:
auto decisionHandlerWithoutPolicies = []() {
};
不过不用担心,编译器会将它转换为 Objective-C 的 Stack Block。它捕获了 checker
实例,如果它被调用了,则会调用 checker->didCallCompletionHandler();
,如果一直没有调用过,当它释放时,checker
实例也被释放从而调用析构方法,并抛出异常!
这样一来其实思路就有了。
Checker
Objective-C 相比于 CPP 有着天然的优势,因为它原生就是引用计数的,不再需要额外的代码支持。那么我们实现一个 RTBlockChecker
:
@interface RTBlockChecker : NSObject
@property (nonatomic, readonly, assign) BOOL hasBeenCalled;
@end
@implementation RTBlockChecker
- (void)dealloc
{
if (_hasBeenCalled)
return;
[NSException raise:NSInternalInconsistencyException
format:@"not called!"];
}
- (void)didCalled {
_hasBeenCalled = YES;
}
@end
很简单,几行代码。然后,我们使用它的地方代码需要一点点改造:
// 原来的实现
void (^block)(...) = ...;
[self callAMethodWithBlock:block];
// 新的实现
void (^block)(...) = ...;
RTBlockChecker *checker = [RTBlockChecker new];
void (^blockWithChecker)(...) = ^(...) {
[checker didCalled];
block(...);
};
[self callAMethodWithBlock:blockWithChecker];
这样的实现,如果仅仅是在自己的项目中运用是完全够用了,但是如果想当作一个通用的第三方组件,代码入侵性就有点大了,另一方面,这种改动仅适用于有源代码的项目,对于没有源码的库,它返回给开发者的 Block 没法让它拥有调用检查的特性。于是不得不换一种思路。
自定义 Block
Block 对 OC 来说也是一种对象,也有完整的生命周期,可不可能在 Block 自身释放时做一些事情?答案是可以的。
以下是 Apple 对 Block 对象的定义(源码可以在 libclosure 找到):
// Values for Block_layout->flags to describe block objects
enum {
BLOCK_DEALLOCATING = (0x0001), // runtime
BLOCK_REFCOUNT_MASK = (0xfffe), // runtime
BLOCK_NEEDS_FREE = (1 << 24), // runtime
BLOCK_HAS_COPY_DISPOSE = (1 << 25), // compiler
BLOCK_HAS_CTOR = (1 << 26), // compiler: helpers have C++ code
BLOCK_IS_GC = (1 << 27), // runtime
BLOCK_IS_GLOBAL = (1 << 28), // compiler
BLOCK_USE_STRET = (1 << 29), // compiler: undefined if !BLOCK_HAS_SIGNATURE
BLOCK_HAS_SIGNATURE = (1 << 30), // compiler
BLOCK_HAS_EXTENDED_LAYOUT=(1 << 31) // compiler
};
#define BLOCK_DESCRIPTOR_1 1
struct Block_descriptor_1 {
uintptr_t reserved;
uintptr_t size;
};
#define BLOCK_DESCRIPTOR_2 1
struct Block_descriptor_2 {
// requires BLOCK_HAS_COPY_DISPOSE
void (*copy)(void *dst, const void *src);
void (*dispose)(const void *);
};
#define BLOCK_DESCRIPTOR_3 1
struct Block_descriptor_3 {
// requires BLOCK_HAS_SIGNATURE
const char *signature;
const char *layout; // contents depend on BLOCK_HAS_EXTENDED_LAYOUT
};
struct Block_layout {
void *isa;
volatile int32_t flags; // contains ref count
int32_t reserved;
void (*invoke)(void *, ...);
struct Block_descriptor_1 *descriptor;
// imported variables
};
注意这里有三种 descriptor
,Block_descriptor_1
是一定存在的,Block_descriptor_2
根据 flags 是否包含 BLOCK_HAS_COPY_DISPOSE
可选,Block_descriptor_3
根据是否包含 BLOCK_HAS_SIGNATURE
可选。那么我们可以构造一个 Block,自定义它的析构函数。
先按 Apple 的定义重写一套自己的类型:
struct RTBlock_Descriptor {
uintptr_t reserved;
uintptr_t size;
void (*copy)(void *dst, const void *src);
void (*dispose)(const void *);
const char *signature;
const char *layout;
};
enum {
BLOCK_NEEDS_FREE = (1 << 24),
BLOCK_HAS_COPY_DISPOSE = (1 << 25),
BLOCK_IS_GC = (1 << 27),
BLOCK_IS_GLOBAL = (1 << 28),
BLOCK_HAS_STRET = (1 << 29),
BLOCK_HAS_SIGNATURE = (1 << 30),
};
struct RTBlock {
Class isa;
int32_t flags;
int32_t reserved;
IMP invoke;
const struct RTBlock_Descriptor* descriptor;
void *forwardingBlock;
};
typedef struct RTBlock RTBlock;
定义一个自己的析构函数,实现函数及静态的 descriptor
常量:
static void rt_blockDispose(const void *block) {
Block_release(((const RTBlock *)block)->forwardingBlock);
if (((const RTBlock *)block)->reserved == 0) {
// exception!
}
}
static void rt_blockInvoke(void *block, ...) {
((RTBlock *)block)->reserved = 1;
// pass all parameters to forwardingBlock
}
static const struct RTBlock_Descriptor RTDescriptor = {
0,
sizeof(RTBlock),
NULL,
(void (*)(const void *))rt_blockDispose,
};
然后写一个函数构造自己的 Block:
RTBlock *block = (RTBlock *)(malloc(sizeof(RTBlock)));
block->isa = NSClassFromString(@"__NSMallocBlock__");
const unsigned retainCount = 1;
block->flags = BLOCK_HAS_COPY_DISPOSE | BLOCK_NEEDS_FREE | (retainCount << 1);
block->reserved = 0;
block->invoke = rt_blockInvoke;
block->descriptor = &RTDescriptor;
block->forwardingBlock = (__bridge void *)[originBlock copy];
这样一来,当 Block 被调用时,实际上进入了 rt_blockInvoke
函数,其中第一个参数 block
相当于 self
。我们可以在这里记一次调用(这里暂时利用 reserved),然后再调原始真正干活的 Block(forwardkingBlock
),最后在 rt_blockDispose
中检查是否被调用过。
新的问题
听起来这个解决方案十分完美,但马上就遇到新的问题了,原始 Block 可能是任意参数的!
这里的 rt_blockInvoke
虽然是可变参数的函数,但是它无法依次以正确的类型取出所有参数的,并传给 forwardingBlock
!
对于这个问题有想到几个方案:
- libffi。
libffi
让 C 语言拥有知道函数指针就可以任意调用的能力,且可以任意参数。但是一个简单的 Block 检查功能引入这样一个库肯定是不划算的。 - 汇编。函数参数的传递是通过特定的寄存器的,用汇编语言实现
rt_blockInvoke
可以直接绕开那些寄存器,然后用br
指令直接跳到forwardingBlock
的实现体。事实上libffi
底层就是汇编了,另一同事给出了这种方案的实现:BlockCallAssert。
其它解决方法
能不能利用 OC 自身的动态性在不引入汇编的情况下做到?留意下之前的 Block_descriptor_3
中的 signature
是不是很熟悉?对,就是 NSObject selector
的 signature
。可以动态地获取 Block 的参数信息:
static NSMethodSignature *rt_blockMethodSignature(id block) {
if (!block) {
return nil;
}
RTBlock *layout = (__bridge RTBlock *)block;
if (!(layout->flags & BLOCK_HAS_SIGNATURE)) {
return nil;
}
char *desc = (char *)layout->descriptor;
desc += 2 * sizeof(uintptr_t);
if (layout->flags & BLOCK_HAS_COPY_DISPOSE) {
desc += 2 * sizeof(void *);
}
if (!desc) {
return nil;
}
const char *signature = *(const char **)desc;
return [NSMethodSignature signatureWithObjCTypes:signature];
}
有了这些信息应该可以处理大部分情况了(为什么是大部分情况,下面会说明),取出原始参数值,传到 NSInvocation,最后调用就好:
static void rt_blockInvoke(id block, ...) {
NSMethodSignature *signature = rt_blockMethodSignature(block);
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
va_list args;
va_start(args, block);
NSUInteger numberOfArguments = signature.numberOfArguments;
for (NSUInteger i = 1; i < numberOfArguments; ++i) {
const char *argType = [signature getArgumentTypeAtIndex:i];
switch (argType[0]) {
case 'c':
case 's':
{
char param = va_arg(args, int);
[invocation setArgument:¶m atIndex:i];
}
break;
case 'f':
case 'd':
{
float param = va_arg(args, double);
[invocation setArgument:¶m atIndex:i];
}
break;
case '@':
{
id param = va_arg(args, id);
[invocation setArgument:¶m atIndex:i];
}
break;
case '^':
{
void * param = va_arg(args, void *);
[invocation setArgument:¶m atIndex:i];
}
break;
...
default:
if (strcmp(argType, @encode(CGSize))) {
CGSize param = va_arg(args, CGSize);
[invocation setArgument:¶m atIndex:i];
}
else ... {
}
break;
}
}
}
看上去不错,但是等等!NSInvocation 的 target
是 forwardingBlock
,但 selector
应该是什么?没有 selector
是无法动态找到 IMP
调用的。后来搜索发现 NSInvocation 有一个私用方法 - (void)invokeUsingIMP:(IMP)imp
,天无绝人之路!
[invocation invokeUsingIMP:((RTBlock *)(((__bridge RTBlock *)block)->forwardingBlock))->invoke];
再等等,Block 中有自定义的 struct
时怎么办?union
呢?情况会有点复杂了。
forwardInvocation
自定义的 struct
问题一直没有好的解决方案,事情就卡在这里了,直到有一天搜索发现一个 block forwarding 的办法,瞬间眼前一亮,原来还有这种操作!
简单的说就是把 Block 的 invoke
函数指向 _objc_msgForward
,利用 Objective-C 自身的消息转发机制自动填充好参数,最终会走到 - (void)forwardInvocation:(NSInvocation *)
方法。
- (void)forwardInvocation:(NSInvocation *)anInvocation
{
RTBlock *layout = (__bridge RTBlock *)self;
layout->reserved = 1;
[anInvocation setTarget:(__bridge id)layout->forwardingBlock];
[anInvocation invokeUsingIMP:((RTBlock *)layout->forwardingBlock)->invoke];
}
block forwarding 项目存在一些问题,它只能处理立即被调用的 Block,持有一段时间后调用会有问题,不满足我的要求,不过最后的成品 RTBlockCallChecker 是基于它的实现思路。
成果
最后的成品在使用上非常简单,用一个宏包裹原始 Block,就好,无论是一个变量还是字面量。它是支持任意参数与返回类型的,而且它是类型敏感的,类型错误编译时可以报警。
void (^someBlockMustBeCalled)() = ^{
...
};
// 原来的代码
[self passBlockToAMethod:someBlockMustBeCalled];
// 改为
[self passBlockToAMethod:RT_CHECK_BLOCK_CALLED(someBlockMustBeCalled)];
以上。