拒绝重写,只想随心钩,一行一勾!---- 一款轻量级的iOS流程确认hook工具

1 自己做了才能信

我们都知道,针对iOS响应屏幕点击事件,在确认最佳响应视图的过程中,最重要的两个函数就是 hitTest:withEvent:pointInside:withEvent:

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

谁说的?他们呀,网上那么多笔记、分析……然而,作为一个除了测试结果,连自己的代码都从不直接信任的严谨的开发工程师,怎能通过 “道听途说” 来让自己信服?一定要调试了才阔以!

So,如何做呢?

2 直观思路:重写目标方法

我们构造UIView的子类ViewAViewB

@interface ViewA : UIView
@end

@interface ViewB : UIView
@end

然后重写ViewAViewBhitTest: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上构造简单的层次关系:

    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)];

我们得到了如下的视图:

构造的视图

点击View B,日志打印:

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

简单分析,完成验证。但是,这似乎太定制了一些:
1)乱入:我们的调试代码要嵌入到业务逻辑(甚至要为此重写一些方法);
2)麻烦:若想基于真实的App页面测试,要一处处进行调试代码添加,测一次加一次,极耗时间和耐心。
3)风险:测试代码要清理的,清理不干净的话……
4)不完整:比如针对该例,那些继承于UIView但是非ViewAViewB的类的实例,又或UIView本身的实例,它们的hitTest:withEvent:pointInside:withEvent:方法,即便系统调用了,我们也hook不到。

所以,直接打日志在很大层面上无法快速灵巧地满足我们的流程确认需求。我们需要更高级一些的方法。

2 统一处理:使用方法交换(cySwizzlingInstanceMethodWithOriginalSel: swizzledSel:)

既然视图都继承于UIView,我们能否对UIView的 hitTest:withEvent:pointInside:withEvent:进行统一的hook操作呢?当然可以!我们创建一个UIViewCategory,构造两个定制的方法实现,引入CYToolkit,然后交换器方法即可。

#import 
#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

关于CYToolkit,是小编自己开发和使用的一个工具库,会不定期更新一些便捷的小工具,通过pod安装即可(可通过提交号更新最新,基于pod版本号的更新做的不及时,懒~)

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

我们发现,所有继承于UIView的对象,他们的hitTest:withEvent:pointInside:withEvent:方法都被响应了!如果只是通过特定类方法重写定制添加,我们便很难发现一些隐蔽的中间流程(比如UITransitionView,虽然吧,我们也不太关心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...

同时,当测试目标转向正式项目,只要copy一份category就好了;删除(测试代码)也方便了很多。

但是,还是感觉不太舒服,还要加新文件……在写交换的方法的时候还要理解原理,不然容易写错……我们想要的只是在某个类的某个函数进行调用的时候,打印一条日志信息,需求如此明确了,就不能再简单一点么?比如:随时随地地加一行代码? 可!

3 简化调用:一行代码一个hook(cyInstanceDebugHook:)

引入CYToolkit(pod 'CYToolkit', :git => 'https://github.com/chrisYooh/CYToolkit.git', :commit => 'b3a7c09')。任意位置引入如下代码(当然要保证在你hook方法调用前hook,比如放到VCviewDidLoad方法中):

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

相关的方法都被hook了,统一处理嘛,所以打印的信息和格式我们也做了一些小心思在里面。

【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 实现原理

第二节、第三节的技术都基于MethodSwizzling方法交换,但其具体的实现原理却略有差异:

4.1 直接的方法交换

cySwizzlingClassMethodWithOriginalSel:swizzledSel:方法的实现,基于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和一个方法实现Method中的IMP组成。比较让人困惑的点即为:平时我们在@implement中写一个方法的时候,方法名和方法实现是写在一起的,所以理解方法交换,我们要从意识上将方法名方法实现的概念分开。

在方法交换前,对应函数的调用是这样的:

方法交换前的hitTest调用流程

而在调用了method_exchangeImplementations进行方法交换后,原始方法的调用流程变成了这样:

方法交换后的hitTest调用流程

这也就是为什么我们写的交换方法要看似很不合理地"调用自己"的原因。

4.2 基于消息转发的方法替换

cyInstanceDebugHook:则使用的是另一种基于forwardInvocation的稍微复杂一些的方法交换,交换前的方法调用流程显然不变,但我们预备了好多待操作的方法名 & 方法实现

方法交换前的hitTest调用流程

方法交换之后,原始方法的调用流程变成了这样:

方法交换后的hitTest调用流程

为何要借用forwardInvocation呢?一个很大的原因是因为ta的调用参数:NSInvocation *invocation。包含了target(实例)、selector(调用方法)、arguement(参数),还提供了invoke这个触发方法,可以很方便地进行方法调用。避免了千法千面的问题。当然,涉及的操作多了,流程变得复杂了一些。

其相关核心代码如下:

1) 将原始方法实现别名方法名记录(图中黄色),并将原始方法替换为消息转发实现(图中红色)

