拒絕重寫,只想隨心鉤,一行一勾!---- 一款輕量級的iOS流程確認hook工具

1 自己做了才能信

我們都知道,針對iOS響應(yīng)屏幕點擊事件,在確認最佳響應(yīng)視圖的過程中,最重要的兩個函數(shù)就是 hitTest:withEvent:pointInside:withEvent:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event

誰說的?他們呀,網(wǎng)上那么多筆記、分析……然而,作為一個除了測試結(jié)果,連自己的代碼都從不直接信任的嚴謹?shù)拈_發(fā)工程師,怎能通過 “道聽途說” 來讓自己信服?一定要調(diào)試了才闊以!

So,如何做呢?

2 直觀思路:重寫目標方法

我們構(gòu)造UIView的子類ViewAViewB

@interface ViewA : UIView
@end

@interface ViewB : UIView
@end

然后重寫ViewA、ViewBhitTest:withEvent:pointInside:withEvent:方法:


@implementation ViewA

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {    
    printf("ViewA hitTest called...\n");
    return [super hitTest:point withEvent:event];
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    printf("ViewA pointInside called...\n");
    return [super pointInside:point withEvent:event];
}

@end

@implementation ViewB

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {    
    printf("ViewB hitTest called...\n");
    return [super hitTest:point withEvent:event];
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    printf("ViewB pointInside called...\n");
    return [super pointInside:point withEvent:event];
}

@end

再將我們的兩個視圖在一個baseView上構(gòu)造簡單的層次關(guān)系:

    ViewA *tmpViewA = [[ViewA alloc] init];
    tmpViewA.backgroundColor = [UIColor yellowColor];
    [baseView addSubview:tmpViewA];
    [tmpViewA setFrame:CGRectMake(50, 50, 300, 300)];
    
    ViewB *tmpViewB = [[ViewB alloc] init];
    tmpViewB.backgroundColor = [UIColor redColor];
    [tmpViewA addSubview:tmpViewB];
    [tmpViewB setFrame:CGRectMake(100, 100, 100, 100)];

我們得到了如下的視圖:

構(gòu)造的視圖

點擊View B,日志打?。?/p>

ViewA hitTest called...
ViewA pointInside called...
ViewB hitTest called...
ViewB pointInside called...

簡單分析,完成驗證。但是,這似乎太定制了一些:
1)亂入:我們的調(diào)試代碼要嵌入到業(yè)務(wù)邏輯(甚至要為此重寫一些方法);
2)麻煩:若想基于真實的App頁面測試,要一處處進行調(diào)試代碼添加,測一次加一次,極耗時間和耐心。
3)風(fēng)險:測試代碼要清理的,清理不干凈的話……
4)不完整:比如針對該例,那些繼承于UIView但是非ViewA、ViewB的類的實例,又或UIView本身的實例,它們的hitTest:withEvent:pointInside:withEvent:方法,即便系統(tǒng)調(diào)用了,我們也hook不到。

所以,直接打日志在很大層面上無法快速靈巧地滿足我們的流程確認需求。我們需要更高級一些的方法。

2 統(tǒng)一處理:使用方法交換(cySwizzlingInstanceMethodWithOriginalSel: swizzledSel:)

既然視圖都繼承于UIView,我們能否對UIView的 hitTest:withEvent:pointInside:withEvent:進行統(tǒng)一的hook操作呢?當(dāng)然可以!我們創(chuàng)建一個UIViewCategory,構(gòu)造兩個定制的方法實現(xiàn),引入CYToolkit,然后交換器方法即可。

#import <CYToolkit/CYToolkit.h>
#import "UIView+TEST.h"

@implementation UIView (TEST)

+ (void)load {
    __weak typeof(self) weakSelf = self;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [weakSelf cySwizzlingInstanceMethodWithOriginalSel:@selector(hitTest:withEvent:) swizzledSel:@selector(cy_hitTest:withEvent:)];
        [weakSelf cySwizzlingInstanceMethodWithOriginalSel:@selector(pointInside:withEvent:) swizzledSel:@selector(cy_pointInside:withEvent:)];
    });
}

- (UIView *)cy_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    printf("%s hitTest called...\n", NSStringFromClass([self class]).UTF8String);
    return [self cy_hitTest:point withEvent:event];
}

- (BOOL)cy_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    printf("%s pointInside called...\n", NSStringFromClass([self class]).UTF8String);
    return [self cy_pointInside:point withEvent:event];
}

@end

關(guān)于CYToolkit,是小編自己開發(fā)和使用的一個工具庫,會不定期更新一些便捷的小工具,通過pod安裝即可(可通過提交號更新最新,基于pod版本號的更新做的不及時,懶~)

