iOS 无痕埋点实现
数据是新时代的石油。——克莱夫·哈姆比(英国数据科学家及数学家)
拷问数据,它会坦白一切。——罗纳德·科斯(英国经济学家、作家及1991年诺贝尔经济学奖获奖者)
数据的重要性不言而喻。
在 iOS 开发中,埋点可以解决两大类问题:一是了解用户使用 App 的行为,二是降低分析线上问题的难度。目前,iOS 开发中常见的埋点方式,主要包括代码埋点、可视化埋点和无痕埋点这三种。
埋点方法
代码埋点
通过手写代码的方式来埋点,能很精确的在需要埋点的代码处加上埋点的代码,可以很方便地记录当前环境的变量值,方便调试,并跟踪埋点内容,但存在开发工作量大,并且埋点代码到处都是,后期难以维护等问题。
可视化埋点
将埋点增加和修改的工作可视化了,提升了增加和维护埋点的体验。
无痕埋点
通过技术手段,完成对用户行为数据无差别的统计上传的工作。后期数据分析处理的时候通过技术手段筛选出合适的数据进行统计分析。它的缺点在于,埋点成本高,后期的解析也比较复杂,再加上 view_path 的不确定性。所以,这种方案并不能解决所有的埋点需求,但对于大量通用的埋点需求来说,能够节省大量的开发和维护成本。
无痕埋点实践
数据收集
动作 | 事件 |
---|---|
App状态切换 | 监听通知 |
UIViewController 生命周期函数 | 给 UIViewController 添加分类,hook 生命周期 |
UIControl 事件 | UIControl 添加分类,hook 点击事件 |
UICollectionView、UITableView 等的 | 在对应的控件添加分类,hook 点击事件 |
手势事件 UITapGestureRecognizer | 相应系统事件 |
具体看下方的代码。
需要注意的是,假如项目中还有其它的类也 hook 了相同的方法,则执行顺序与 Compile Sources 中的顺序相反。
- Compile Sources 顺序
- 执行顺序
准备工作
- 说明
先写一个 NSObject 的分类,添加一个交换方法的扩展方法。
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50#import "NSObject+MethodSwizzle.h"
#import <objc/runtime.h>
@implementation NSObject (MethodSwizzle)
+ (void)my_classMethodSwizzleWithClass:(Class)class originalSelector:(SEL)originalSelector swizzleSelector:(SEL)swizzleSelector {
Class realClass = object_getClass(class);
Method originalMethod = class_getClassMethod(realClass, originalSelector);
Method swizzleMethod = class_getClassMethod(realClass, swizzleSelector);
if (!originalMethod || !swizzleMethod) {
return;
}
class_addMethod(realClass,
originalSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
class_addMethod(realClass,
swizzleSelector,
method_getImplementation(swizzleMethod),
method_getTypeEncoding(swizzleMethod));
Method originalMethod2 = class_getClassMethod(class, originalSelector);
Method swizzleMethod2 = class_getClassMethod(class, swizzleSelector);
method_exchangeImplementations(originalMethod2, swizzleMethod2);
}
+ (void)my_instanceMethodSwizzleWithClass:(Class)class originalSelector:(SEL)originalSelector swizzleSelector:(SEL)swizzleSelector {
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzleMethod = class_getInstanceMethod(class, swizzleSelector);
if (!originalMethod || !swizzleMethod) {
return;
}
class_addMethod(class,
originalSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
class_addMethod(class,
swizzleSelector,
method_getImplementation(swizzleMethod),
method_getTypeEncoding(swizzleMethod));
Method originalMethod2 = class_getInstanceMethod(class, originalSelector);
Method swizzleMethod2 = class_getInstanceMethod(class, swizzleSelector);
method_exchangeImplementations(originalMethod2, swizzleMethod2);
}
@end
Appdelegate
- 说明
在应用程序不同状态转换的过程中,系统会回调实现了 UIApplicationDelegate 协议的类的一些方法,并发送相应的本地通知(系统会先回调相应的方法,待回调方法执行完后,再发送相应的通知)。
- 代码
1 | @implementation BehaviorAnalysisManager |
UIViewController
- 说明
直接在 UIVIewController 的分类中,hook 对应的生命周期函数,在对应的生命周期加入对应的埋点代码。
可以根据当前展示控制器的类名,标识生命周期所属的 UIViewController。就可以跟踪到具体的展示页面了。
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37#import "UIViewController+baHooks.h"
#import "NSObject+MethodSwizzle.h"
@implementation UIViewController (baHooks)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
@autoreleasepool {
[self my_instanceMethodSwizzleWithClass:UIViewController.class originalSelector:@selector(viewWillAppear:) swizzleSelector:@selector(ba_viewWillAppear:)];
[self my_instanceMethodSwizzleWithClass:UIViewController.class originalSelector:@selector(viewDidAppear:) swizzleSelector:@selector(ba_viewDidAppear:)];
[self my_instanceMethodSwizzleWithClass:UIViewController.class originalSelector:@selector(viewWillDisappear:) swizzleSelector:@selector(ba_viewWillDisappear:)];
[self my_instanceMethodSwizzleWithClass:UIViewController.class originalSelector:@selector(viewDidDisappear:) swizzleSelector:@selector(ba_viewDidDisappear:)];
}
});
}
- (void)ba_viewWillAppear:(BOOL)animated {
NSLog(@"UIViewController hooks:——————%@ ba_viewWillAppear", NSStringFromClass(self.class));
[self ba_viewWillAppear:animated];
}
- (void)ba_viewDidAppear:(BOOL)animated {
NSLog(@"UIViewController hooks:——————%@ ba_viewDidAppear", NSStringFromClass(self.class));
[self ba_viewDidAppear:animated];
}
- (void)ba_viewWillDisappear:(BOOL)animated {
NSLog(@"UIViewController hooks:——————%@ ba_viewWillDisappear", NSStringFromClass(self.class));
[self ba_viewWillDisappear:animated];
}
- (void)ba_viewDidDisappear:(BOOL)animated {
NSLog(@"UIViewController hooks:——————%@ ba_viewDidDisappear", NSStringFromClass(self.class));
[self ba_viewDidDisappear:animated];
}
@end
UIControl
- 说明
在 UIKit 框架中点击事件都遵循了 Target-Action 设计模式。我们常用的点击控件:UIButton、UISwitch、UISlider等都是 UIControl 的子类,所以,这里我们直接扩展 UIControl,并 hook 了它的 sendAction:to:forEvent: 方法。
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26#import "UIControl+baHooks.h"
#import "NSObject+MethodSwizzle.h"
@implementation UIControl (baHooks)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self my_instanceMethodSwizzleWithClass:UIControl.class originalSelector:@selector(sendAction:to:forEvent:) swizzleSelector:@selector(ba_sendAction:to:forEvent:)];
});
}
- (void)ba_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
[self ba_sendAction:action to:target forEvent:event];
NSString *actionString = NSStringFromSelector(action); // 选择器名
NSString *targetName = NSStringFromClass([target class]); // 视图类名
// UIButton 的点击
NSString *reportMessage;
if ([self isKindOfClass:UIButton.class]) {
reportMessage = [NSString stringWithFormat:@"%@_%@_%@",self.accessibilityIdentifier, targetName, actionString];
}
NSLog(@"BehaviorAnalysis log————UIControl hooks event reportMessage: %@", reportMessage);
NSLog(@"BehaviorAnalysis log————UIControl hooks event accessibilityIdentifier: %@", self.accessibilityIdentifier);
}
@end
UICollectionView、UITableView
- 说明
hook 了对应的 setDelegate: 方法,这样可以得到 delegate class,就可以去 hook 对应的 cell 被点击的事件,从而进行埋点。
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49@implementation UITableView (baHooks)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self my_instanceMethodSwizzleWithClass:UITableView.class originalSelector:@selector(setDelegate:) swizzleSelector:@selector(ba_tableView_setDelegate:)];
});
}
- (void)ba_tableView_setDelegate:(id<UITableViewDelegate>)delegate {
[self ba_tableView_setDelegate:delegate];
// delegate 其实就是我们的代理对象,delegate会实现 cell 的点击事件
SEL sel = @selector(tableView:didSelectRowAtIndexPath:);
SEL sel_ = NSSelectorFromString([NSString stringWithFormat:@"%@_%@_%ld", NSStringFromClass(delegate.class), NSStringFromClass(self.class),(long)self.tag]);
//因为 tableView:didSelectRowAtIndexPath:方法是optional的,所以没有实现的时候直接return
if (![delegate respondsToSelector:sel]) {
return;
}
BOOL addsuccess = class_addMethod(delegate.class,
sel_,
method_getImplementation(class_getInstanceMethod([self class], @selector(ba_tableView:didSelectItemAtIndexPath:))),
nil);
//如果添加成功了就直接交换实现, 如果没有添加成功,说明之前已经添加过并交换过实现了
if (addsuccess) {
Method selMethod = class_getInstanceMethod(delegate.class, sel);
Method sel_Method = class_getInstanceMethod(delegate.class, sel_);
method_exchangeImplementations(selMethod, sel_Method);
}
}
- (void)ba_tableView:(UITableView *)tableView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
SEL sel = NSSelectorFromString([NSString stringWithFormat:@"%@_%@_%ld", NSStringFromClass([self class]), NSStringFromClass(tableView.class), (long)tableView.tag]);
if ([self respondsToSelector:sel]) {
IMP imp = [self methodForSelector:sel];
void (*func)(id, SEL,id,id) = (void *)imp;
func(self, sel,tableView,indexPath);
}
// 搜集数据
// tableView 的标识, cell 的标识:类名 + indexPath.section + indexPath.row
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
NSLog(@"BehaviorAnalysis log————UITableView baHook:—tableView:%@——cell:%@_%ld_%ld", tableView.accessibilityIdentifier, NSStringFromClass(cell.class), indexPath.section, indexPath.row);
}
@end
1 | @implementation UICollectionView (baHooks) |
UITapGestureRecognizer
- 说明
思路与 UITableView、UICollectionView 一样,通过 hook initWithTarget:action: 方法,可以得到 target class 和所需执行的方法 action,进行 hook 即可。
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105@interface UITapGestureRecognizer ()
@property (nonatomic, strong) NSString *originalActionName;
@end
@implementation UITapGestureRecognizer (baHooks)
- (void)setOriginalActionName:(NSArray *)originalActionName {
objc_setAssociatedObject(self, @selector(originalActionName), originalActionName, OBJC_ASSOCIATION_RETAIN);
}
- (NSString *)originalActionName {
return objc_getAssociatedObject(self, _cmd);
}
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self my_instanceMethodSwizzleWithClass:self originalSelector:@selector(initWithTarget:action:) swizzleSelector:@selector(ba_initWithTarget:action:)];
[self my_instanceMethodSwizzleWithClass:self originalSelector:@selector(addTarget:action:) swizzleSelector:@selector(ba_addTarget:action:)];
});
}
- (instancetype)ba_initWithTarget:(id)target action:(SEL)action {
UITapGestureRecognizer *selfGestureRecognizer = [self ba_initWithTarget:target action:action];
if (!target && !action) {
return selfGestureRecognizer;
}
Class class = [target class];
SEL sel = action;
//创建一个新的方法 方法名为 sel_name
NSString * sel_name = [NSString stringWithFormat:@"ba_%s_%@", class_getName([target class]),NSStringFromSelector(action)];
SEL sel_ = NSSelectorFromString(sel_name);
//添加一个方法 参数:相应手势的类,添加的方法名,实现方法的函数 ba_responseUserGesture
BOOL isAddMethod = class_addMethod(class,
sel_,
method_getImplementation(class_getInstanceMethod([self class], @selector(ba_responseUserGesture:))),
nil);
self.originalActionName = NSStringFromSelector(action);
//方法添加成功,原先的方法实现 action -> 新的方法实现 ba_responseUserGesture。
if (isAddMethod) {
Method selMethod = class_getInstanceMethod(class, sel);
Method sel_Method = class_getInstanceMethod(class, sel_);
method_exchangeImplementations(selMethod, sel_Method);
}
return selfGestureRecognizer;
}
- (void)ba_addTarget:(id)target action:(SEL)action {
[self ba_addTarget:target action:action];
if (!target && !action) {
return;
}
Class class = [target class];
SEL sel = action;
//创建一个新的方法 方法名为 sel_name
NSString * sel_name = [NSString stringWithFormat:@"ba_%s_%@", class_getName([target class]),NSStringFromSelector(action)];
SEL sel_ = NSSelectorFromString(sel_name);
//添加一个方法 参数:相应手势的类,添加的方法名,实现方法的函数 ba_responseUserGesture
BOOL isAddMethod = class_addMethod(class,
sel_,
method_getImplementation(class_getInstanceMethod([self class], @selector(ba_responseUserGesture:))),
nil);
self.originalActionName = NSStringFromSelector(action);
//方法添加成功,原先的方法实现 action -> 新的方法实现 ba_responseUserGesture。
if (isAddMethod) {
Method selMethod = class_getInstanceMethod(class, sel);
Method sel_Method = class_getInstanceMethod(class, sel_);
method_exchangeImplementations(selMethod, sel_Method);
}
}
- (void)ba_responseUserGesture:(UITapGestureRecognizer *)gesture {
if (![gesture isKindOfClass:UIGestureRecognizer.class]) {
return;
}
NSString * identifier = [NSString stringWithFormat:@"ba_%s_%@", class_getName([self class]),gesture.originalActionName];
//调用原方法
SEL sel = NSSelectorFromString(identifier);
if ([self respondsToSelector:sel]) {
IMP imp = [self methodForSelector:sel];
void (*func)(id, SEL,id) = (void *)imp;
func(self, sel,gesture);
}
// 搜集数据
UIView *tapView = gesture.view;
NSLog(@"BehaviorAnalysis log————UITapGestureRecognizer baHook:—View:%@——action:%@", tapView.accessibilityIdentifier, self.originalActionName);
}
@end
控件的唯一标识符
- 说明
对于 Appdelegate 和 UIViewController 我们常用的埋点是它们的生命周期,对于控制器是很容易通过它的类名来区分的,但是对于项目中不计其数的点击事件来说,我们怎么样才能精准的定位到事件的触发,并埋点上传呢?
我们需要让每一个事件产生一个唯一的标识符,这个标识符该怎么去生成呢?
针对我当前项目所处的情况,目前我使用三重保证,尽量保证控件的唯一标识。
- 先使用视图层级的路径作为第一标识。
- 再判断当前控件是不是某个类的属性,是的话属性名称作为第二标识。
- 针对某些常用控件(UILabel、UIImageView、UIButton)特殊处理第三标识。
- UILabel:使用其中文字标识。
- UIImageView:使用其显示的图片名称。
- UIbutton:使用其显示的文字和图片名称组合。
这三重保证基本上可以定位到某个特定的控件了,然后针对特定的点击,我们埋点时还会加上标识事件的一些参数:
控件 | 标识参数 |
---|---|
UIControl | target、action |
UITableView、UICollectionView | indexPath |
UITapGestureRecognizer | action |
- 代码
采用 hook 的方式来在运行时生成标签。hook UIView 中的 accessibilityIdentifier 的原因是此时的视图层级更全,并且是惰性生成标签。
1 | @implementation UIView (uniqueId) |
辅助 UIView 生成 accessibilityIdentifier 的分类:
1 | @implementation UIResponder (uniqueId) |
1 | @implementation UIImage (uniqueId) |
总结
本篇文章总结的埋点,只能针对通用的事件进行处理。实际埋点需求中,埋点往往和业务数据强相关,比如点击页面上的加购物车按钮,埋点上报的数据中需要有商品id,当前商品的促销类型,商家id等等,这类需求暂时没有很好的无痕埋点方案。
所以,实际工作场景的埋点需求可能还是针对普适性的埋点需求使用无痕埋点思路实现,针对特定的业务需求还是需要特定的实现。
参考链接