iOS 无痕埋点解决方案—— AOP 篇(1)

简单介绍一下 AOP

无痕埋点最重要的技术是将埋点代码从业务代码中剥离,放到独立的模块中的技术。写业务的同学只需按照正常的设计思路编写业务代码,写埋点的同学通过 AOP 技术往业务代码中插入埋点代码。

AOP 全称叫 Aspect-Oriented Programming,中文名叫面向切面编程。网络上有关于面向切面编程的名词解释。通俗一点,即可使用下图来说明。

如果希望某个函数的实现逻辑如下:

希望逻辑

代码块 1代码块 2 之间的逻辑毫无干系,是两块独立的逻辑,那么同时写在一个文件的一个函数中会造成代码臃肿,不易维护。

如果能将两个代码分离,编写在不同文件中,即可简化逻辑,增加代码的可阅读性:

编码时

上图为编码时的代码逻辑图,两份代码块写在两个不同的方法中。当使用 AOP 技术后,软件在运行时就会将两份逻辑合并到一个方法中,变成当初期望的逻辑的样子。

运行时

在 iOS 平台中,AOP 一般使用基于 ObjC 动态性质以及消息转发的功能实现的。ObjC 的动态性就是所谓的 Runtime。利用 Runtime 可以对一个类的某个方法进行修改,从而实现运行时的逻辑变化。而消息转发则是对面向对象的方法调用的拦截,增加自己特定的处理。Runtime 配合消息转发就能实现 iOS 平台上基本的 AOP 需求。

目前,能够完成 iOS 的 AOP 的库有:ReactiveCocoa、RxSwift、Aspects 等等。RAC 和 Rx 系列则是为了响应式编程而出现的,他们功能强大,但也很重。如果仅仅只是为了无痕埋点,引入响应式变成则需要很大的成本,也是大材小用。Aspects 则是专门为 AOP 设计的,底层学习苹果 KVO 实现机制,可以以对象为颗粒进行 AOP。曾经我们的项目引入了这个库,但如果业务已经实现了 - (void)forwardInvocation:(NSInvocation *)invocation 方法,又被 Aspects 进行 AOP 后,业务的消息转发机制就会被覆盖,造成不可预计的后果*。

*关于 Aspects 的 bug:

在去年使用时,发现 Aspects 与 JSPatch(都使用了 - forwardInvocation: 方法)不兼容。主要表现是当 JSPatch 先生效,Aspects 后生效的情况下,JSPatch 的消息转发机制断裂,无法完成补丁修复,甚至 Crash。

而我们在业务上也用到了 - forwardInvocation: 方法,即使没有引入 JSPatch,也依旧会出现同样的问题。

在 Issue 里查找了一番,发现是 class_replaceMethod 的 bug (Merge),但至今都没有修复这个问题(已并入 master,但是在 tag 1.4.2 之后并入的,而 CocoaPods 上最新发布的版本是 1.4.1)。

所以在不引入臃肿的三方库外,如何既安全又准确地使用 AOP 进行无痕埋点,就是本文即将讨论的内容。

大部分的无痕埋点实现方案的弊端

在丢弃 Aspects 后,就寻找无痕埋点的解决方案。百度搜索 iOS 无痕埋点 关键字,得到的结果几乎一样。贴上搜索结果第一篇的地址:简书 - iOS无痕埋点方案分享探究 by: SandyLoo

在此先非常感谢作者的分享,此方案给我提供了几个版本的稳定的无痕埋点。

但是在某次埋点 SDK 重构的过程中,发现了此方案的多处弊端,由于网络上大部分的无痕埋点方案都与此大同小异,所以就用此例子来分析这系列的方案存在的隐患和缺陷。

方案基本介绍

无痕埋点主要是记录用户的操作,而用户的操作无非就是按钮的点击和列表的选择,所以无痕埋点的需求即是对用户点击的响应方法进行 AOP 处理,插入对应埋点方法。