pod 'CYToolkit',  :git => 'https://github.com/chrisYooh/CYToolkit.git', :commit => 'b3a7c09'

我們發(fā)現(xiàn),所有繼承于UIView的對象,他們的hitTest:withEvent:pointInside:withEvent:方法都被響應(yīng)了!如果只是通過特定類方法重寫定制添加,我們便很難發(fā)現(xiàn)一些隱蔽的中間流程(比如UITransitionView,雖然吧,我們也不太關(guān)心ta……)。

UIWindow hitTest called...
UIWindow pointInside called...
UITransitionView hitTest called...
UITransitionView pointInside called...
UIDropShadowView hitTest called...
UIDropShadowView pointInside called...
UIView hitTest called...
UIView pointInside called...
ViewA hitTest called...
ViewA pointInside called...
ViewB hitTest called...
ViewB pointInside called...

同時,當(dāng)測試目標轉(zhuǎn)向正式項目,只要copy一份category就好了;刪除(測試代碼)也方便了很多。

但是,還是感覺不太舒服,還要加新文件……在寫交換的方法的時候還要理解原理,不然容易寫錯……我們想要的只是在某個類的某個函數(shù)進行調(diào)用的時候,打印一條日志信息,需求如此明確了,就不能再簡單一點么?比如:隨時隨地地加一行代碼? 可!

3 簡化調(diào)用:一行代碼一個hook(cyInstanceDebugHook:)

引入CYToolkit(pod 'CYToolkit', :git => 'https://github.com/chrisYooh/CYToolkit.git', :commit => 'b3a7c09')。任意位置引入如下代碼(當(dāng)然要保證在你hook方法調(diào)用前hook,比如放到VCviewDidLoad方法中):

    [UIView cyInstanceDebugHook:@selector(hitTest:withEvent:)];
    [UIView cyInstanceDebugHook:@selector(pointInside:withEvent:)];

相關(guān)的方法都被hook了,統(tǒng)一處理嘛,所以打印的信息和格式我們也做了一些小心思在里面。

【CYDebug】hitTest:withEvent:  --  0x7f9f93d06010 (UIWindow)
【CYDebug】pointInside:withEvent:  --  0x7f9f93d06010 (UIWindow)
【CYDebug】hitTest:withEvent:  --  0x7f9f93e0b5f0 (UITransitionView)
【CYDebug】pointInside:withEvent:  --  0x7f9f93e0b5f0 (UITransitionView)
【CYDebug】hitTest:withEvent:  --  0x7f9f93e0c460 (UIDropShadowView)
【CYDebug】pointInside:withEvent:  --  0x7f9f93e0c460 (UIDropShadowView)
【CYDebug】hitTest:withEvent:  --  0x7f9f93e0b7d0 (UIView)
【CYDebug】pointInside:withEvent:  --  0x7f9f93e0b7d0 (UIView)
【CYDebug】hitTest:withEvent:  --  0x7f9f93e0b080 (ViewA)
【CYDebug】pointInside:withEvent:  --  0x7f9f93e0b080 (ViewA)
【CYDebug】hitTest:withEvent:  --  0x7f9f93e0ae50 (ViewB)
【CYDebug】pointInside:withEvent:  --  0x7f9f93e0ae50 (ViewB)

一行一勾,隨加隨刪,終于感覺舒服聊~

4 實現(xiàn)原理

第二節(jié)、第三節(jié)的技術(shù)都基于MethodSwizzling方法交換,但其具體的實現(xiàn)原理卻略有差異:

4.1 直接的方法交換

cySwizzlingClassMethodWithOriginalSel:swizzledSel:方法的實現(xiàn),基于method_exchangeImplementations

