1. 埋点方案
-
代码埋点
由开发人员在触发事件的具体方法里,添加多行代码把需要上传的参数上报至服务端。
-
可视化埋点
根据标识来识别每一个事件, 针对指定的事件进行取参埋点。而事件的标识与参数信息都写在配置表中,通过动态下发配置表来实现埋点统计。
-
无埋点
无埋点并不是不需要埋点,更准确的说应该是“全埋”, 前端的任意一个事件都被绑定一个标识,所有的事件都别记录下来。通过定期上传记录文件,配合文件解析,解析出来我们想要的数据, 并生成可视化报告 , 因此实现“无埋点”统计。
2. 方案选择
通常业务都需要加埋点统计事件,但在每个业务类里埋点会导致每个页面内耦合了大量的无关业务的埋点代码使得代码不够整洁,所以放弃了代码埋点。
考虑到无埋点成本较高,后期解析也复杂,选择了可视化埋点,即通过配置事件唯一标识,设置需要埋点分析的业务。
2.1 实现可视化埋点核心问题
-
封装埋点组件,降低耦合 -
如何实现后台配置唯一标识 -
埋点上报
2.2 针对第一个问题想到的方案如下:
-
每个业务页面添加一个埋点类,单独将埋点的方法提取到这个类中。 -
利用 Runtime
在底层进行方法拦截,从而添加埋点代码。
结合AOP的核心思想:将应用程序中的业务逻辑同对其提供支持的通用服务进行分离,最后采用了第2种方案。
2.3 配置唯一标识问题
唯一标识的组成方式主要是又 target + action
来确定, 即任何一个事件都存在一个 target
与 action
。在此引入 AOP 编程,AOP(Aspect-Oriented-Programming)
即面向切面编程的思想,基于 Runtime
的 Method Swizzling
能力,来 hook
相应的方法,从而在 hook
方法中进行统一的埋点处理。例如所有的按钮被点击时,都会触发 UIApplication
的 sendAction
方法,我们 hook
这个方法,即可拦截所有按钮的点击事件。
2.3.1 唯一标识(viewPath)的获取:
整个 APP 的视图结构可以看成是一颗树(viewTree
),树的根节点就是 UIWindow
,树的枝干由 UIViewController
及 UIView
组成,树的叶节点都是由 UIView
组成。
那么在 viewTree
中用什么信息来表示其中任意一个 view
的位置呢?很容易想到的就是使用目标 view
到根之间的每个节点的深度(层次)组成一个路径,而节点的深度(层次)是指此节点在父节点中的 index
。这样确实能够唯一的表示此 view
了,但是有一个缺点:它的可读性很差。因此在此基础上又增加了每个节点的名称,节点的名称由当前节点的 view
的类名来表示。同时在开头都添加了一个页面名称作为标识。
因此,在 viewTree
中,由一个 view
到根节点之间的每个节点的名称与深度(层次)共同组成的信息构成了此 view
的 viewPath
。另外,由于在做 view 的统计分析时,都是以页面为单位的,因此 SDK
在生成 viewPath
时,只到 view 所在的 UIViewController
级别,而非根部的 UIWindow
。这样做也在一定程度上减少了 viewPath
的长度。
UITableView
和 UICollectionView
的树级关系没有到每个具体的 cell
,避免产生很多无用的 id
,而是将 indexpath
作为描述信息传入。实现逻辑如下图:
2.3.4 唯一标识的作用主要分为两个部分
-
事件的锁定
事件的锁定主要是靠 “事件唯一标识符”来锁定,而事件的唯一标识是由我们写入配置表中的。
-
埋点数据的上报。
埋点数据的数据又分为两种类型: 固定数据与可变的业务数据, 而固定数据我们可以直接写到配置表中, 通过唯一标识来获取。而对于业务数据,数据是有持有者的, 例如我们 Controller
的一个属性值, 或者数据在 Model
的某一个层级。就可以通过 KVC
的的方式来递归获取该属性的值来取到业务数据。
2.4 埋点上报
自定义埋点上报数据类型,上报到 elastic
,后台进行数据分析
3. 实现部分
3.1 SDK 架构
3.2 技术原理
3.2.1 Method-Swizzling
OC
中的方法调用其实是向一个对象发送消息 ,利用 OC
的动态性可以实现方法的交换。
-
用 method_exchangeImplementations
方法来交换两个方法中的IMP -
用 class_replaceMethod
方法来替换类的方法, -
用 method_setImplementation
方法来直接设置某个方法的IMP
3.2.2 Target-Action
按钮的点击事件,UIControl
会调用 sendAction:to:forEvent:
来将行为消息转发到 UIApplication
,再由 UIApplication
调用其 sendAction:to:fromSender:forEvent:
方法来将消息分发到指定的 target
上。
3.3 分析及实现
3.3.1 需要添加埋点统计的地方
-
button
相关的点击事件 -
页面进入、页面推出 -
tableView
的点击 -
collectionView
的点击 -
手势相关事件
3.3.2 分析
-
对于用户交互的操作,我们使用 runtime
对应的方法hook
下sendAction:to:forEvent:
便可以得到进行的交互操作。这个方法对UIControl
及继承UIControl
的子类对象有效,如:UIButton
、UISlider
等。 -
对于 UIViewController
,hook
下ViewDidAppear:
这个方法知道哪个页面显示了就足够了。 -
对于 tableview
及collectionview
,我们hook下setDelegate:
方法。检测其有没有实现对应的点击代理,因为tableView:didSelectRowAtIndexPath:
及collectionView:didSelectItemAtIndexPath:
是option
的不是必须要实现的。 -
对于手势,我们在创建的时候进行 hook
,方法为initWithTarget:action:
。
3.3.3 实现原理
用运行时方法替换方法实现无侵入的埋点方法。
实现原理图:
具体实现方法:
创建一个运行时方法替换类 HGMethodSwizzingTool
,实现替换的方法 `swizzingForClass: originalSel: swizzingSel:``
#import "LZMethodSwizzingTool.h"
#import <objc/runtime.h>
@implementation LZMethodSwizzingTool
+ (void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector {
Class class = cls;
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzingMethod = class_getInstanceMethod(class, swizzingSelector);
BOOL addMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzingMethod), method_getTypeEncoding(swizzingMethod));
if (addMethod) {
class_replaceMethod(class, swizzingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzingMethod);
}
}
@end
这个方法利用运行时 method_exchangeImplementations
进行交换,当原方法被调用时,就会 hook
到指定的新方法去执行。
3.3.4 埋点分类实现
1. UIViewController+Track(页面进入、页面推出)
@implementation UIViewController (Track)
+ (void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalWillAppearSelector = @selector(viewWillAppear:);
SEL swizzingWillAppearSelector = @selector(hg_viewWillAppear:);
[LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillAppearSelector swizzingSel:swizzingWillAppearSelector];
SEL originalWillDisappearSel = @selector(viewWillDisappear:);
SEL swizzingWillDisappearSel = @selector(hg_viewWillDisappear:);
[LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillDisappearSel swizzingSel:swizzingWillDisappearSel];
SEL originalDidLoadSel = @selector(viewDidLoad);
SEL swizzingDidLoadSel = @selector(hg_viewDidLoad);
[LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSel swizzingSel:swizzingDidLoadSel];
});
}
- (void)hg_viewWillAppear:(BOOL)animated {
[self hg_viewWillAppear:animated];
//埋点实现区域
[self dataTrack:@"viewWillAppear"];
}
- (void)hg_viewWillDisappear:(BOOL)animated {
[self hg_viewWillDisappear:animated];
//埋点实现区域
[self dataTrack:@"viewWillDisappear"];
}
- (void)hg_viewDidLoad {
[self hg_viewDidLoad];
//埋点实现区域
[self dataTrack:@"viewDidLoad"];
}
- (void)dataTrack:(NSString *)methodName {
NSString *identifier = [NSString stringWithFormat:@"%@/%@",[[LZFindVCManager currentViewController] class],methodName];
NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"ViewController"] objectForKey:identifier];
if (eventDict) {
NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
//预留参数配置,以后拓展
NSDictionary *param = [eventDict objectForKey:@"eventParam"];
__block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
[param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
//在此处进行属性获取
id value = [LZCaptureTool captureVarforInstance:self varName:key];
if (key && value) {
[eventParam setObject:value forKey:key];
}
}];
if (eventParam.count) {
NSLog(@"identifier:%@-------useDefind:%@----eventParam:%@",identifier,useDefind,eventParam);
}
}
}
@end
Category
在 +openTrackSelector()
方法里使用了 HGMethodSwizzingTool
进行方法替换,在替换的方法里执行需要埋点的方法 - (void)dataTrack:(NSString *)methodName
实现埋点。这样每个 UIViewController
生命周期到了 ViewWillAppear
都会执行埋点的方法。
在这里,我们是通过类名 NSStringFromClass([self class])
来区分不同的控制器的。
2. UIControl+Track(button相关的点击事件)
@implementation UIControl (Track)
+ (void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalSelector = @selector(sendAction:to:forEvent:);
SEL swizzingSelector = @selector(hg_sendAction:to:forEvent:);
[LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
});
}
- (void)hg_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
[self hg_sendAction:action to:target forEvent:event];
//埋点实现区域====
//页面/方法名/tag用来区分不同的点击事件
NSString *identifier = [NSString stringWithFormat:@"%@/%@/%@", [target class], NSStringFromSelector(action),@(self.tag)];
if ([target isKindOfClass:[UIView class]]) {
UIView *view = (id)[target superview];
while (view.nextResponder) {
identifier =[NSString stringWithFormat:@"%@/%@",NSStringFromClass(view.class),identifier];
if ([view.class isSubclassOfClass:[UIViewController class]]) {
break;
}
view = (id)view.nextResponder;
}
}
NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"Action"] objectForKey:identifier];
if (eventDict) {
NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
//预留参数配置,以后拓展
NSDictionary *param = [eventDict objectForKey:@"eventParam"];
__block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
[param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
//在此处进行属性获取
id value = [LZCaptureTool captureVarforInstance:target varName:key];
if (key && value) {
[eventParam setObject:value forKey:key];
}
}];
NSLog(@"useDefind:%@----eventParam:%@",useDefind,eventParam);
}
}
// UIView 分类
- (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat
{
NSString *classStr = NSStringFromClass([self class]);
//cell的子view
//UITableView 特殊的superview (UITableViewContentView)
//UICollectionViewCell
BOOL shouldUseSuperView =
([classStr isEqualToString:@"UITableViewCellContentView"]) ||
([[self.superview class] isKindOfClass:[UITableViewCell class]])||
([[self.superview class] isKindOfClass:[UICollectionViewCell class]]);
if (shouldUseSuperView) {
return [self obtainIndexPathByView:self.superview];
}else {
return [self obtainIndexPathByView:self];
}
}
- (NSString *)obtainIndexPathByView:(UIView *)view
{
NSInteger viewTreeNodeDepth = NSIntegerMin;
NSInteger sameViewTreeNodeDepth = NSIntegerMin;
NSString *classStr = NSStringFromClass([view class]);
NSMutableArray *sameClassArr = [[NSMutableArray alloc]init];
//所处父view的全部subviews根节点深度
for (NSInteger index =0; index < view.superview.subviews.count; index ++) {
//同类型
if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){
[sameClassArr addObject:view.superview.subviews[index]];
}
if (view == view.superview.subviews[index]) {
viewTreeNodeDepth = index;
break;
}
}
//所处父view的同类型subviews根节点深度
for (NSInteger index =0; index < sameClassArr.count; index ++) {
if (view == sameClassArr[index]) {
sameViewTreeNodeDepth = index;
break;
}
}
return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];
}
@end
找到点击事件的方法 sendAction:to:forEvent:
,然后在 +openTrackSelector()
方法里使用 HGMethodSwizzingTool
替换新的方法。
和 UIViewController
生命周期埋点不同的是,一个类中可能有许多不同的 UIButton
子类,相同的 UIButton
子类在不同的视图中的埋点也要区分出来,所以我们通过 NSStringFromClass([target class]) + NSStringFromSelector(action)
来区别,即类名加方法名的格式作为唯一标识。
tableView
、collectionView
、手势的点击事件与上述实现方法类似。
3.3.5 埋点配置文件
埋点配置文件通过唯一标识锁定事件,可以使用 json
文件或 plist
文件,Demo
里就随便写了一些测试数据,LZDataTrack.json
是直接放在了项目资源里,实际项目是通过 API
从服务器下载的配置文件,以实现实时更新埋点配置。
测试 json
文件:
{
"Gesture":{
"RootViewController/gestureclicked:":{
"userDefined": {
"action": "click",
"pageid": "1234",
"pageName": "首页",
"eventName":"点击手势"
},
"eventParam":{
"spm":"a-b-c-spm",
"pageName":"",
"tips":""
}
}
},
"ViewController":{
"RootViewController/viewWillAppear":{
"userDefined": {
"action": "show",
"pageid": "1234",
"pageName": "首页",
"eventName":"首页展示"
},
"eventParam":{
"spm":"",
"pageName":"",
"tips":""
}
},
"SecondViewController/viewWillAppear":{
"userDefined": {
"action": "show",
"pageid": "1235",
"pageName": "灵感页",
"eventName":"灵感页展示"
},
"eventParam":{
"spm":"",
"pageName":"",
"tips":""
}
}
},
"CollectionView":{
"ThirdViewController/0":{
"viewcontroller":true,
"userDefined": {
"action": "click",
"pageid": "12345",
"pageName": "灵感页",
"eventName":"点击collectionview"
},
"eventParam":{
"spm":"a-b-c-spm",
"pageName":"",
"tips":""
}
}
},
"TableView":{
"SecondViewController/0":{
"viewcontroller":true,
"userDefined": {
"action": "click",
"pageid": "12345",
"pageName": "灵感页",
"eventName":"点击tableview"
},
"eventParam":{
"spm":"a-b-c-spm",
"pageName":"",
"tips":""
}
}
},
"Action":{
"RootViewController/testButtonClick:/0":{
"userDefined": {
"action": "click",
"pageid": "1234",
"pageName": "首页",
"eventName":"点击测试按钮"
},
"eventParam":{
"spm":"a-b-c-spm",
"pageName":"",
"tips":""
}
},
"SecondViewController/UIView/UITableView/TableViewCell/testButtonClick:/0":{
"userDefined": {
"action": "click",
"pageid": "1234",
"pageName": "灵感",
"eventName":"cell里的点击测试按钮"
},
"eventParam":{
"spm":"a-b-c-spm",
"pageName":"",
"tips":""
}
}
}
}
总结
使用运行时方法的替换实现了无侵入埋点,但仍存在很多问题,比如唯一标识难以维护、准确性有待验证。目前的方式只能实现页面进、出以及点击事件的埋点统计,涉及到具体业务的埋点统计,比如开机启动、需要上报参数信息等类型的埋点还是要依赖代码埋点。所以无侵入埋点方案还有很大优化空间。
附 Demo :
项目源码通过公众号"网罗开发"后台回复「20210810」获取
-End-
领取面试题
进入上方二维码内 回复「面试题」
本文分享自微信公众号 - 小明菜市场(fileGeek)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。