无痕埋点的设计与实现
在移动互联网时代,对于每个公司、企业来说,用户的数据非常重要。重要到什么程度,用户在这个页面停留多久、点击了什么按钮、浏览了什么内容、什么手机、什么网络环境、App 什么版本等都需要清清楚楚。甚至一些大厂的蛮多业务成果都是靠基于用户操作行为和记录的推荐转换二次。
那么有了上述的诉求,那么技术人员如何满足这些需求?引出来了一个技术点 -“埋点”
埋点手段
业界中对于代码埋点主要有 3 种主流的方案:代码手动埋点、可视化埋点、无痕埋点。简单说说这几种埋点方案。
代码手动埋点:根据业务需求(运营、产品、开发多个角度出发)在需要埋点地方手动调用埋点接口,上传埋点数据。
可视化埋点:通过可视化配置工具完成采集节点,在前端自动解析配置并上报埋点数据,从而实现可视化“无痕埋点”
无痕埋点:通过技术手段,完成对用户行为数据无差别的统计上传的工作。后期数据分析处理的时候通过技术手段筛选出合适的数据进行统计分析。
技术选型
代码手动埋点
该方案情况下,如果需要埋点,则需要在工程代码中,写埋点相关代码。因为侵入了业务代码,对业务代码产生了污染,显而易见的缺点是埋点的成本较高、且违背了单一原则。
例 1:假如你需要知道用户在点击“购买按钮”时的相关信息(手机型号、App 版本、页面路径、停留时间、动作等等),那么就需要在按钮的点击事件里面去写埋点统计的代码。这样明显的弊端就是在之前业务逻辑的代码上面又多出了埋点的代码。由于埋点代码分散、埋点的工作量很大、代码维护成本较高、后期重构很头痛。
例 2:假如 App 采用了 Hybrid 架构,当 App 的第一版本发布的时候 H5 的关键业务逻辑统计是由 Native 定义好关键逻辑(比如 H5 调起了 Native 的分享功能,那么存在一个分享的埋点事件)的桥接。假如某天增加了一个扫一扫功能,未定义扫一扫的埋点桥接,那么 H5 页面变动的时候,Native 埋点代码不去更新的话,变动的 H5 的业务就未被精确统计。
优点:产品、运营工作量少,对照业务映射表就可以还原出相关业务场景、数据精细无须大量的加工和处理
缺点:开发工作量大、前期需要和运营、产品指定的好业务标识,以便产品和运营进行数据统计分析
可视化埋点
可视化埋点的出现,是为解决代码埋点流程复杂、成本高、新开发的页面(H5、或者服务端下发的 json 去生成相应页面)不能及时拥有埋点能力
前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到 view 的 xpath 过程。
用户每次操作的控件,都生成一个 xpath 字符串,然后通过接口将 xpath 字符串 (view 在前端系统中的唯一定位。以 iOS 为例,App 名称、控制器名称、一层层 view、同类型 view 的序号:“GoodCell.21.RetailTableView.GoodsViewController.*baoApp”) 到真正的业务模块(“宝 App- 商城控制器 - 分销商品列表 - 第 21 个商品被点击了”)的映射关系上传到服务端。xpath 具体是什么在下文会有介绍。
之后操作 App 就生成对应的 xpath 和埋点数据(开发者通过技术手段将从服务端获取的关键数据塞到前端的 UI 控件上。iOS 端为例,UIView 的 accessibilityIdentifier 属性可以设置我们从服务端获取的埋点数据)上传到服务端。
优点:数据量相对准确、后期数据分析成本低
缺点:前期控件的唯一识别、定位都需要额外开发;可视化平台的开发成本较高;对于额外需求的分析可能会比较困难
无痕埋点
通过技术手段无差别地记录用户在前端页面上的行为。可以正确的获取 PV、UV、IP、Action、Time 等信息。
缺点:前期开发统计基础信息的技术产品成本较高、后期数据分析数据量很大、分析成本较高(大量数据传统的关系型数据库压力大)
优点:开发人员工作量小、数据全面、无遗漏、产品和运营按需分析、支持动态页面的统计分析
如何选择
结合上述优缺点,我们选择了无痕埋点 + 可视化埋点结合的技术方案。
怎么说呢?对于关键的业务开发结束上线后、通过可视化方案(类似于一个界面,想想看 Dreamwaver,你在界面上拖拖控件,简单编辑下就可以生成对应的 HTML 代码)点击一下绑定对应关系到服务端。
那么这个对应关系是什么?我们需要唯一定位一个前端元素,那么想到的办法就是不管 Native 和 Web 前端,控件或者元素来说就是一个树形层级,DOM tree 或者 UI tree,所以我们通过技术手段定位到这个元素,以 Native iOS 为例子假如我点击商品详情页的加入购物车按钮会根据 UI 层级结构生成一个唯一标识“addCartButton.GoodsViewController.GoodsView.*BaoApp”。但是用户在使用 App 的时候,上传的是这串东西的 MD5 到服务端。
这么做有 2 个原因:服务端数据库存储这串很长的东西不是很好;埋点数据被劫持的话直接看到明文不太好。所以 MD5 再上传。
操刀就干
数据的收集
实现方案由以下几个关键指标:
现有代码改动少、尽量不要侵入业务代码去实现拦截系统事件
全量收集
如何唯一标识一个控件元素
不侵入业务代码拦截系统事件
以 iOS 为例。我们会想到 AOP(Aspect Oriented Programming)面向切面编程思想。动态地在函数调用前后插入相应的代码,在 Objective-C 中我们可以利用 Runtime 特性,用 Method Swizzling 来 hook 相应的函数
为了给所有类方便地 hook,我们可以给 NSObject 添加个 Category,名字叫做 NSObject+MethodSwizzling
+ (void)swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
Class class = [self class];
// 原有方法
Method originalMethod = class_getInstanceMethod(class, originalSelector);
// 替换原有方法的新方法
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
// 先尝试給源 SEL 添加 IMP,这里是为了避免源 SEL 没有实现 IMP 的情况
BOOL didAddMethod = class_addMethod(class,originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {// 添加成功:表明源 SEL 没有实现 IMP,将源 SEL 的 IMP 替换到交换 SEL 的 IMP
class_replaceMethod(class,swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {// 添加失败:表明源 SEL 已经有 IMP,直接将两个 SEL 的 IMP 交换即可
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
+ (void)swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
Class class = [self class];
// 原有方法
Method originalMethod = class_getClassMethod(class, originalSelector);
// 替换原有方法的新方法
Method swizzledMethod = class_getClassMethod(class, swizzledSelector);
// 先尝试給源 SEL 添加 IMP,这里是为了避免源 SEL 没有实现 IMP 的情况
BOOL didAddMethod = class_addMethod(class,originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {// 添加成功:表明源 SEL 没有实现 IMP,将源 SEL 的 IMP 替换到交换 SEL 的 IMP
method_exchangeImplementations(originalMethod, swizzledMethod);
} else {// 添加失败:表明 SEL 已经有 IMP,直接将两个 SEL 的 IMP 交换即可
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
全量收集
我们会想到 hook AppDelegate 代理方法、UIViewController 生命周期方法、按钮点击事件、手势事件、各种系统控件的点击回调方法、应用状态切换等等。
动作
事件
App 状态的切换
给 Appdelegate 添加分类,hook 生命周期
UIViewController 生命周期函数
给 UIViewController 添加分类,hook 生命周期
UIButton 等的点击
UIButton 添加分类,hook 点击事件
UICollectionView、UITableView 等的
在对应的 Cell 添加分类,hook 点击事件
手势事件 UITapGestureRecognizer、UIControl、UIResponder
相应系统事件
以统计页面的打开时间和统计页面的打开、关闭的需求为例,我们对 UIViewController 进行 hook
static char *viewController_open_time = “viewController_open_time”;
static char *viewController_close_time = “viewController_close_time”;
// load 方法里面添加 dispatch_once 是为了防止手动调用 load 方法。
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
@autoreleasepool {
[[self class] swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(viewWillAppear:)];
[[self class] swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(viewWillDisappear:)];
}
});
}
#pragma mark – add prop
– (void)setOpenTime:(NSDate *)openTime
{
objc_setAssociatedObject(self,&viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
– (NSDate *)getOpenTime
{
return objc_getAssociatedObject(self, &viewController_open_time);
}
– (void)setCloseTime:(NSDate *)closeTime
{
objc_setAssociatedObject(self,&viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
– (NSDate *)getCloseTime
{
return objc_getAssociatedObject(self, &viewController_close_time);
}
– (void)viewWillAppear:(BOOL)animated
{
NSString *className = NSStringFromClass([self class]);
NSString *refer = [NSString string];
if ([self getPageUrl:className]) {
// 设置打开时间
[self setOpenTime:[NSDate dateWithTimeIntervalSinceNow:0]];
if (self.navigationController) {
if (self.navigationController.viewControllers.count >=2) {
// 获取当前 vc 栈中 上一个 VC
UIViewController *referVC = self.navigationController.viewControllers[self.navigationController.viewControllers.count-2];
refer = [self getPageUrl:NSStringFromClass([referVC class])];
}
}
if (!refer || refer.length == 0) {
refer = @”unknown”;
}
[SDGDataCenter openPage:[self getPageUrl:className] fromPage:refer];
}
[self viewWillAppear:animated];
}
– (void)viewWillDisappear:(BOOL)animated
{
NSString *className = NSStringFromClass([self class]);
if ([self getPageUrl:className]) {
[self setCloseTime:[NSDate dateWithTimeIntervalSinceNow:0]];
[SDGDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]];
}
[self viewWillDisappear:animated];
}
#pragma mark – private method
– (NSString *)p_calculationTimeSpend
{
if (![self getOpenTime] || ![self getCloseTime]) {
return @”unknown”;
}
NSTimeInterval aTimer = [[self getCloseTime] timeIntervalSinceDate:[self getOpenTime]];
int hour = (int)(aTimer/3600);
int minute = (int)(aTimer – hour*3600)/60;
int second = aTimer – hour*3600 – minute*60;
return [NSString stringWithFormat:@”%d”,second];
}
@end
如何唯一标识一个控件元素
xpath 是移动端定义可操作区域的唯一标识。既然想通过一个字符串标识前端系统中可操作的控件,那么 xpath 需要 2 个指标:
唯一性:在同一系统中不存在不同控件有着相同的 xpath
稳定性:不同版本的系统中,在页面结构没有变动的情况下,不同版本的相同页面,相同的控件的 xpath 需要保持一致。
我们想到 Naive、H5 页面等系统渲染的时候都是以树形结构去绘制和渲染,所以我们以当前的 View 到系统的根元素之间的所有关键点(UIViewController、UIView、UIView 容器(UITableView、UICollectionView 等)、UIButton…)串联起来这样就唯一定位了控件元素。
为了精确定位元素节点,参看下图
假设一个 UIView 中有三个子 view,先后顺序是:label、button1、button2,那么深度依次为:0、1、2。假如用户做了某些操作将 label1 从父 view 中被移除了。此时 UIView 只有 2 个子 view:button1、button2,而且深度变为了:0、1。
可以看出仅仅由于其中某个子 view 的改变,却导致其它子 view 的深度都发生了变化。因此,在设计的时候需要注意,在新增 / 移除某一 view 时,尽量减少对已有 view 的深度的影响,调整了对节点的深度的计算方式:采用当前 view 位于其父 view 中的所有 与当前 view 同类型 子 view 中的索引值。
我们再看一下上面的这个例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次为:0、1。可以看出,在这个例子中,label 的移除并未对 button1、button2 的深度造成影响,这种调整后的计算方式在一定程度上增强了 xpath 的抗干扰性。
另外,调整后的深度的计算方式是依赖于各节点的类型的,因此,此时必须要将各节点的名称放到 viewPath 中,而不再是仅仅为了增加可读性。
在标识控件元素的层级时,需要知道「当前 view 位于其父 view 中的所有 与当前 view 同类型 子 view 中的索引值」。参看上图,如果不是同类型的话,则唯一性得不到保证。
有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。
有 2 个解决方案
利用系统提供的 accessibilityIdentifier 官方给出的解释是标识用户界面元素的字符串
找出当前元素在父层同类型元素中的索引。根据当前的元素遍历当前元素的父级元素的子元素,如果出现相同的元素,则需要判断当前元素是所在层级的第几个元素
/*A string that identifies the user interface element.
default == nil
*/
@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier NS_AVAILABLE_IOS(5_0);
服务端下发唯一标识
接口获取的数据,里面有当前元素的唯一标识。比如在 UITableView 的界面去请求接口拿到数据,那么在在获取到的数据源里面会有一个字段,专门用来存储动态化的经常变动的数据。
cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString];
判断在同层级、同类型的控件元素里面的序号
对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:GoodsCell-3.GoodsTableView.GoodsViewController.xxApp
//UIResponder 分类
{// if (self.xq_identifier_ka == nil) {
if ([self isKindOfClass:[UIView class]]) {
UIView *view = (id)self;
NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath];
NSMutableString *str = [NSMutableString string];
// 特殊的 加减购 因为带有 spm 但是要区分加减 需要带 TreeNode
NSString *className = [NSString stringWithUTF8String:object_getClassName(view)];
if (!view.accessibilityIdentifier || [className isEqualToString:@”XQButton”]) {
[str appendString:sameViewTreeNode];
[str appendString:@”,”];
}
while (view.nextResponder) {
[str appendFormat:@”%@,”, NSStringFromClass(view.class)];
if ([view.class isSubclassOfClass:[UIViewController class]]) {
break;
}
view = (id)view.nextResponder;
}
self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@”%@”,str]];
// self.xq_identifier_ka = [NSString stringWithFormat:@”%@”,str];
}
// }
return self.xq_identifier_ka;
}
// 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];
}
## 数据的上传
数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢?
App 运行期间用户会点击非常多的数据,如果实时上传的话对于网络的利用率较低,所以需要考虑一个机制去控制用户产生的埋点数据的上传。
思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 AppMonitor 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。
App 应用状态的切换策略如下:
– didFinishLaunchWithOptions: 内存日志信息写入硬盘
– didBecomeActive:上传
– willTerimate:内存日志信息写入硬盘
– didEnterBackground:内存日志信息写入硬盘
// 将 App 日志信息写入到内存中。当内存中的数量到达一定规模(超过设置的内存中存储的数量)的时候就将内存中的日志存储到文件信息中
(void)joinEvent:(NSDictionary *)dictionary
{
if (dictionary) {
NSDictionary *tmp = [self createDicWithEvent:dictionary];
if (!s_memoryArray) {
s_memoryArray = [NSMutableArray array];
}
[s_memoryArray addObject:tmp];
if ([s_memoryArray count] >= s_flushNum) {
[self writeEventLogsInFilesCompletion:^{
[self startUploadLogFile];
}];
}
}
}
// 外界调用的数据传递入口(App 埋点统计)
(void)traceEvent:(AMStatisticEvent *)event
{
// 线程锁, 防止多处调用产生并发问题
@synchronized (self) {
if (event && event.userInfo) {
[self joinEvent:event.userInfo];
}
}
}
// 将内存中的数据写入到文件中,持久化存储
(void)writeEventLogsInFilesCompletion:(void(^)(void))completionBlock
{
NSArray *tmp = nil;
@synchronized (self) {
tmp = s_memoryArray;
s_memoryArray = nil;
}
if (tmp) {
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *jsonFilePath = [weakSelf createTraceJsonFile];
if ([weakSelf writeArr:tmp toFilePath:jsonFilePath]) {
NSString *zipedFilePath = [weakSelf zipJsonFile:jsonFilePath];
if (zipedFilePath) {
[AppMonotior clearCacheFile:jsonFilePath];
if (completionBlock) {
completionBlock();
}
}
}
});
}
}
// 从 App 埋点统计压缩包文件夹中的每个压缩包文件上传服务端,成功后就删除本地的日志压缩包
(void)startUploadLogFile
{
NSArray *fList = [self listFilesAtPath:[self eventJsonPath]];
if (!fList || [fList count] == 0) {
return;
}
[fList enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
if (![obj hasSuffix:@”.zip”]) {
return;
}
NSString *zipedPath = obj;
unsigned long long fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:zipedPath error:nil] fileSize];
if (!fileSize || fileSize < 1) {
return;
}
[self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) {
if ([completionResult isEqual:@”OK”]) {
[AppMonotior clearCacheFile:zipedPath];
}
}];
}];
}
![绑定页面唯一标识与功能描述的对应关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-03-07-UserTracet1.PNG)
总结下来关键步骤:
1. hook 系统的各种事件(UIResponder、UITableView、UICollectionView 代理事件、UIControl 事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码
2. 对于点击的元素按照视图树生成对应的唯一标识(addCartButton.GoodsView.GoodsViewController)的 md5 值
3. 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App 层级、业务模块、关键页面、关键操作)给绑定起来。比如 addCartButton.GoodsView.GoodsViewController.tbApp 对应了 tbApp- 商城模块 - 商品详情页 - 加入购物车功能。
4. 将所需要的数据存储下来
5. 设计机制等到合适的时机去上传数据