+ (void)cySwizzlingInstanceMethodWithOriginalSel:(SEL)originalSel swizzledSel:(SEL)swizzledSel {
    
    Class class = [self class];
    
    SEL originalSelector = originalSel;
    SEL swizzledSelector = swizzledSel;
    
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
    
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

思路上,我們可以理解為每一個類的每一個方法由一個方法名SEL和一個方法實現(xiàn)Method中的IMP組成。比較讓人困惑的點即為:平時我們在@implement中寫一個方法的時候,方法名和方法實現(xiàn)是寫在一起的,所以理解方法交換,我們要從意識上將方法名方法實現(xiàn)的概念分開。

在方法交換前,對應(yīng)函數(shù)的調(diào)用是這樣的:

方法交換前的hitTest調(diào)用流程

而在調(diào)用了method_exchangeImplementations進行方法交換后,原始方法的調(diào)用流程變成了這樣:

方法交換后的hitTest調(diào)用流程

這也就是為什么我們寫的交換方法要看似很不合理地"調(diào)用自己"的原因。

4.2 基于消息轉(zhuǎn)發(fā)的方法替換

cyInstanceDebugHook:則使用的是另一種基于forwardInvocation的稍微復(fù)雜一些的方法交換,交換前的方法調(diào)用流程顯然不變,但我們預(yù)備了好多待操作的方法名 & 方法實現(xiàn)

方法交換前的hitTest調(diào)用流程

方法交換之后,原始方法的調(diào)用流程變成了這樣:

方法交換后的hitTest調(diào)用流程

為何要借用forwardInvocation呢?一個很大的原因是因為ta的調(diào)用參數(shù):NSInvocation *invocation。包含了target(實例)、selector(調(diào)用方法)、arguement(參數(shù)),還提供了invoke這個觸發(fā)方法,可以很方便地進行方法調(diào)用。避免了千法千面的問題。當(dāng)然,涉及的操作多了,流程變得復(fù)雜了一些。

其相關(guān)核心代碼如下:

1) 將原始方法實現(xiàn)別名方法名記錄(圖中黃色),并將原始方法替換為消息轉(zhuǎn)發(fā)實現(xiàn)(圖中紅色)

+ (void)__replaceSelToMsgForward:(SEL)tarSel {
    
    Class klass = [self class];
    SEL selector = tarSel;
    SEL aliasSelector = __aliasSel(selector);
    
    Method targetMethod = class_getInstanceMethod(klass, selector);
    IMP targetMethodIMP = method_getImplementation(targetMethod);
    const char *typeEncoding = method_getTypeEncoding(targetMethod);

    class_addMethod(klass, aliasSelector, targetMethodIMP, typeEncoding);
    class_replaceMethod(klass, selector, _objc_msgForward, typeEncoding);
}

2)替換forwardInvocation方法(圖中紫色部分)

+ (void)__replaceForwardInvocation {
    
    Class klass = [self class];
    if ([klass instancesRespondToSelector:NSSelectorFromString(__fwdInvocationSelName)]) {
        /* 方法已經(jīng)進行了hook,不重復(fù)hook */
        return;
    }
    
    IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__cy_fwdInvocation_imp_, "v@:@");
    if (originalImplementation) {
        class_addMethod(klass, NSSelectorFromString(__fwdInvocationSelName), originalImplementation, "v@:@");
    }
}

3)重寫的forwardInvocation實現(xiàn)(圖中藍色部分)

static void __cy_fwdInvocation_imp_(__unsafe_unretained NSObject *self, SEL selector, NSInvocation *invocation) {
    
    SEL originalSelector = invocation.selector;
    SEL aliasSelector = __aliasSel(originalSelector);
    Class klass = object_getClass(invocation.target);

    BOOL isHooked = [klass instancesRespondToSelector:aliasSelector];
    
    /* 執(zhí)行 hook 邏輯 */
    if (isHooked) {
        printf("【CYDebug】%s  --  %p (%s)\n",
               NSStringFromSelector(originalSelector).UTF8String,
               self,
               NSStringFromClass([self class]).UTF8String
              );
        
        invocation.selector = aliasSelector;
        [invocation invoke];
    }
    
    /* 沒有進行方法Hook,執(zhí)行原邏輯 */
    else {
        SEL originalForwardInvocationSEL = NSSelectorFromString(__fwdInvocationSelName);
        if ([self respondsToSelector:originalForwardInvocationSEL]) {
            ((void( *)(id, SEL, NSInvocation *))objc_msgSend)(self, originalForwardInvocationSEL, invocation);
        } else {
            [self doesNotRecognizeSelector:invocation.selector];
        }
    }
}

那么,閱讀過Aspect源碼的小伙伴或許會發(fā)現(xiàn),我們的大體思路與其(Aspect)是相同的,那我們是否可以基于Aspect直接封裝cyInstanceDebugHook:呢?當(dāng)然可以,那為什么沒有這么做呢?:

1)需求更簡單
我們的需求相比Aspect的通用性切面編程支持,要簡單很多。無須太多額外的設(shè)計(Apsect涉及多個數(shù)據(jù)結(jié)構(gòu)的定義,源碼畢竟接近1000行呢,而我們只要100行

2)防止沖突
Apsect作為比較知名的切面編程庫,很多小伙伴已經(jīng)在使用,直接在我們的工具中引入可能造成沖突。

3)便于理解
放心地使用一款工具,免不了對齊基礎(chǔ)原理的理解。那么cyInstanceDebugHook:的原理,一張圖就列的清楚了。對應(yīng)代碼去理解,不要太快。理得舒心,用得放心。

