Block 調(diào)用檢查

前言

如何確保一個傳遞給別人的 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)自動檢測出來?這個問題一直沒有思路,直到某一天在處理 WebKitdelegate 時突發(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_didCallCompletionHandlerfalse 時,直接拋異常。而這個 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

BlockOC 來說也是一種對象,也有完整的生命周期,可不可能在 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)原始真正干活的 BlockforwardkingBlock),最后在 rt_blockDispose 中檢查是否被調(diào)用過。

新的問題

聽起來這個解決方案十分完美,但馬上就遇到新的問題了,原始 Block 可能是任意參數(shù)的!
這里的 rt_blockInvoke 雖然是可變參數(shù)的函數(shù),但是它無法依次以正確的類型取出所有參數(shù)的,并傳給 forwardingBlock

對于這個問題有想到幾個方案:

  1. libffi。libffiC 語言擁有知道函數(shù)指針就可以任意調(diào)用的能力,且可以任意參數(shù)。但是一個簡單的 Block 檢查功能引入這樣一個庫肯定是不劃算的。
  2. 匯編。函數(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 selectorsignature。可以動態(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:&param atIndex:i];
            }
                break;
            case 'f':
            case 'd':
            {
                float param = va_arg(args, double);
                [invocation setArgument:&param atIndex:i];
            }
                break;
            case '@':
            {
                id param = va_arg(args, id);
                [invocation setArgument:&param atIndex:i];
            }
                break;
            case '^':
            {
                void * param = va_arg(args, void *);
                [invocation setArgument:&param atIndex:i];
            }
                break;
            ...
            default:
                if (strcmp(argType, @encode(CGSize))) {
                    CGSize param = va_arg(args, CGSize);
                    [invocation setArgument:&param atIndex:i];
                }
                else ... {
                    
                }
                break;
        }
    }

}

看上去不錯,但是等等!NSInvocationtargetforwardingBlock,但 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 的辦法,瞬間眼前一亮,原來還有這種操作!

簡單的說就是把 Blockinvoke 函數(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)];

以上。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容