UIKit 提供的用户交互接口里,主要可以分为两大类:

  1. Delegate 类(UITableViewUICollectionView 的点击事件,特点是方法名定死,使用 weak 属性持有响应对象)
  2. Target-Action 类(UIControlUIGestureRecognizer 的回调事件,特点是方法名可自定义,方法参数可有可无,使用 weak 属性持有响应对象,支持多个响应者)

此方案也对这几种接口提供了不同的 AOP 代码。

1. UITableView 与 UICollectionView

这两种对象归结到第一类中(下文主要讲解 UITableViewUICollectionView 同理就不解释),业务通过实现 - tableView:didSelectRowAtIndex: 方法来捕获用户点击事件。此方法的方法签名(由返回值类型和参数类型编码而成)因 UITableViewDelegate 的定义而被定死,所以可以很好的完成 AOP 代码。

  1. 使用 runtime 对 -[UITableView setDelegate:] 进行方法交换,插入 delegate 的捕获代码。
  2. 当捕获到 delegate 对象时(一般为 ViewController),获取该对象的类。
  3. 构建临时方法名:aop_tableView:didSelectRowAtIndex:,判断 2 中的类是否有这个方法。
  4. 如果有,说明此类被处理过,则不继续。
  5. 如果没有,将预先写好的 static 函数,通过 runtime 构建新的 Method 实例(方法名是 3 中的方法名),添加到类中。
  6. 将 5 添加的方法和原方法进行方法交换。

其中步骤 5 中预先写好的 static 函数大致如下:

static void AOP_tableView_didSelectRowAtIndex(id<UITableViewDelegate> self, SEL _cmd, UITableView *tableView, NSIndexPath *indexPath) {
    //  先调用业务代码
    SEL origSel = @selector(aop_tableView:didSelectRowAtIndex:);
    [self performSelector:origSel withObject:tableView withObject:indexPath];
    
    //  再埋点
    [[Tracker sharedTracker] trackEvent:@"xxxxx"];
}

分析一下此方案的弊端:

缺陷 1: 继承导致重复埋点

如果某个业务代码如下:

业务代码

如果出现了某种代码执行顺序:

  1. 子类实例化
  2. 父类实例化

则会出现如下情况:

  1. 捕获到子类的对象,发现没有经过 AOP,则进行 AOP 处理,产生如下代码:
子类对象被 AOP

捕获到父类,发现没有经过 AOP (步骤 1 是在子类处理,所以父类无法检测),则进行 AOP 处理,产生如下代码:

父类对象被 AOP

此时,如果 1 实例化出来的子类对象还存在,或者在这之后实例化了新的子类对象,对应的埋点代码逻辑会执行两次,逻辑如下:

最终子类会执行两次埋点逻辑

缺陷 2: 如果业务手动执行 tableView:didSelectRowAtIndex: 也会触发埋点

手动执行应当是代码产生的,而非用户真实点击。即使正常开发不会这么做,但是如果真的这么做了,就会产生一次不必要的埋点数据。

缺陷 3: 如果业务使用了 _cmd 参数,可能取到错误的 SEL

上述文章中做了处理,不会有这种问题。但网络上依旧有使用 performSelector 方法或通过声明方法然后使用中括号语法来调用原方法代码,这种方式会导致传递给业务的 _cmd 参数是 AOP 的 SEL,也就是上文的 aop_tableView:didSelectRowAtIndexPath:。如果业务方用到了这个 _cmd 参数,则会出现和预期不一样的数据。

2. UIGestureRecognizer

手势的回调接口是 target-action,通过添加 target(回调对象) 和 action(对应的回调方法) 对,来完成手势触发的回调。手势可以归结到上述分类中的第二类。

