本篇文章给大家谈谈全链路埋点解决方案:自建VTree技术解析,以及对应的知识点,文章可能有点长,但是希望大家可以阅读完,增长自己的知识,最重要的是希望对各位有所帮助,可以解决了您的问题,不要忘了收藏本站喔。
针对这个问题,我们自研了一套全链路埋点方案,从埋点设计、到客户端三端( iOS 、 Android 、 H5 )开发、以及埋点校验&稽查、再到埋点数据使用,目前已经广泛应用于云音乐各个主要APP。
二、先聊聊传统埋点方案的弊端
三、我们曾经做过的一些尝试
3.1 无痕埋点
市面上有很多人介绍 无痕埋点 ,我们曾经也做过类似的尝试;这种无痕,主要是针对一些坑位事件(比如点击、双击、滑动等事件)埋点做自动生成埋点,同时附带上生成的 xpath (根据view层级生成),然后把埋点上报到数据平台后,再将xpath赋予真实的业务意义,从而可以进行数据分析;
但是这个方案的问题是只能处理一些简单事件场景,并且数据平台做xpath关联是一件噩梦,工作量大,最主要的是 不稳定 ,对于埋点数据高精度场景,这个方案不可行(没有哪个客户端开发人员天天花费大量时间查找 xpath 是什么意义,以及随着迭代业务的开发,xpath由于不受控制的变化带来的数据问题带来的排查工作量是巨大的)。
特别对于资源位的曝光上,想要做到真正的无痕,自动埋点,是不太可行的;比如列表场景,底层是不认识一个cell是什么资源的,甚至都也不知道是不是一个资源。
四、我们的方案
4.1 对象
对象是我们方案埋点管理和开发的基本单位,给一个 UIView 设置 _oid (对象Id: Object Id),该view就是一个对象; 对象分为两大类, page & element ;
对象&参数
- page对象: 比如 UIViewController.view, WebView, 或者一个半屏浮层的view,再或者一个业务弹窗
- element对象: 比如 UIButton, UICollectionViewCell, 或者一个自定义view
- 对象参数: 对象是埋点具体信息的承载体,承载着对象维度的具体埋点参数
- 对象的复用: 对象的存在,其中一个很大的原因,就是需要做复用,对于一些通用UI组件,尤为合适
4.2 虚拟树(VTree)
对象不是孤立存在的,而是以 虚拟树(VTree) 的方式组合在一起的, 下面是一个示例:
虚拟树 VTree
虚拟树VTree有如下特点:
- View树子集: 原始view树层级很复杂,被标识成对象的称为节点,所有节点就组合成了VTree,是原始view树的子集
- 上下文: 虚拟树中的对象,是存在上下关系的,一个节点的所有祖先节点,就是该对象(节点)的上下文
- 对象参数: 有了节点的上下层级,不同维度的对象,只关心自己维度的参数,比如歌单详情页中歌曲cell不关心页面请求级别的歌单id
- SPM: 节点及其所有祖先结点的oid组成了SPM值(其实还有position参数的参与,稍后再详解),该SPM可以唯一定位该节点
- 持续生成: VTree是源源不断的构建的,每一个view发生了变化,View的添加/删除/层级变化/位移/大小变动/hidden/alpha,等等,都会引起重新构建一颗新的VTree
五、埋点的产生
上面的方案介绍完之后,你一定存在很多疑惑,有了对象,有了虚拟树,对象有了参数,埋点在哪儿?
5.1 先来看下埋点格式
一个埋点除了有事件类型(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": "数字,毫秒单位"}从上面的数据结构可以看出,数据结构是结构化的,坑位不是独立的,存在层级关系的
5.2 点击事件
大部分的点击事件,都发生在如下四个场景上:
对于上述四种场景,我们采用了AOP的方式来内部承接掉,这里简单说明下如何做的;
1.UIView: 通过 Method Swizzling 方式来进行对关键方法进行hock,当需要给view添加TapGesture时,顺便添加一个我们自己的 TapGesture, 这样我们就可以在点击事件触发的时候增加点击埋点,关键方法如下:
- initWithTarget:action:
- addTarget:action:
- removeTarget:action:
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) NSMapTable2.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); NSMutableArray3.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;5.3 曝光埋点
曝光埋点在传统埋点场景下是最棘手的,很难做到 高精度 埋点,埋点时机总是穷举不完,即使有了完善的规范,开发人员还总是会遗漏场景
我们这里的方案让开发者完全忽略曝光埋点的时机,开发者只把精力放在构建对象(或者说构建VTree),以及给对象添加参数上,下面看下是如何基于VTree做曝光的:
随着时间,会源源不断的生成新的VTree:
远远不断地生成VTree
比如T1时刻生成的VTree:
T1时刻的VTree
T2时刻生成的VTree:
T2时刻的VTree
先后两颗VTree的diff:
- T1存在T2不存在的节点: 3, 4, 6, 7, 8, 11
- T1不存在T2存在的节点: 20, 21, 22, 23
上面的diff结果,就是曝光埋点的结论
- 曝光结束: 3, 4, 6, 7, 8, 11
- 曝光开始: 20, 21, 22, 23
从上面以及VTree Diff的曝光策略,得出如下:
5.4 埋点开发步骤
基于VTree的埋点,不管是点击、滑动等事件埋点,还是元素、页面的曝光埋点,转化成了如下两个开发步骤:
第一步: 给View设置oid
第二步: 给对象设置埋点参数
六、VTree的构建
6.1 VTree构建过程
构建一个VTree,是需要遍历原始view树的,构建过程中有如下特点:
修改可见区域
被遮挡了
从虚拟树上来看,被遮挡的结果:
从虚拟树上来看,被遮挡的结果
一个常见的例子,拿云音乐首页列表举例子,每一个模块的title和资源容器(内部可横向滑动),分别是一个cell;图中的浅红色(模块)其实没有一个UIView与之对应,业务侧埋点需要我们提供 模块 维度的曝光数据(但是Android开发过程中,通常都有UI与之对应)
虚拟父节点
精细化埋点:
6.2 构建过程的性能考虑
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 Funcvoid 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
6.3 性能相关数据
七、链路追踪
这个是SDK的重中之重的功能,目标是将app产生的所有埋点 链 起来,以协助数据侧统一一套模型即可分析漏斗/归因数据
7.1 链路追踪 refer 的含义
refer是一段格式化的字符串,可以通过该字符串,在整个数仓中唯一定位到一个埋点,这就是链路追踪
7.2 如何定义一个埋点
通过上述三个参数,即可定位某一次app启动 & 一次页面曝光 周期内,哪一次的 交互 事件
7.3 先来看看如何认识一个埋点坑位
[cid:ctype:ctraceid:ctrp]7.3 refer格式解析
格式: [_dkey:${keys}][F:${option}][sessid][e/p/xxx][_actseq][_pgstep][spm][scm]
位option解析
7.4 refer的使用
先举一个典型的使用场景
歌曲播放-refer
过程解读:
_addrefer_pgreferrefer的查找:
7.5 refer的统一解析
根据上面refer的格式,数仓侧梳理出refer的格式统一解析,配合埋点管理平台,让规范化的漏斗/归因分析变为可能
7.6 其他refer使用场景
7.7 refer对开发人员透明
对象维度的三个标准私参(组成了_scm): cid, ctype, ctraceid, ctrp
八、H5、RN
- RN: 做了一层桥接,可以在RN维度给view设置节点,同时设置参数
RN桥接
- 站内H5: 采用了半白盒方案,H5内部局部虚拟树,所有埋点通过客户端SDK产生,H5埋点到达SDK后,在native侧做虚拟树融合,从而将站内H5跟native无缝地衔接了起来
H5半白盒方案
九、可视化工具
客户端上传统的埋点都是看不见摸不着的,基于VTree的方案是结构化的,可以做到可视化查看埋点的数据,以及如何埋点的,下面是几个工具的截图
可视化工具-埋点层级结构
可视化工具-埋点数据
十、埋点校验&稽查
- 埋点是结构化的,虚拟树是在埋点平台管理起来的,埋点的校验,可以做到精确校验,校验出客户端的埋点虚拟树是否正确
- 以及每一个对象上埋点的参数是否正确
稽查:
- 在测试包、灰度包中,对产生的所有埋点在平台侧做稽查,并输出稽查报告,在版本发布前,对有问题的埋点问题进行及时的修复,避免上线带来数据问题
十一、落地
该全链路埋点方案,已经全面在云音乐各个app铺开,并且P0场景已经完成数据侧切割,得到了充分的验证。
十二、未来规划
基于VTree可以做非常多的事情,比如:
用户评论
这篇文章讲得真详细!我一直在找类似方案改进埋点的效率,没想到自建VTree真的这么强大! 尤其对大规模数据场景的解释很透彻,让我受益匪浅。 <br>
有19位网友表示赞同!
埋点的全链路追踪一直是老难题了,之前用传统方案实现的效果有限,看到这篇博客介绍基于自建VTree的新方案还是挺振奋的! 现在很多开源工具都是基于Trie树做埋点,感觉这个VTree更适合高性能和大规模的数据情况,期待后续深入了解具体的实现细节
有14位网友表示赞同!
我一直觉得单靠标准化埋点数据很难满足实际需求,总需要自定义,自建VTree这招确实不错! 可以根据项目需求灵活定制埋点字段和触发条件,提升数据的准确性。
有12位网友表示赞同!
搞了个基于VTree的全链路埋点的方案,确实能提高效率和处理能力,这一点很厉害! 但是文章没有细说VTree的具体结构和实现细节,希望作者能补充完善,方便大家进一步理解和学习。
有14位网友表示赞同!
这篇博客说的概念比较抽象,我感觉对不太了解树状数据结构的人来说有点难理解。希望能结合一些实际案例和代码示例更加直观地介绍一下自建VTree的优势和具体应用场景,这样更容易被更广泛的人群所接受
有6位网友表示赞同!
全链路埋点一直是我工作中遇到的痛点之一,效率低、数据量大很难集中分析整理。基于自建VTree的新方案确实很新颖,能够更有效地处理大量数据,提升埋点效率!希望能详细介绍该方案的部署和运维思路。
有9位网友表示赞同!
看了这篇博客,感觉自建VTree的思路很不错,可以针对具体项目灵活定制埋点规则,提高数据的准确性和价值。但这种方法相比于直接使用现成的埋点工具,开发和维护难度可能更大一些,需要慎重权衡利弊。
有19位网友表示赞同!
全链路埋点的确很重要,这个基于自建VTree的方案看起来很牛逼! 不过能不能具体说一下该方案在实际应用中的优势和改进效果呢?比如与传统方案相比,效率提升了多少?数据处理速度更快了吗?
有10位网友表示赞同!
对于技术实力强大的公司来说可以考虑自建VTree实现全链路埋点。 但是对于规模较小、人力有限的公司而言,或许更加适用于使用现成的开源工具来简化部署和维护工作。
有10位网友表示赞同!
这篇文章的写作风格有点过于学术,对于没有深入了解树状数据结构的人来说理解起来难度有点大。期望作者可以加入一些更通俗易懂的例子和讲解,让更多人能受益于这个方案。
有16位网友表示赞同!
自建VTree需要一定的开发成本和技术积累,但如果能够成功实现,带来的提升绝对值得!相信随着技术的进步和开源社区的发展,类似基于 VTree 的埋点方案会更便捷易用,并且得到越来越广泛的应用
有7位网友表示赞同!
全链路埋点一直是一个永恒的话题,自建VTree确实是个不错的思路! 文章讲解的非常详细,但是对于测试、调试方面没有具体提到,希望能补充一些相关内容,帮助我们更好地理解和实施。
有19位网友表示赞同!
基于自建VTree的方案确实能够有效提升全链路埋点的效率,但在实际应用中,还需要考虑数据安全性和隐私保护问题。 文章可以进一步探讨这些方面的解决方案
有18位网友表示赞同!
这个方案很有意思,对于数据驱动型的企业来说非常有价值! 提高埋点效率还能更快地获得用户行为数据分析结果,从而推动产品改进和商业决策。
有14位网友表示赞同!
自建VTree的思路很新颖,可以更灵活地定制埋点规则。 但相比于现有的成熟工具,其开发周期和维护难度可能更大一些…
有16位网友表示赞同!
这篇博文让我对全链路埋点有了更深刻的理解, 感谢作者分享这个创新方案! 我一定会尝试在我的项目中应用。 <br>
有19位网友表示赞同!