4)更靈活
Aspect為了通用場景的安全性(避免用戶踩坑找他們麻煩),做了一個存在繼承關(guān)系的類不允許hook同一個方法的限制。

@"Error: %@ already hooked in %@. A method can only be hooked once per class hierarchy."

那么,它無法滿足我們對存在繼承關(guān)系的類hook同一個方法的需求。(比如子類重寫的對應(yīng)的方法,并且子類方法沒有調(diào)用父類的方法 場景下的hook

5 玩起來

那么,去隨便找個堆棧,看看其中有哪些感興趣的實例方法,hook看看吧。(記得在方法調(diào)用前hook就OK)

堆棧截圖

那么,對我們選中的方法添加hook代碼吧,一個一行~

    [UIApplication cyInstanceDebugHook:@selector(_run)];
    [UIView cyInstanceDebugHook:@selector(_hitTest:withEvent:windowServerHitTestWindow:)];
    [UIWindow cyInstanceDebugHook:@selector(_hitTestLocation:inScene:withWindowServerHitTestWindow:event:)];
    [UIWindowScene cyInstanceDebugHook:@selector(_topVisibleWindowPassingTest:)];
    [UIWindowScene cyInstanceDebugHook:@selector(_enumerateWindowsIncludingInternalWindows:onlyVisibleWindows:asCopy:stopped:withBlock:)];
    [UIWindowScene cyInstanceDebugHook:@selector(_topVisibleWindowPassingTest:)];
    [UIWindow cyInstanceDebugHook:@selector(_targetWindowForPathIndex:atPoint:forEvent:windowServerHitTestWindow:)];

看看我們暴力hook后,點擊ViewB的結(jié)果,Wooh,很黃很暴力

【CYDebug】_run  --  0x105004ae0 (SubApplication)

【CYDebug】_targetWindowForPathIndex:atPoint:forEvent:windowServerHitTestWindow:  --  0x133e095f0 (UIWindow)
【CYDebug】_topVisibleWindowPassingTest:  --  0x133e0a5b0 (UIWindowScene)
【CYDebug】_enumerateWindowsIncludingInternalWindows:onlyVisibleWindows:asCopy:stopped:withBlock:  --  0x133e0a5b0 (UIWindowScene)
【CYDebug】_hitTestLocation:inScene:withWindowServerHitTestWindow:event:  --  0x133e095f0 (UIWindow)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e095f0 (UIWindow)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e098e0 (UITransitionView)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e0b7c0 (UIDropShadowView)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e0db90 (UIView)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e0af80 (ViewA)
【CYDebug】_hitTest:withEvent:windowServerHitTestWindow:  --  0x133e08d40 (ViewB)

大家可能多多少少都聽說過無痕埋點無痕埋點很關(guān)鍵的一個點就是要找到合適的hook目標方法,查看堆棧信息就是重要的方法尋找途徑之一~

哈哈,越來越喜歡這個工具了,繼續(xù)嘗試,試試追蹤iOS的響應(yīng)鏈吧!來來,hook一下touchesBegan:withEvent:

[UIView cyInstanceDebugHook:@selector(touchesBegan:withEvent:)];

Poom!崩潰了……-_-||

什么原因呢?留個懸念,我們下次再聊(壞笑)。


附:

1 參考:Aspects
2 工具:CYToolkit
3 CYToolkit pod引入?yún)⒖迹?strong>pod 'CYToolkit', :git => 'https://github.com/chrisYooh/CYToolkit.git', :commit => 'b3a7c09'
4 一行一勾函數(shù)名:cyInstanceDebugHook:

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

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

  • 背景 某年某月的某一天,產(chǎn)品小 S 向開發(fā)君小 Q 提出了一個簡約而不簡單的需求:擴大一下某個 button 的點...
    羈擁_f357閱讀 328評論 0 0
  • 可否使用 == 來判斷兩個NSString類型的字符串是否相同?為什么? 不能。==判斷的是兩個變量的值的內(nèi)存地址...
    漸z閱讀 669評論 0 0
  • iOS中所有的手勢操作都繼承于UIGestureRecognizer,這個類本身不能直接使用。這個類中定義了這幾種...
    Imkata閱讀 1,262評論 0 1
  • 漸變的面目拼圖要我怎么拼? 我是疲乏了還是投降了? 不是不允許自己墜落, 我沒有滴水不進的保護膜。 就是害怕變得面...
    悶熱當(dāng)乘涼閱讀 4,462評論 0 13
  • 感覺自己有點神經(jīng)衰弱,總是覺得手機響了;屋外有人走過;每次媽媽不聲不響的進房間突然跟我說話,我都會被嚇得半死!一整...
    章魚的擁抱閱讀 2,364評論 4 5

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