+ (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)]) {
        /* 方法已经进行了hook,不重复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实现(图中蓝色部分)

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];
    
    /* 执行 hook 逻辑 */
    if (isHooked) {
        printf("【CYDebug】%s  --  %p (%s)\n",
               NSStringFromSelector(originalSelector).UTF8String,
               self,
               NSStringFromClass([self class]).UTF8String
              );
        
        invocation.selector = aliasSelector;
        [invocation invoke];
    }
    
    /* 没有进行方法Hook,执行原逻辑 */
    else {
        SEL originalForwardInvocationSEL = NSSelectorFromString(__fwdInvocationSelName);
        if ([self respondsToSelector:originalForwardInvocationSEL]) {
            ((void( *)(id, SEL, NSInvocation *))objc_msgSend)(self, originalForwardInvocationSEL, invocation);
        } else {
            [self doesNotRecognizeSelector:invocation.selector];
        }
    }
}

那么,阅读过Aspect源码的小伙伴或许会发现,我们的大体思路与其(Aspect)是相同的,那我们是否可以基于Aspect直接封装cyInstanceDebugHook:呢?当然可以,那为什么没有这么做呢?:

1)需求更简单
我们的需求相比Aspect的通用性切面编程支持,要简单很多。无须太多额外的设计(Apsect涉及多个数据结构的定义,源码毕竟接近1000行呢,而我们只要100行

2)防止冲突
Apsect作为比较知名的切面编程库,很多小伙伴已经在使用,直接在我们的工具中引入可能造成冲突。

3)便于理解
放心地使用一款工具,免不了对齐基础原理的理解。那么cyInstanceDebugHook:的原理,一张图就列的清楚了。对应代码去理解,不要太快。理得舒心,用得放心。

4)更灵活
Aspect为了通用场景的安全性(避免用户踩坑找他们麻烦),做了一个存在继承关系的类不允许hook同一个方法的限制。

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

那么,它无法满足我们对存在继承关系的类hook同一个方法的需求。(比如子类重写的对应的方法,并且子类方法没有调用父类的方法 场景下的hook

5 玩起来

那么,去随便找个堆栈,看看其中有哪些感兴趣的实例方法,hook看看吧。(记得在方法调用前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的结果,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)

大家可能多多少少都听说过无痕埋点无痕埋点很关键的一个点就是要找到合适的hook目标方法,查看堆栈信息就是重要的方法寻找途径之一~

哈哈,越来越喜欢这个工具了,继续尝试,试试追踪iOS的响应链吧!来来,hook一下touchesBegan:withEvent:

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

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

什么原因呢?留个悬念,我们下次再聊(坏笑)。


附:

1 参考:Aspects
2 工具:CYToolkit
3 CYToolkit pod引入参考:pod 'CYToolkit', :git => 'https://github.com/chrisYooh/CYToolkit.git', :commit => 'b3a7c09'
4 一行一勾函数名:cyInstanceDebugHook:

你可能感兴趣的:(拒绝重写,只想随心钩,一行一勾!---- 一款轻量级的iOS流程确认hook工具)