UITableView 相比,最大的差异是方法名需要动态获取,所以需要一个新的 AOP 逻辑:

  1. 使用 runtime 对 -[UIGestureRecognizer initWithTarget:action:] 进行方法交换,插入捕获 targetaction 的代码。
  2. 捕获到 action 时,添加特殊前缀,得到 aop_action,并判断 target 的类是否拥有 aop_action 方法。
  3. 如果有则说明此 target 对应的类已做 AOP 处理。
  4. 如果没有,则通过预先写好的 static 函数和 aop_action 创建一个 Method,添加到 target 的类中。
  5. 将 4 添加的 aop_action 方法和原 action 方法进行方法交换。

其中步骤 4 中的方法大致如下:

static void AOP_gestureRecognizerAction(id self, SEL _cmd, UIGestureRecognizer *sender) {
    //   调用原方法
    NSString *sel = [@"aop_" stringByAppendingString:NSStringFromSelector(_cmd)];
    [self performSelector:NSSelectorFromString(sel) withObject:sender];
    
    //  埋点
    [[Tracker sharedTracker] trackEvent:@"xxxxx"];
}

缺陷

手势的缺陷将与 UIControl 的缺陷一并介绍。

3. UIControl

UIControl 和手势类似,也是使用 target-action 接口来回调控件状态变化事件。但这里需要和手势分开介绍。

与手势不同的是,UIControl 并不是通过动态添加方法来完成无痕埋点,而是直接拦截系统方法(调用 action 方法的方法)来完成埋点。具体如下:

  1. 使用 runtime 对 -[UIControl sendAction:to:forEvent:] 进行方法交换,插入捕获发送事件的代码。
  2. 捕获到发送事件时,埋点。

UIGestureRecognizer 和 UIControl 的缺陷

缺陷1: 没有携带 sender 参数的 action 可能会导致手势埋点闪退

由于手势预先写好了函数,而函数的参数列表包含了手势本身(也就是通常的 sender 参数),但是业务写的方法不一定都包含 sender 参数,所以这里会有隐患。

缺陷2: 手势埋点会遗漏使用 addTarget:action: 方法添加的回调

上述文章中只 hook 了初始化方法,并没有 hook add 方法,所以如果业务使用 init 方法创建对象,再用 add 方法添加回调,埋点就会遗漏。

但这不是大问题。

缺陷3: 当业务同时绑定手势和控件到同一个方法时可能会导致闪退

如果手势和按钮绑定到同一个 action 时,并且手势和按钮都被当做 sender 参数传入到 action 中。当按钮点击时,就会触发手势埋点,如果手势埋点取了手势的 view 就会闪退:

static void AOP_gestureRecognizerAction(id self, SEL _cmd, UIGestureRecognizer *sender) {
    //   用户点击了按钮,但是还是走了手势的埋点,并且 sender 是个按钮对象

    //   调用原方法
    NSString *sel = [@"aop_" stringByAppendingString:NSStringFromSelector(_cmd)];
    [self performSelector:NSSelectorFromString(sel) withObject:sender];

    //  取 View 来构建埋点参数
    UIView *view = sender.view;   //  此处会闪退,因为传进来可能是 UIButton
    UIViewController *controller = sender.nextResponse;
    [[Tracker sharedTracker] trackEvent:@"xxxxx"];
}

手势和按钮绑定到同一个 action 的业务场景举例:ActionSheet

如果你自己实现了 ActionSheet,你可能会在 ActionSheet 上方阴影部分添加手势来隐藏 ActionSheet ,同时你也会增加一个取消按钮,来隐藏 ActionSheet。这两个绑定的 action 可能会是同一个方法。

缺陷4: 如果缺陷 2 不闪退,UIControl 触发事件会导致两次埋点

即使缺陷 3 没有导致闪退,UIControl 的埋点在 - sendAction:to:forEvent: 方法中,手势的埋点在 action 方法中。用户点击了按钮,先执行 - sendAction:to:forEvent: 的埋点,然后执行业务的 action,也就会执行手势的埋点。这就导致产生了一次垃圾数据

缺陷5: 如果业务手动调用 action 会导致不必要的手势埋点

