拒绝重写,只想随心钩,一行一勾!---- 一款轻量级的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 <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

关于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:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容

  • 背景 某年某月的某一天,产品小 S 向开发君小 Q 提出了一个简约而不简单的需求:扩大一下某个 button 的点...
    羁拥_f357阅读 240评论 0 0
  • 可否使用 == 来判断两个NSString类型的字符串是否相同?为什么? 不能。==判断的是两个变量的值的内存地址...
    渐z阅读 578评论 0 0
  • iOS中所有的手势操作都继承于UIGestureRecognizer,这个类本身不能直接使用。这个类中定义了这几种...
    Imkata阅读 1,087评论 0 1
  • 黑色的海岛上悬着一轮又大又圆的明月,毫不嫌弃地把温柔的月色照在这寸草不生的小岛上。一个少年白衣白发,悠闲自如地倚坐...
    小水Vivian阅读 3,088评论 1 5
  • 渐变的面目拼图要我怎么拼? 我是疲乏了还是投降了? 不是不允许自己坠落, 我没有滴水不进的保护膜。 就是害怕变得面...
    闷热当乘凉阅读 4,227评论 0 13