前言
如何確保一個傳遞給別人的 Block 被調(diào)用過,是一個一直困擾我的問題,因為 Block 作為 iOS 的一種回調(diào)機制,它可以像函數(shù)一樣馬上被調(diào)用,也可以像對象一樣被持有、被傳遞、被釋放,并在將來的某個時候被調(diào)用。有些時候我們傳出去的 Block 必須被調(diào)用一次,否則會處于一種不確定的狀態(tài)而導致程序無法繼續(xù),或者出錯。例如,之前一篇文章《一種 App 內(nèi)路由系統(tǒng)的設計》中的路由注冊方式,如果使用 Block 方式,那么在路由完成后需要調(diào)用一次 complete 以通知路由系統(tǒng)已經(jīng)完成,否則無法處理新的路由。而在實際開發(fā)過程中,確實會遇到條件過多以后,在某些條件下忘記調(diào)用 complete 的情況。
WebKit 的實現(xiàn)
如何讓系統(tǒng)自動檢測出來?這個問題一直沒有思路,直到某一天在處理 WebKit 的 delegate 時突發(fā)奇想,我不調(diào)用它的 handler 會怎么樣?
我們知道,相比于 UIWebview 的直接返回布爾值的方式,WKWebview 把決定是否切換導航做成了異步回調(diào)的方式。
// UIWebview
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType;
// WKWebview
- (void)webView:(WKWebView *)webView
decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
這樣的改動,使得我們甚至可以在請求一次網(wǎng)絡后,根據(jù)返回值再決定導航動作。這個 decisionHandler 可以被傳遞到其他對像上,并把決定權(quán)交給它。Block 被傳遞時像普通對象一樣會被引用(拷貝),最終會被釋放。但是一旦 decisionHandler 釋放前沒有被調(diào)用過,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
最關(guān)鍵的代碼在析構(gòu)方法上,當 m_didCallCompletionHandler 為 false 時,直接拋異常。而這個 m_didCallCompletionHandler 什么時候會設為 true 呢?查看源碼 NavigationState.mm 第 354 行左右:
// 為了方便理解,去掉了不相關(guān)的代碼
...
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 傳給開發(fā)者的 decisionHandlerWithoutPolicies 實際上是 CPP 閉包:
auto decisionHandlerWithoutPolicies = []() {
};
不過不用擔心,編譯器會將它轉(zhuǎn)換為 Objective-C 的 Stack Block。它捕獲了 checker 實例,如果它被調(diào)用了,則會調(diào)用 checker->didCallCompletionHandler();,如果一直沒有調(diào)用過,當它釋放時,checker 實例也被釋放從而調(diào)用析構(gòu)方法,并拋出異常!
這樣一來其實思路就有了。
Checker
Objective-C 相比于 CPP 有著天然的優(yōu)勢,因為它原生就是引用計數(shù)的,不再需要額外的代碼支持。那么我們實現(xiàn)一個 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
很簡單,幾行代碼。然后,我們使用它的地方代碼需要一點點改造:
// 原來的實現(xiàn)
void (^block)(...) = ...;
[self callAMethodWithBlock:block];
// 新的實現(xiàn)
void (^block)(...) = ...;
RTBlockChecker *checker = [RTBlockChecker new];
void (^blockWithChecker)(...) = ^(...) {
[checker didCalled];
block(...);
};
[self callAMethodWithBlock:blockWithChecker];
這樣的實現(xiàn),如果僅僅是在自己的項目中運用是完全夠用了,但是如果想當作一個通用的第三方組件,代碼入侵性就有點大了,另一方面,這種改動僅適用于有源代碼的項目,對于沒有源碼的庫,它返回給開發(fā)者的 Block 沒法讓它擁有調(diào)用檢查的特性。于是不得不換一種思路。
自定義 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 根據(jù) flags 是否包含 BLOCK_HAS_COPY_DISPOSE 可選,Block_descriptor_3 根據(jù)是否包含 BLOCK_HAS_SIGNATURE 可選。那么我們可以構(gòu)造一個 Block,自定義它的析構(gòu)函數(shù)。
先按 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;
定義一個自己的析構(gòu)函數(shù),實現(xiàn)函數(shù)及靜態(tài)的 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,
};
然后寫一個函數(shù)構(gòu)造自己的 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 被調(diào)用時,實際上進入了 rt_blockInvoke 函數(shù),其中第一個參數(shù) block 相當于 self。我們可以在這里記一次調(diào)用(這里暫時利用 reserved),然后再調(diào)原始真正干活的 Block(forwardkingBlock),最后在 rt_blockDispose 中檢查是否被調(diào)用過。
新的問題
聽起來這個解決方案十分完美,但馬上就遇到新的問題了,原始 Block 可能是任意參數(shù)的!
這里的 rt_blockInvoke 雖然是可變參數(shù)的函數(shù),但是它無法依次以正確的類型取出所有參數(shù)的,并傳給 forwardingBlock!
對于這個問題有想到幾個方案:
-
libffi。
libffi讓 C 語言擁有知道函數(shù)指針就可以任意調(diào)用的能力,且可以任意參數(shù)。但是一個簡單的 Block 檢查功能引入這樣一個庫肯定是不劃算的。 - 匯編。函數(shù)參數(shù)的傳遞是通過特定的寄存器的,用匯編語言實現(xiàn)
rt_blockInvoke可以直接繞開那些寄存器,然后用br指令直接跳到forwardingBlock的實現(xiàn)體。事實上libffi底層就是匯編了,另一同事給出了這種方案的實現(xiàn):BlockCallAssert。
其它解決方法
能不能利用 OC 自身的動態(tài)性在不引入?yún)R編的情況下做到?留意下之前的 Block_descriptor_3 中的 signature 是不是很熟悉?對,就是 NSObject selector 的 signature。可以動態(tài)地獲取 Block 的參數(shù)信息:
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];
}
有了這些信息應該可以處理大部分情況了(為什么是大部分情況,下面會說明),取出原始參數(shù)值,傳到 NSInvocation,最后調(diào)用就好:
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 是無法動態(tài)找到 IMP 調(diào)用的。后來搜索發(fā)現(xiàn) NSInvocation 有一個私用方法 - (void)invokeUsingIMP:(IMP)imp,天無絕人之路!
[invocation invokeUsingIMP:((RTBlock *)(((__bridge RTBlock *)block)->forwardingBlock))->invoke];
再等等,Block 中有自定義的 struct 時怎么辦?union 呢?情況會有點復雜了。
forwardInvocation
自定義的 struct 問題一直沒有好的解決方案,事情就卡在這里了,直到有一天搜索發(fā)現(xiàn)一個 block forwarding 的辦法,瞬間眼前一亮,原來還有這種操作!
簡單的說就是把 Block 的 invoke 函數(shù)指向 _objc_msgForward,利用 Objective-C 自身的消息轉(zhuǎn)發(fā)機制自動填充好參數(shù),最終會走到 - (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 項目存在一些問題,它只能處理立即被調(diào)用的 Block,持有一段時間后調(diào)用會有問題,不滿足我的要求,不過最后的成品 RTBlockCallChecker 是基于它的實現(xiàn)思路。
成果
最后的成品在使用上非常簡單,用一個宏包裹原始 Block,就好,無論是一個變量還是字面量。它是支持任意參數(shù)與返回類型的,而且它是類型敏感的,類型錯誤編譯時可以報警。
void (^someBlockMustBeCalled)() = ^{
...
};
// 原來的代碼
[self passBlockToAMethod:someBlockMustBeCalled];
// 改為
[self passBlockToAMethod:RT_CHECK_BLOCK_CALLED(someBlockMustBeCalled)];
以上。