如果业务主动调了一次 action,并非用户实际操作,手势埋点替换 action 的方式下也会被埋点。这样也产生了一次垃圾数据。

缺陷6: 移除 target-action 后埋点依旧有效

移除 target-action 时并不能恢复被 runtime 改造的 action 方法,所以埋点依旧生效。

无痕埋点方案实现

为了让业务代码安全无误的执行,我们必须尽可能地不修改业务代码,也就是不会对业务代码进行 Runtime 处理。

在遵循此原则下,我们设计了如下的无痕埋点解决方案。

Delegate 系列无痕埋点

UITableView 介绍 Delegate 的无痕埋点。

在没有无痕埋点的情况下,ControllerUITableView 的持有关系如下:

ViewController 和 Table View 持有关系

为了尽可能的不修改 View Controller 的内容,我们为 delegate 那条虚线添加了一个中间对象 Proxy:

插入一个中间对象

同时为了防止 Proxy 销毁,我们使用关联对象让 TableView 强引用 Proxy。

为何不是 Controller 强持有 Proxy?

因为一个 table view 只能有一个 delegate,但是一个 controller 可以成为多个 table view 的 delegate

为何 Proxy 是弱引用 View Controller 而不是强引用?

因为不管是 VC 持有 Proxy 还是 TableView 持有 Proxy,只要 Proxy 持有 VC,就会产生循环引用。

而大体的步骤如下:

  1. 使用 runtime 对 -[UITabelView setDelegate:] 进行方法交换,替换 setter。
  2. 创建 Proxy 对象。
  3. 将 delegate 传递给 Proxy 对象。
  4. 将 Proxy 当做新的 delegate 参数传递给原方法
  5. 将 TableView 使用关联对象强持有 Proxy

而 Proxy 要做的工作:

  1. 拦截 - tableView:didSelectRowAtIndexPath: 方法,并做埋点,同时将此事件转发给 View Controller
  2. 由于拦截了 delegate,就会拦截所有 delegate 方法,所以 Proxy 要模拟 View Controller 对 UITableViewDelegate 协议中的几个方法的响应情况。
  3. 对于 View Controller 实现的方法,需要将事件转发给 View Controller。

Proxy 的代码大致如下:

//  self.delegate 就是实际的 View Controller

//  拦截此 delegate 方法
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    if ([self.delegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]) {
        [self.delegate tableView:tableView didSelectRowAtIndexPath:indexPath];
    }
    
    //  埋点
}

//  使用以下四个方法来模拟 delegate 其他方法的响应以及转发这些方法

- (BOOL)conformsToProtocol:(Protocol *)aProtocol {
    BOOL conforms = [super conformsToProtocol:aProtocol];
    if (!conforms) {
        conforms = [self.delegate conformsToProtocol:aProtocol];
    }
    return conforms;
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    BOOL hasResponds = [super respondsToSelector:aSelector];
    hasResponds |= [self.delegate respondsToSelector:aSelector];
    return hasResponds;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    NSMethodSignature *methodSignature = [super methodSignatureForSelector:selector];
    if (!methodSignature) {
        if ([self.delegate respondsToSelector:selector]) {
            methodSignature = [(NSObject *)self.delegate methodSignatureForSelector:selector];
        }
    }
    return methodSignature;
}

- (void)forwardInvocation:(NSInvocation*)invocation {
    [invocation invokeWithTarget:self.delegate];
}

经过以上处理,ViewController 的代码无需任何改动,通过中间对象来拦截 - tableView:didSelectRowAtIndexPath: 方法来实现埋点。遵循尽可能不修改业务代码的原则。

Target-Action 系列无痕埋点

UIGestureRecognizer 和 UIControl 的埋点实现方案相同,此处仅介绍 UIButton 的无痕埋点。

在没有无痕埋点的情况下,UIViewController 和 UIButton 的持有关系如下:

View Controller 和 Button 持有关系

