iOS用户行为追踪——无侵入埋点

作者: RockyRock | 来源:发表于2017-03-25 13:06 被阅读1818次

    本文章系作者原创文章,如需转载学习,请注明该文章的原始出处和网址链接。
      在阅读的过程中,如若对该文章有不懂或值得优化的建议,欢迎大家加QQ:690091622 进行技术交流和探讨。


    前言:
      前几日做项目,需要做这样的一个功能:
        记录应用Crash之前用户操作的最后20步
      看到这样的需求,第一感觉就是有些懵,excuse me? 用户咋操作的我咋知道???应用啥时候Crash我咋知道???

    最后,经过各方查找资料,终于搞定了。
      先不多说,放一张控制台输出的运行结果的截图。

    User_Trace_Sequence.jpg

    1. 技术原理

    1.1 Method-Swizzling

    在Objective-C中调用一个方法,其实是向一个对象发送消息,查找消息的唯一依据是selector的名字。
      利用Objective-C的动态特性,可以实现在运行时偷换selector对应的方法实现,达到给方法hook的目的。
      每个类都有一个方法列表,存放着selector的名字和方法实现的映射关系。IMP有点类似函数指针,指向具体的Method实现。

    IMP.jpg
    1. method_exchangeImplementations 方法来交换2个方法中的IMP,
    2. class_replaceMethod 方法来修改类,
    3. method_setImplementation 方法来直接设置某个方法的IMP,

    其实,就是在程序运行中偷换了selector的IMP,如下图所示:

    IMP_exchange.jpg

    1.2 Target-Action

    对于一个给定的事件,UIControl会调用sendAction:to:forEvent:来将行为消息转发到UIApplication对象,再由UIApplication对象调用其sendAction:to:fromSender:forEvent:方法来将消息分发到指定的target上,而如果我们没有指定target,则会将事件分发到响应链上第一个想处理消息的对象上。
      而如果子类想监控或修改这种行为的话,则可以重写这个方法。

    2.实现分析

    用户的操作行为轨迹在应用上的体现无非就是以下这几种情况:

    • 点击了哪个按钮
    • 哪个页面跳转到哪个页面
    • 当前停留在是哪个界面

    1. 对于我们需要实现的功能中关于记录用户交互的操作,我们使用runtime中的方法hook下sendAction:to:forEvent:便可以知道用户进行了什么样的交互操作。
    这个方法对UIControl及继承于UIControl而实现的子类对象是有效的,比如UIButton、UISlider、UIDatePicker、UISegmentControl等。
      2. iOS中页面切换有两种方式:UIViewController中的presentViewController:animated:dismissViewController:completion:;UINavigationController中的pushViewController:animated:popViewControllerAnimated:
      但是,对于UIViewController来说,我们不对这两个方法hook,因为页面跳来跳去,记录下来的各种数据会很多很乱,不利于后续查看。所以hook下ViewDidAppear:这个方法知道哪个页面显示了就足够了,而所有显示的页面按时间顺序连成序列,便是用户操作后应用中的页面跳转的轨迹。

    这个解决方案看起来很不错,这样既没有在项目中到处插入埋点函数,也没有给项目增加多少代码量,是一个两全其美的办法。

    3. 代码实现

    以下是对三个类进行hook的主要实现代码。

    3.1. UIApplication

    @interface UIApplication (HLCHook) 
    + (void)hookUIApplication;
    @end
    @implementation UIApplication (HLCHook)
    + (void)hookUIApplication
    {
        Method controlMethod = class_getInstanceMethod([UIApplication class], @selector(sendAction:to:from:forEvent:));
        Method hookMethod = class_getInstanceMethod([self class], @selector(hook_sendAction:to:from:forEvent:));
        method_exchangeImplementations(controlMethod, hookMethod);
    } 
    
    - (BOOL)hook_sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;
    {
        NSString \*actionDetailInfo = [NSString stringWithFormat:@" %@ - %@ - %@", NSStringFromClass([target class]), NSStringFromClass([sender class]), NSStringFromSelector(action)];
        NSLog(@"%@", actionDetailInfo);
        return [self hook_sendAction:action to:target from:sender forEvent:event];
    }
    @end
    

    3.2. UIViewController

    @interface UIViewController (HLCHook)
    + (void)hookUIViewController;
    @end 
    @implementation UIViewController (HLCHook)
    
    + (void)hookUIViewController
    {
        Method appearMethod = class_getInstanceMethod([self class], @selector(viewDidAppear:));
        Method hookMethod = class_getInstanceMethod([self class], @selector(hook_ViewDidAppear:));
        method_exchangeImplementations(appearMethod, hookMethod);
    } 
    - (void)hook_ViewDidAppear:(BOOL)animated
    {
        NSString \*appearDetailInfo = [NSString stringWithFormat:@" %@ - %@", NSStringFromClass([self class]), @"didAppear"];
        NSLog(@"%@", appearDetailInfo);
        [self hook_ViewDidAppear:animated];
    }
    @end
    

    3.3. UINavigatinoController

    @interface UINavigationController (HLCHook)
    + (void)hookUINavigationController_push;
    + (void)hookUINavigationController_pop;
    @end 
    
    @implementation UINavigationController (HLCHook)
    + (void)hookUINavigationController_push
    {
        Method pushMethod = class_getInstanceMethod([self class], @selector(pushViewController:animated:));
        Method hookMethod = class_getInstanceMethod([self class], @selector(hook_pushViewController:animated:));
        method_exchangeImplementations(pushMethod, hookMethod);
    } 
    - (void)hook_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
    {
        NSString \*popDetailInfo = [NSString stringWithFormat: @"%@ - %@ - %@", NSStringFromClass([self class]), @"push", NSStringFromClass([viewController class])];
        NSLog(@"%@", popDetailInfo);
        [self hook_pushViewController:viewController animated:animated];
    } 
    
    + (void)hookUINavigationController_pop
    {
        Method popMethod = class_getInstanceMethod([self class], @selector(popViewControllerAnimated:));
        Method hookMethod = class_getInstanceMethod([self class], @selector(hook_popViewControllerAnimated:));
        method_exchangeImplementations(popMethod, hookMethod);
    } 
    - (nullable UIViewController *)hook_popViewControllerAnimated:(BOOL)animated
    {
        NSString \*popDetailInfo = [NSString stringWithFormat:@"%@ - %@", NSStringFromClass([self class]), @"pop"];
        NSLog(@"%@", popDetailInfo);
        return [self hook_popViewControllerAnimated:animated];
    }
    @end
    

    至此,核心代码已经完成了。
      那么如何使用该功能来记录用户操作轨迹呢?
      在appDelegate.m文件中的application:didFinishLaunchingWithOptions:添加如下四行代码: <pre>
    [UIApplication hookUIApplication];
    [UIViewController hookUIViewController];
    [UINavigationController hookUINavigationController_push];
    [UINavigationController hookUINavigationController_pop];
    </pre>
      启动程序,并观察控制台输出,神奇的事情将会发生,用户的每一次操作和页面跳转都会被记录下来。

    提醒

    1.UITabBarItem
      当用户点击了UITabBarItem时,会同时记录三次事件,分别是:

    • _buttonDown:
    • _buttonUp:
    • _tabBarItemClicked:

    所以,对于这三个事件,我们可以只需保留一个,将其他两个在记录的时候过滤掉。若记录空间有限,过滤掉冗余的信息,这样可以在有限的记录空间上记录更多的用户操作数据。

    总结

    1.hook方式非常强大,几乎可以截取任何用户想截取的消息事件,但是,每次触发hook,必然存在置换IMP整个过程,频繁的置换IMP必然会影响到应用及手机资源的消耗,不到非不得已,建议少用。
      2.什么时候用hook的方式来埋点呢?例如,当应用有10个页面,而我们只需在其中两个页面上埋点,那么就没必要用这种方式了。具体什么时候用,由开发者根据项目实际需求来权衡,我们的原则就是要力图资源消耗最少。
      3.对于View上的手势触摸事件touchBegan:withEvent:等,这种方式截取不到消息。之所以暂时不做,也是因为消耗的问题,因为苹果手机都是触摸屏的,每进行一次触摸屏幕,不管会不会产生交互事件都会触发该事件的。有兴趣的小伙伴可以根据以上提供的思路来自己尝试实现下,测试下系统消耗,看适不适合来做。

    • 以上内容都是手动输入的,文字个别错误还请见谅。
    • 如果技术说明上有不正确之处,欢迎批评指正,可在下方留言,谢谢!

    相关文章

      网友评论

      • justjustjust:你好,我尝试hook UIResponder 的TouchBegan方法,但是似乎会导致所有点击操作的响应链不再往下传递,各种点击方法都失效了,不知道楼主有没有好的办法

        + (void)trackingUIResponder {

        Method touchBeganMethod = class_getInstanceMethod([self class], @Selector(touchesBegan:withEvent:));
        Method hookMethod = class_getInstanceMethod([self class], @Selector(hook_TouchesBegan:withEvent:));
        method_exchangeImplementations(touchBeganMethod, hookMethod);
        }

        - (void)hook_TouchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

        NSString *touchInfo = [NSString stringWithFormat:@" %@ - %@", NSStringFromClass([self class]), @"touchBegan"];
        [self hook_TouchesBegan:touches withEvent:event];
        }
      • abfc95f97191:我一个界面里面有两三个viewcontroller对象叠加的,那么如何统计在一个页面停留的时间呢?因为通过appear和disappear都是不准的
      • 推遍天下无敌手:楼主,这几天在搞无埋点数据收集,看到您这种方式,感觉有突破口了,但是发现这样收集的话是无法定位到具体用户点击了具体哪个按钮的呀,请问一下有思路么
        推遍天下无敌手:@Rocky_Hui 函数名是一样的,只是传的参数不一样,看来只能从这里入手了,O(∩_∩)O谢谢
        RockyRock:@推遍天下无敌手 每个按钮绑定的action函数名应该是不一样的,可以根据调用的函数名反推出是哪个按钮
      • 乐视薯片:分别获取UITextView 、UITableView的delegate,但是发现TableView 竟然会执行UITextView的setDelegate 方法,说不太清楚,可以帮我看看吗?
      • 乐视薯片:你好,我想问一下,对于UITextView 、UITextView 这样的怎么获取到输入的文本呢,应该拦截什么方法呢,还是有其他实现方式?
        乐视薯片:@Rocky_Hui 如果没有写代理方法呢,因为有些不需要设置代理,这种怎么跟踪呢
        RockyRock:@初心_媛 回调方法分别是:textViewDidChange、textFieldDidEndEdit。
        当然,前提是你代理对象中有实现该方法。
        RockyRock:@初心_媛 这类输入控件都有代理方法的,可以从代理方法中获取。想统一去获取,hook原理是一样的,可以仿照我文中提供的例子。
      • d9a1e9f5d856:你好,我交换了pop方法,然后在页面中自定义按钮调用了系统的pop就崩了,这是怎么回事啊
        RockyRock:@乡村气息CODER 介意把代码片段发到我QQ邮箱吗?我帮你调下。
        d9a1e9f5d856:@Rocky_Hui 没有,应该是运行时的问题,用自定义方法调用nav的pop就会崩,其他方法没问题,试了很多方法,有一种方法是强制放到主线程执行pop,有一阵子可以,后来又是不行。你可以试一下,用自定义返回按钮,就会崩溃
        RockyRock:有打印的错误信息吗?

      本文标题:iOS用户行为追踪——无侵入埋点

      本文链接:https://www.haomeiwen.com/subject/nczqottx.html