简单介绍一下 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
提供的用户交互接口里,主要可以分为两大类:
- Delegate 类(
UITableView
,UICollectionView
的点击事件,特点是方法名定死,使用weak
属性持有响应对象) - Target-Action 类(
UIControl
,UIGestureRecognizer
的回调事件,特点是方法名可自定义,方法参数可有可无,使用weak
属性持有响应对象,支持多个响应者)
此方案也对这几种接口提供了不同的 AOP 代码。
1. UITableView 与 UICollectionView
这两种对象归结到第一类中(下文主要讲解 UITableView
,UICollectionView
同理就不解释),业务通过实现 - tableView:didSelectRowAtIndex:
方法来捕获用户点击事件。此方法的方法签名(由返回值类型和参数类型编码而成)因 UITableViewDelegate
的定义而被定死,所以可以很好的完成 AOP 代码。
- 使用 runtime 对
-[UITableView setDelegate:]
进行方法交换,插入 delegate 的捕获代码。 - 当捕获到 delegate 对象时(一般为 ViewController),获取该对象的类。
- 构建临时方法名:
aop_tableView:didSelectRowAtIndex:
,判断 2 中的类是否有这个方法。 - 如果有,说明此类被处理过,则不继续。
- 如果没有,将预先写好的 static 函数,通过 runtime 构建新的
Method
实例(方法名是 3 中的方法名),添加到类中。 - 将 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: 继承导致重复埋点
如果某个业务代码如下:
如果出现了某种代码执行顺序:
- 子类实例化
- 父类实例化
则会出现如下情况:
- 捕获到子类的对象,发现没有经过 AOP,则进行 AOP 处理,产生如下代码:
捕获到父类,发现没有经过 AOP (步骤 1 是在子类处理,所以父类无法检测),则进行 AOP 处理,产生如下代码:
此时,如果 1 实例化出来的子类对象还存在,或者在这之后实例化了新的子类对象,对应的埋点代码逻辑会执行两次,逻辑如下:
缺陷 2: 如果业务手动执行 tableView:didSelectRowAtIndex:
也会触发埋点
手动执行应当是代码产生的,而非用户真实点击。即使正常开发不会这么做,但是如果真的这么做了,就会产生一次不必要的埋点数据。
缺陷 3: 如果业务使用了 _cmd 参数,可能取到错误的 SEL
上述文章中做了处理,不会有这种问题。但网络上依旧有使用
performSelector
方法或通过声明方法然后使用中括号语法来调用原方法代码,这种方式会导致传递给业务的_cmd
参数是 AOP 的SEL
,也就是上文的aop_tableView:didSelectRowAtIndexPath:
。如果业务方用到了这个_cmd
参数,则会出现和预期不一样的数据。
2. UIGestureRecognizer
手势的回调接口是 target-action,通过添加 target(回调对象) 和 action(对应的回调方法) 对,来完成手势触发的回调。手势可以归结到上述分类中的第二类。
和 UITableView
相比,最大的差异是方法名需要动态获取,所以需要一个新的 AOP 逻辑:
- 使用 runtime 对
-[UIGestureRecognizer initWithTarget:action:]
进行方法交换,插入捕获target
和action
的代码。 - 捕获到
action
时,添加特殊前缀,得到aop_action
,并判断target
的类是否拥有aop_action
方法。 - 如果有则说明此
target
对应的类已做 AOP 处理。 - 如果没有,则通过预先写好的 static 函数和
aop_action
创建一个Method
,添加到 target 的类中。 - 将 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 方法的方法)来完成埋点。具体如下:
- 使用 runtime 对
-[UIControl sendAction:to:forEvent:]
进行方法交换,插入捕获发送事件的代码。 - 捕获到发送事件时,埋点。
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 的无痕埋点。
在没有无痕埋点的情况下,Controller
和 UITableView
的持有关系如下:
为了尽可能的不修改 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,就会产生循环引用。
而大体的步骤如下:
- 使用 runtime 对
-[UITabelView setDelegate:]
进行方法交换,替换 setter。 - 创建 Proxy 对象。
- 将 delegate 传递给 Proxy 对象。
- 将 Proxy 当做新的 delegate 参数传递给原方法
- 将 TableView 使用关联对象强持有 Proxy
而 Proxy 要做的工作:
- 拦截
- tableView:didSelectRowAtIndexPath:
方法,并做埋点,同时将此事件转发给 View Controller - 由于拦截了 delegate,就会拦截所有 delegate 方法,所以 Proxy 要模拟 View Controller 对
UITableViewDelegate
协议中的几个方法的响应情况。 - 对于 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 ,我们也添加了一个对象。但基于 Target-Action 是个集合的关系,这个对象并非中间对象,而是附属对象:
大体步骤如下:
- 使用 runtime 对
- addTarget:action:
方法交换,插入捕获代码 - 当捕获到时,创建 Action 对象
- 将
[target class]
和action
记录到 Action 对象中(当做埋点参数) - 调用原方法,将 target 和 action 添加进 UIButton
- 调用原方法,将 Action 和
action:
添加进 UIButton
Action 对象实现大致如下:
- (void)action:(UIControl *)sender {
// 埋点
}
接下来,当按钮产生事件后,会依次执行 View Controller 的代码和 Action 的代码,Action 则可以通过步骤 3 记录的数据来完成埋点。
为了防止 Action 对象销毁,我们需要拿其中一个对象关联住 Action,但是用 View Controller 还是 UIButton 来持有需要进行一系列的场景模拟。
当 UIButton 持有 Action 时:
Button 先于 View Controller 销毁
Action 销毁,两处 target 持有关系断裂
View Controller 先于 Button 销毁
Action 没销毁,且依旧响应 UIButton 的点击事件(非预期效果)
当 View Controller 持有 Action 时:
Button 先于 View Controller 销毁
Action 没销毁,两处 target 持有关系断裂
View Controller 先于 Button 销毁
Action 销毁,两处 target 持有关系断裂
通过模拟,发现使用 View Controller 持有 Action 对象更合适
接下来还有一些细节要处理:
- View Controller 可以接受多个 Button 的点击事件,所以关联对象的 Key 需要动态生成一个唯一 Key。可以通过
VC类名.控件类名.方法名.ControlEvent名
来生成 Key。 - UIControl 支持 remove 操作,所以也要 hook remove 方法,删除 Action 对象。
- UIGestureRecognizer 有
- initWithTarget:action:
方法,也需要被 hook,然后按照- addTarget:action:
同样的方式处理 - 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 部分的实现。
再接下来的篇幅中,将会介绍无痕埋点如何生成一个唯一事件标识,以及如何在无痕埋点中携带一些业务数据。