同样,为了不修改 View Controller ,我们也添加了一个对象。但基于 Target-Action 是个集合的关系,这个对象并非中间对象,而是附属对象:

增加一个 Action 对象

大体步骤如下:

  1. 使用 runtime 对 - addTarget:action: 方法交换,插入捕获代码
  2. 当捕获到时,创建 Action 对象
  3. [target class]action 记录到 Action 对象中(当做埋点参数)
  4. 调用原方法,将 target 和 action 添加进 UIButton
  5. 调用原方法,将 Action 和 action: 添加进 UIButton

Action 对象实现大致如下:

- (void)action:(UIControl *)sender {
    // 埋点
}

接下来,当按钮产生事件后,会依次执行 View Controller 的代码和 Action 的代码,Action 则可以通过步骤 3 记录的数据来完成埋点。

为了防止 Action 对象销毁,我们需要拿其中一个对象关联住 Action,但是用 View Controller 还是 UIButton 来持有需要进行一系列的场景模拟。

当 UIButton 持有 Action 时:

image.png

Button 先于 View Controller 销毁

Action 销毁,两处 target 持有关系断裂

View Controller 先于 Button 销毁

Action 没销毁,且依旧响应 UIButton 的点击事件(非预期效果)

当 View Controller 持有 Action 时:

image.png

Button 先于 View Controller 销毁

Action 没销毁,两处 target 持有关系断裂

View Controller 先于 Button 销毁

Action 销毁,两处 target 持有关系断裂

通过模拟,发现使用 View Controller 持有 Action 对象更合适

接下来还有一些细节要处理:

  1. View Controller 可以接受多个 Button 的点击事件,所以关联对象的 Key 需要动态生成一个唯一 Key。可以通过 VC类名.控件类名.方法名.ControlEvent名 来生成 Key。
  2. UIControl 支持 remove 操作,所以也要 hook remove 方法,删除 Action 对象。
  3. UIGestureRecognizer 有 - initWithTarget:action: 方法,也需要被 hook,然后按照 - addTarget:action: 同样的方式处理
  4. Action 对象可以根据自己的埋点需求,通过属性来存储埋点时需要的参数。我们用 Action 记录了 VC 的类名以及方法名称,供生成埋点唯一 code。

解决的缺陷

现在再回来分析之前的缺陷是否还存在:

缺陷1(解决): 没有携带 sender 参数的 action 可能会导致手势埋点闪退

埋点和业务走两套 target-action,不会因为是否包含 sender 参数导致出错

缺陷2(解决): 手势埋点会遗漏使用 addTarget:action: 方法添加的回调

不管是手势还是控件,都可以埋到 addTarget:action: 的方法

缺陷3(解决): 当业务同时绑定手势和控件到同一个方法时可能会导致闪退

手势埋点和控件埋点的 Action 对象完全独立,互不干扰。自然不会因为 sender 参数类型不对导致闪退

缺陷4(解决): 如果缺陷 2 不闪退,UIControl 触发事件会导致两次埋点

手势埋点和控件埋点的 Action 对象完全独立,互不干扰。所以手势触发时不会执行按钮埋点,按钮触发时不会执行手势埋点。

缺陷5(解决): 如果业务手动调用 action 会导致不必要的埋点

手势埋点和控件埋点均没有修改业务代码,所以业务如果自己调用了 action 也不会触发埋点。

缺陷6(解决): 移除 target-action 后埋点依旧有效

增加对 remove 的监听,实现埋点和 target-action 同步增删。

总结

经过分析,最终产生的无痕埋点方案安全高效,没有大量的方法替换的操作,对象销毁后 AOP 环境也会跟着销毁,并且适应各种业务场景。

以上就是 iOS 端无痕埋点解决方案 AOP 部分的实现。

再接下来的篇幅中,将会介绍无痕埋点如何生成一个唯一事件标识,以及如何在无痕埋点中携带一些业务数据。

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

推荐阅读更多精彩内容