大家好,我是OB!,今天来聊聊iOS的埋点。
不管是埋点,统计还是什么其他辟邪剑谱,主要的目的是为了了解用户行为习惯,进而开发出更友好的APP。
埋点的形式主要有:
- 统计页面停留时长
- 页面出现次数
- 按钮的点击次数
在技术上,埋点主要包括代码埋点、可视化埋点和全埋点。
埋点方式 | 优点 | 缺点 |
代码埋点(侵入式) | 方便灵活,什么样的埋点都可以实现。包括各种奇葩埋点。 | 维护成本高,由于到处都是埋点的代码,所以清理维护难 |
可视化埋点/全埋点(非侵入式) | 埋点统一维护,解耦。适用于大量通用的埋点 | 不适用所有,唯一标识难以确定,开发成本较大 |
侵入式埋点确实没什么可说的,主要是将埋点统计代码写在需要埋点的
view
viewControll
的具体类里面,所以主要说说非侵入式埋点
非侵入式埋点
利用runtime
,交换系统方法,并实现埋点逻辑
#import <objc/runtime.h>
@interface OBAspect : NSObject
+ (void)ob_hookTarget:(id)target originSelector:(SEL)osel newSelector:(SEL)nsel;
@end
@implementation OBAspect
//未做非空判断,实际还需要验证方法的实现是否完整
+ (void)ob_hookTarget:(id)target originSelector:(SEL)osel newSelector:(SEL)nsel {
Method m1 = class_getInstanceMethod(target, osel);
Method m2 = class_getInstanceMethod(target, nsel);
method_exchangeImplementations(m1, m2);
}
@end
按钮这里最主要是,找到这个点击事件的方法 sendAction:to:forEvent:
,然后在 +load()
方法使用 OBAspect
hook
方法
@interface UIButton(OBCount)
@end
@implementation UIButton(OBCount)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[OBAspect ob_hookTarget:self originSelector:@selector(sendAction:to:forEvent:) newSelector:@selector(ob_sendAction:to:forEvent:)];
});
}
- (void)ob_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
NSLog(@"点击了[%@-%@-%@]",NSStringFromClass([target class]),NSStringFromSelector(action),self.titleLabel.text);
[self ob_sendAction:action to:target forEvent:event];
}
@end
页面统计时:页面进入次数、页面停留时间都是对 UIViewController
生命周期函数进行hook
,然后埋点。创建一个 UIViewController
的 Category
@interface UIViewController(OBCount)
@end
@implementation UIViewController(OBCount)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[OBAspect ob_hookTarget:self originSelector:@selector(viewWillAppear:) newSelector:@selector(ob_viewWillAppear:)];
[OBAspect ob_hookTarget:self originSelector:@selector(viewWillDisappear:) newSelector:@selector(ob_viewWillDisappear:)];
});
}
- (void)ob_viewWillAppear:(BOOL)animated {
NSLog(@"进入了%@",NSStringFromClass([self class]));
[self ob_viewWillAppear:animated];
}
- (void)ob_viewWillDisappear:(BOOL)animated {
NSLog(@"退出了%@",NSStringFromClass([self class]));
[self ob_viewWillDisappear:animated];
}
然后运行
Test_Hook[11462:12348762] 进入了ViewController
Test_Hook[11462:12348762] 点击了[ViewController-loginClick:-登录]
Test_Hook[11462:12348762] 退出了ViewController
Test_Hook[11462:12348762] 进入了HomeViewController
注意:要找到
view
的唯一性,可以是多个信息组合,比如NSStringFromClass([target class])
,NSStringFromSelector(action)
组合,再不行加上text
如何对cell埋点?
推荐:无埋点和可视化埋点
UITableView的cell有缓存机制,每个cell的点击事件的埋点的关键是唯一标识符的制定规则;
唯一标识符的制定规则
利用-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
方法中下标,可以确定是点击的哪个cell,我们在制定唯一标识符
的时候,可以是HomeVC_1_2
这样的格式,表示在首页的tableView中,第1个section的第2个cell,这样就可以确定唯一标识符
,在根据这个唯一标识符
可以去NSDictionary中查找对应的value,保存并上传数据。
NSDictionary *dict = @{
@"HomeVC_1_0":@"男士",
@"HomeVC_1_1":@"女士",
@"HomeVC_1_2":@"儿童"
};
// 启动优化,load方法的逻辑延后执行
+ (void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
//交换tableView的代理方法
Method m1 = class_getInstanceMethod([self class], @selector(setDelegate:));
Method m2 = class_getInstanceMethod([self class], @selector(setOBDelegate:));
method_exchangeImplementations(m1, m2);
});
}
//此处可以 获取 tableView的代理
- (void)setOBDelegate:(id<UITableViewDelegate>)delegate {
Class cla = [delegate class];
//1:拿到了代理,交换代理的点击方法
SEL originalSelector = @selector(tableView:didSelectRowAtIndexPath:);
SEL swizzledSelector = @selector(ob_tableView:didSelectRowAtIndexPath:);
Method originalMethod = class_getInstanceMethod(cla, originalSelector);
Method swizzledMethod = class_getInstanceMethod(cla, swizzledSelector);
BOOL add = NO;
//2:原方法可以肯定有,但是替换的方法不一定有。所以没有就要add
if (swizzledMethod == nil) {
//方法的实现是在self中,不能写在delegate中,否则耦合
IMP imp = class_getMethodImplementation([self class], swizzledSelector);
const char* types = method_getTypeEncoding(swizzledMethod);
add = class_addMethod(cla, swizzledSelector, imp, types);
swizzledMethod = class_getInstanceMethod(cla, swizzledSelector);
}
//3:两个方法都有了,交换方法实现
method_exchangeImplementations(originalMethod, swizzledMethod);
//设置原来的代理
[self setOBDelegate:delegate];
}
- (void)ob_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
//code 埋点的代码逻辑
[lock lock]; //写入文件时注意加锁
//
NSString * str = [NSString stringWithFormat:@"%ld-%ld",indexPath.section,indexPath.row];
[lock unlock];
[self ob_tableView:tableView didSelectRowAtIndexPath:indexPath];
}
cell的出现时长统计?
思路:在滑动停止时,对出现在屏幕上的cell做计时或者设置开始时间,然后页面消失或者滑动tableView时,再次停止时,对比前后的出现在屏幕上的cell,如果cell还在屏幕上,继续计时,如果cell滑出屏幕了,计时停止,并做好统计,更新,以便下次出现使用
可以hook UITableView 的tableView:willDisplayCell:forRowAtIndexPath:
或者UICollectionView的collectionView:willDisplayCell:forRowAtIndexPath:
方法
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
NSLog(@"cell------------");
}
- (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath {
}
注意点:这个两个方法是实时调用的,考虑到性能,要等到页面滑动停止了,再开始计算,(如果滑动过程中就开始计算,那么是不正确的,因为滑动时,用户看不清楚)所以,计算要等到结束时计时,可以通过
[tableView performSelector:@selector(hlj_calculateViewVisible:) withObject:dict afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
,只有在切换到NSDefaultRunLoopMode
才会计算
或者hook这两个函数:tableView.visibleCells
和tableView.indexPathsForVisibleRows
,也是可以得到当前屏幕中的cell,然后取差集。
VisibleCells:
出现在屏幕上的cell,没有下标indexPathsForVisibleRows:
出现在屏幕上的cell,有下标,可以确定唯一标识符
如何取差集
利用一个记录上一次的cell的lastDictionary
字典,第二次页面停止时会产生一个currentDictionary
,遍历currentDictionary
,把里面的key取出来,然后去lastDictionary
里面删除这个key(如果有这个key),那么最后剩下来的lastDictionary
就是第一次出现在屏幕中,第二次消失的cell,对他计算统计,然后在需要将lastDictionary
重新赋值。
埋点数据如何上传?
埋点分两种:一种是普通埋点,另一种是日志埋点;
1:普通埋点
满足三个条件上传:1:进入APP时;2:进入后台时;3:使用时
1:进入APP时
在-application: didFinishLaunchingWithOptions:
或者进入前台时,检测本地有没有埋点数据,有就上传。没有就不上传
如果系统crash,或者被kill时,需要将缓存中的数据保存到本地,下次打开上传。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//去除埋点数据,有就上传,没有作罢
NSDictionary *dict = [[MDManager shared] readDataFormManager];
//注册crash时的回调,crash时,可以执行保存的操作
NSSetUncaughtExceptionHandler(exceptionHandler);
return YES;
}
// 系统crash时执行
void exceptionHandler(NSException *exception) {
//保存数据
[[MDManager shared] saveToDisk];
}
// 系统被回收时执行
- (void)applicationWillTerminate:(UIApplication *)application {
//保存数据
[[MDManager shared] saveToDisk];
}
2:进入后台时
进入后台时:有就上传。没有就不上传
3:使用时
如果设置数据上限,比如当数据大于 100k时,立即上传
注意:上传时,也有埋点数据,需要小心处理,不然会数据丢失
这里不加读写锁(或者是CGD的栅栏函数),目的是首先不加锁也能实现数据不丢失。其次加锁会对每次写入有额外的性能消耗。
- (void)uploadDataFromMemory {
NSDictionary * updict = [self.dict copy];
dispatch_queue_t q = dispatch_queue_create("ob", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(q, ^{
// code .. 上传代码
//上传成功后删,除上传的部分
[updict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
[self.dict removeObjectForKey:key];
}];
});
}
部分cell埋点代码
#import "UITableView+MDTableView.h"
#import <objc/runtime.h>
#import "MDManager.h"
@implementation UITableView (MDTableView)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method m1 = class_getInstanceMethod([self class], @selector(setDelegate:));
Method m2 = class_getInstanceMethod([self class], @selector(setMDDelegate:));
method_exchangeImplementations(m1, m2);
});
}
+ (void)initialize {
}
- (void)setMDDelegate:(id<UITableViewDelegate>)delegate {
SEL s1 = @selector(tableView:willDisplayCell:forRowAtIndexPath:);
Method m1 = class_getInstanceMethod([delegate class], s1);
if (m1 == nil) {
SEL s1_no = @selector(MD_No_tableView:willDisplayCell:forRowAtIndexPath:);
Method m1_no = class_getInstanceMethod([self class], s1_no);
IMP imp1 = method_getImplementation(m1_no);
const char * types_s1 = method_getTypeEncoding(m1_no);
BOOL add_m1 = class_addMethod([delegate class], s1, imp1, types_s1);
if (add_m1) {
//
m1 = class_getInstanceMethod([delegate class], s1);
} else {
//
}
}
SEL s2 = @selector(MD_tableView:willDisplayCell:forRowAtIndexPath:);
Method m2 = class_getInstanceMethod([self class], s2);
IMP imp2 = method_getImplementation(m2);
const char * types = method_getTypeEncoding(m2);
BOOL add = class_addMethod([delegate class], s2, imp2, types);
//
if (add) {
Method m22 = class_getInstanceMethod([delegate class], s2);
method_exchangeImplementations(m1, m22);
}
//hook
[self setMDDelegate:delegate];
}
- (void)MD_tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary * dict = @{@"cell":cell,@"indexPath":indexPath,@"tableView":tableView};
[tableView performSelector:@selector(hlj_calculateViewVisible:) withObject:dict afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
[self MD_tableView:tableView willDisplayCell:cell forRowAtIndexPath:indexPath];
}
- (void)hlj_calculateViewVisible:(NSDictionary *)dict {
UIView * view = dict[@"cell"];
NSIndexPath *ip = dict[@"indexPath"];
UITableView *tb = dict[@"tableView"];
NSString *str = [NSString stringWithFormat:@"%@_%@_%@_",[tb.delegate class],[tb class],[view class]];
[[MDManager shared] viewExposure:[NSString stringWithFormat:@"%@%ld-%ld",str,(long)ip.section,(long)ip.row]];
}
- (void)MD_No_tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
NSLog(@"========");
}
@end