本文作者: dl
在当前移动互联网时代,一个产品想快速、准确的抢占市场,无疑是需要产品快速迭代更新,如何协助产品经理对产品当前的数据做出最优判断是关键,这就需要客户端侧提供 高精度 、 稳定 、 全链路 的埋点数据;做客户端开发的同学都深刻知道,想要在开发过程中满足上述三点,开发过程都是头大的;
针对这个问题,我们自研了一套全链路埋点方案,从埋点设计、到客户端三端( iOS 、 Android 、 H5 )开发、以及埋点校验&稽查、再到埋点数据使用,目前已经广泛应用于云音乐各个主要APP。
传统埋点,就是BI数据人员根据策划想要的数据,设计出一个个的 单点 的坑位埋点,然后客户端人员逐个埋进来,这些埋点经常都存在以下特点:
市面上有很多人介绍 无痕埋点 ,我们曾经也做过类似的尝试;这种无痕,主要是针对一些坑位事件(比如点击、双击、滑动等事件)埋点做自动生成埋点,同时附带上生成的 xpath (根据view层级生成),然后把埋点上报到数据平台后,再将xpath赋予真实的业务意义,从而可以进行数据分析;
但是这个方案的问题是只能处理一些简单事件场景,并且数据平台做xpath关联是一件噩梦,工作量大,最主要的是 不稳定 ,对于埋点数据高精度场景,这个方案不可行(没有哪个客户端开发人员天天花费大量时间查找 xpath 是什么意义,以及随着迭代业务的开发,xpath由于不受控制的变化带来的数据问题带来的排查工作量是巨大的)。
特别对于资源位的曝光上,想要做到真正的无痕,自动埋点,是不太可行的;比如列表场景,底层是不认识一个cell是什么资源的,甚至都也不知道是不是一个资源。
对象是我们方案埋点管理和开发的基本单位,给一个 UIView 设置 _oid (对象Id: Object Id),该view就是一个对象; 对象分为两大类, page & element ;
对象&参数
对象不是孤立存在的,而是以 虚拟树(VTree) 的方式组合在一起的, 下面是一个示例:
虚拟树 VTree
虚拟树VTree有如下特点:
上面的方案介绍完之后,你一定存在很多疑惑,有了对象,有了虚拟树,对象有了参数,埋点在哪儿?
一个埋点除了有事件类型(action), 埋点时间等一些基本信息之外,还得有业务埋点参数,以及能体现出对象上下级的结构
先来看下一个普通埋点的格式:
{
"_elist": [
{
"_oid": "【必选】元素的oid",
"_pos": "【可选】,业务方配置的位置信息",
"biz_param": "【按需】业务参数"
}
],
"_plist": [
{
"_oid": "【必选】page的oid",
"_pos": "【可选】,业务方配置的位置信息",
"_pgstep": "【必选】, 该page/子page曝光时的页面深度"
}
],
"_spm": "【必选】这里描述的是节点的“位置”信息,用来定位节点",
"_scm": "【必选】这里描述的是节点的“内容”信息,用来描述节点的内容",
"_sessid": "【必选】冷启动生成,会话id",
"_eventcode": "【必选】事件: _ec/_ev/_ed/_pv/_pd",
"_duration": "数字,毫秒单位"
}
从上面的数据结构可以看出,数据结构是结构化的,坑位不是独立的,存在层级关系的
大部分的点击事件,都发生在如下四个场景上:
对于上述四种场景,我们采用了AOP的方式来内部承接掉,这里简单说明下如何做的;
1.UIView: 通过 Method Swizzling 方式来进行对关键方法进行hock,当需要给view添加TapGesture时,顺便添加一个我们自己的 TapGesture, 这样我们就可以在点击事件触发的时候增加点击埋点,关键方法如下:
1.对UIView点击事件的hock注意需要做到随着业务侧事件的增加/删除而一起增加/删除
关键代码如下:
@interface UIViewEventTracingAOPTapGesHandler : NSObject
@property(nonatomic, assign) BOOL isPre;
- (void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer *)gestureRecognizer;
@end
@implementation UIViewEventTracingAOPTapGesHandler
- (void)view_action_gestureRecognizerEvent:(UITapGestureRecognizer *)gestureRecognizer {
if (![gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]
|| gestureRecognizer.ne_et_validTargetActions.count == 0) {
return;
}
UIView *view = gestureRecognizer.view;
// for: pre
if (self.isPre) {
/// MARK: 这里是 Pre 代码位置
return;
}
// for: after
/// MARK: 这里是 After 代码位置
}
@interface UITapGestureRecognizer (AOP)
@property(nonatomic, strong, setter=ne_et_setPreGesHandler:) UIViewEventTracingAOPTapGesHandler *ne_et_preGesHandler; /// MARK: Add Category Property
@property(nonatomic, strong, setter=ne_et_setAfterGesHandler:) UIViewEventTracingAOPTapGesHandler *ne_et_afterGesHandler; /// MARK: Add Category Property
@property(nonatomic, strong, readonly) NSMapTable *> *ne_et_validTargetActions; /// MARK: Add Category Property
@end
@implementation UITapGestureRecognizer (AOP)
- (instancetype)ne_et_tap_initWithTarget:(id)target action:(SEL)action {
if ([self _ne_et_needsAOP]) {
[self _ne_et_initPreAndAfterGesHanderIfNeeded];
}
if (target && action) {
UITapGestureRecognizer *ges = [self init];
[self addTarget:target action:action];
return ges;
}
return [self ne_et_tap_initWithTarget:target action:action];
}
- (void)ne_et_tap_addTarget:(id)target action:(SEL)action {
if (!target || !action
|| ![self _ne_et_needsAOP]
|| [[self.ne_et_validTargetActions objectForKey:target] containsObject:NSStringFromSelector(action)]) {
[self ne_et_tap_addTarget:target action:action];
return;
}
SEL handlerAction = @selector(view_action_gestureRecognizerEvent:);
// 1. pre
[self _ne_et_initPreAndAfterGesHanderIfNeeded];
if (self.ne_et_validTargetActions.count == 0) { // 第一个 target+action 被添加的时候,才添加 pre
[self ne_et_tap_addTarget:self.ne_et_preGesHandler action:handlerAction];
}
[self ne_et_tap_removeTarget:self.ne_et_afterGesHandler action:handlerAction]; // 保障 after 是最后一个,所以先行尝试删除一次
// 2. original
[self ne_et_tap_addTarget:target action:action];
NSMutableSet *actions = [self.ne_et_validTargetActions objectForKey:target] ?: [NSMutableSet set];
[actions addObject:NSStringFromSelector(action)];
[self.ne_et_validTargetActions setObject:actions forKey:target];
// 3. after
[self ne_et_tap_addTarget:self.ne_et_afterGesHandler action:handlerAction];
}
- (void)ne_et_tap_removeTarget:(id)target action:(SEL)action {
[self ne_et_tap_removeTarget:target action:action];
NSMutableSet *actions = [self.ne_et_validTargetActions objectForKey:target];
[actions removeObject:NSStringFromSelector(action)];
if (actions.count == 0) {
[self.ne_et_validTargetActions removeObjectForKey:target];
}
if (self.ne_et_validTargetActions.count > 0) { // 删除当前 target+action 之后,还有其他的,则不需做任何处理,否则清理掉 pre+after
return;
}
SEL handlerAction = @selector(view_action_gestureRecognizerEvent:);
[self ne_et_tap_removeTarget:self.ne_et_preGesHandler action:handlerAction];
[self ne_et_tap_removeTarget:self.ne_et_afterGesHandler action:handlerAction];
}
- (BOOL)_ne_et_needsAOP {
return self.numberOfTapsRequired == 1 && self.numberOfTouchesRequired == 1;
}
- (void)_ne_et_initPreAndAfterGesHanderIfNeeded {
if (!self.ne_et_preGesHandler) {
UIViewEventTracingAOPTapGesHandler *preGesHandler = [[UIViewEventTracingAOPTapGesHandler alloc] init];
preGesHandler.isPre = YES;
self.ne_et_preGesHandler = preGesHandler;
}
if (!self.ne_et_afterGesHandler) {
self.ne_et_afterGesHandler = [[UIViewEventTracingAOPTapGesHandler alloc] init];
}
}
@end
2.UIControl: 通过 Method Swizzling 方式对关键方法进行hock,关键方法: sendAction:to:forEvent:
对UIcontrol点击事件的hock需要注意业务侧添加了多个 Target-Action 事件,不能埋点埋了多次
关键代码如下:
@interface UIControl (AOP)
@property(nonatomic, copy, readonly) NSMutableArray *ne_et_lastClickActions; /// MARK: Add Category Property
@end
@implementation UIControl (AOP)
- (void)ne_et_Control_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
NSString *selStr = NSStringFromSelector(action);
NSMutableArray *actions = @[].mutableCopy;
[self.allTargets enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) {
NSArray *actionsForTarget = [self actionsForTarget:obj forControlEvent:UIControlEventTouchUpInside];
if (actionsForTarget.count) {
[actions addObjectsFromArray:actionsForTarget];
}
}];
BOOL valid = [actions containsObject:selStr];
if (!valid) {
[self ne_et_Control_sendAction:action to:target forEvent:event];
return;
}
// pre
if ([self.ne_et_lastClickActions count] == 0) {
/// MAKR: 这里是 Pre 代码位置
}
[self.ne_et_lastClickActions addObject:[NSString stringWithFormat:@"%@-%@", [target class], NSStringFromSelector(action)]];
// original
[self ne_et_Control_sendAction:action to:target forEvent:event];
// after
if (self.ne_et_lastClickActions.count == actions.count) {
/// MARK: 这里是 After 代码位置
[self.ne_et_lastClickActions removeAllObjects];
}
}
@end
3.UITableViewCell: 先对 setDelegate: 进行hock,然后以 NSProxy 的形式将 Original Delegate 进行 封装 ,组成 Delegate Chain 的形式,然后在 DelegateProxy 内部做消息分发,从而可以完全掌控点击事件
1.该 Delegate Chain 的方式可以hock的不支持 点击事件,可以hock所有 Delegate 的方法
2.同样,也支持 pre & after 两个维度的hock
3.特别注意: 需要做到真正的 DelegateChain,不然会跟不少三方库冲突,比如 RXSwift,RAC,BlocksKit,IGListKit等
关键示例代码几个重要的相关方法 (代码较多不再展示,三方有多个库均可以借鉴):
- (id)forwardingTargetForSelector:(SEL)selector;
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector;
- (void)forwardInvocation:(NSInvocation *)invocation;
- (BOOL)respondsToSelector:(SEL)selector;
- (BOOL)conformsToProtocol:(Protocol *)aProtocol;
曝光埋点在传统埋点场景下是最棘手的,很难做到 高精度 埋点,埋点时机总是穷举不完,即使有了完善的规范,开发人员还总是会遗漏场景
我们这里的方案让开发者完全忽略曝光埋点的时机,开发者只把精力放在构建对象(或者说构建VTree),以及给对象添加参数上,下面看下是如何基于VTree做曝光的:
随着时间,会源源不断的生成新的VTree:
远远不断地生成VTree
比如T1时刻生成的VTree:
T1时刻的VTree
T2时刻生成的VTree:
T2时刻的VTree
先后两颗VTree的diff:
上面的diff结果,就是曝光埋点的结论
从上面以及VTree Diff的曝光策略,得出如下:
基于VTree的埋点,不管是点击、滑动等事件埋点,还是元素、页面的曝光埋点,转化成了如下两个开发步骤:
第一步: 给View设置oid
第二步: 给对象设置埋点参数
构建一个VTree,是需要遍历原始view树的,构建过程中有如下特点:
修改可见区域
被遮挡了
从虚拟树上来看,被遮挡的结果:
从虚拟树上来看,被遮挡的结果
一个常见的例子,拿云音乐首页列表举例子,每一个模块的title和资源容器(内部可横向滑动),分别是一个cell;图中的浅红色(模块)其实没有一个UIView与之对应,业务侧埋点需要我们提供 模块 维度的曝光数据(但是Android开发过程中,通常都有UI与之对应)
虚拟父节点
精细化埋点:
view的任何变化,都会引起VTree构建,看上去这是一件很恐怖的事情,因为每一次构建VTree都需要遍历整颗原始view树,我们做了如下优化来保障性能:
主线程runloop
关键代码如下:
/// MARK: 添加最小时长限流器
_throtte = [[NEEventTracingTraversalRunnerDurationThrottle alloc] init];
/// 至少间隔 0.1s 才做一次
_throtte.tolerentDuration = 0.1f;
_throtte.callback = self;
/// MAKR: runloop observer
CFRunLoopObserverContext context = {0, (__bridge void *) self, NULL, NULL, NULL};
const CFIndex CFIndexMax = LONG_MAX;
_runloopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, CFIndexMax, &ETRunloopObserverCallback, &context);
/// MAKR: Observer Func
void ETRunloopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
NEEventTracingTraversalRunner *runner = (__bridge NEEventTracingTraversalRunner *)info;
switch (activity) {
case kCFRunLoopEntry:
[runner _runloopDidEntry];
break;
case kCFRunLoopBeforeWaiting:
[runner.throtte pushValue:nil];
break;
case kCFRunLoopAfterWaiting:
[runner _runloopDidEntry];
break;
default:
break;
}
}
- (void)_runloopDidEntry {
_currentLoopEntryTime = CACurrentMediaTime() * 1000.f;
}
- (void)_needRunTask {
CFTimeInterval now = CACurrentMediaTime() * 1000.f;
// 如果本次主线程的runloop已经使用了了超过 16.7/2.f 毫秒,则本次runloop不再遍历,放在下个runloop的beforWaiting中
// 按照目前手机一秒60帧的场景,一帧需要1/60也就是16.7ms的时间来执行代码,主线程不能被卡住超过16.7ms
// 特别是针对 iOS 15 之后,iPhone 13 Pro Max 帧率可以设置到 120hz
static CFTimeInterval frameMaxAvaibleTime = 0.f;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSInteger maximumFramesPerSecond = 60;
if (@available(iOS 10.3, *)) {
maximumFramesPerSecond = [UIScreen mainScreen].maximumFramesPerSecond;
}
frameMaxAvaibleTime = 1.f / maximumFramesPerSecond * 1000.f / 3.f;
});
if (now - _currentLoopEntryTime > frameMaxAvaibleTime) {
return;
}
BOOL runModeMatched = [[NSRunLoop mainRunLoop].currentMode isEqualToString:(NSString *) self.currentRunMode];
/// MARK: 这里回调,开始构建 VTree
}
滚动中构建VTree
这个是SDK的重中之重的功能,目标是将app产生的所有埋点 链 起来,以协助数据侧统一一套模型即可分析漏斗/归因数据
refer是一段格式化的字符串,可以通过该字符串,在整个数仓中唯一定位到一个埋点,这就是链路追踪
通过上述三个参数,即可定位某一次app启动 & 一次页面曝光 周期内,哪一次的 交互 事件
[cid:ctype:ctraceid:ctrp]
格式: [_dkey:${keys}][F:${option}][sessid][e/p/xxx][_actseq][_pgstep][spm][scm]
位
option解析
先举一个典型的使用场景
歌曲播放-refer
过程解读:
_addrefer
_pgrefer
refer的查找:
根据上面refer的格式,数仓侧梳理出refer的格式统一解析,配合埋点管理平台,让规范化的漏斗/归因分析变为可能
对象维度的三个标准私参(组成了_scm): cid, ctype, ctraceid, ctrp
RN桥接
H5半白盒方案
客户端上传统的埋点都是看不见摸不着的,基于VTree的方案是结构化的,可以做到可视化查看埋点的数据,以及如何埋点的,下面是几个工具的截图
可视化工具-埋点层级结构
可视化工具-埋点数据
稽查:
该全链路埋点方案,已经全面在云音乐各个app铺开,并且P0场景已经完成数据侧切割,得到了充分的验证。
基于VTree可以做非常多的事情,比如:
作者:云音乐技术团队-dl
来源:https://zhuanlan.zhihu.com/p/564189923
留言与评论(共有 0 条评论) “” |