1. 背景
业务埋点的开发和维护一直以来都是研发环节中的一个痛点,对于产品来说埋点数据可以帮助判断需求上线后的运行情况,对于开发而言则需要在编写业务逻辑的同时,还需要增加一些数据采集上报埋点平台的任务,我们也一直在探索一些提效的解决方案,这里先简单概述下埋点开发中的几个痛点:
主要痛点概述:
- 无法完全自动化: 埋点逻辑主要集中在业务数据采集,无法通过类似AOP等技术来解决全部的埋点需求
- 埋点 的属性数据繁多: 主要属性都集中在业务逻辑数据中,和业务紧密相关
- 埋点 逻辑掺杂 业务 逻辑: 业务逻辑中掺杂了很多埋点逻辑代码,不便于维护和排查问题
- 新增 埋点 逻辑占用太多开发时间: 遇到埋点稍多的需求,会占用太多开发时间,需要一种高效的方式来让开发人员快速的增加埋点逻辑
上述的这些显而易见的问题,是我们一直以来不断去探索埋点方案的一个重要原因,在不断的需求开发节奏中,找出一种能快速实现埋点需求,又能和业务逻辑解耦合,同时还能提高我们的编写埋点代码的效率,这也是我们本文将要讨论的埋点方案和具体的实践过程。
2. 目前面临的问题
这部分主要从货拉拉APP开发中,针对埋点遇到的一些痛点,进行详细的描述,也让大家了解后续我们的方案主要解决的核心问题是什么,以及如何在现有的情况下,来通过我们这套方案去改善现状,优化我们的架构。
2.1 埋点现状架构图
在讲述痛点之前,我先用一张目前我们业务中埋点现状的架构图,从宏观的角度来看下当前的埋点逻辑情况。
从这幅架构简图中,我们可以看到项目在基于MVP架构模式开发下,对于埋点数据的采集、聚合、以及最后的上报,都没有一个合适的设计来支撑,也可能是项目在初期就对埋点这种逻辑不敏感,忽略了它在日后不断增量所带来的问题,因此造就了现在开发所面临的问题挑战。
2.2 痛点一:组件间数据传递繁琐
在组件化的架构模式下,大到模块之间的信息传递,小到组件内部的MVP(单元)之间的数据交互,组件之间的交互是非常频繁的,拿MVP模式的Protocol
为例,通常使用协议
来定义一个类的功能接口,每个类的Protocol
只开放和这个类功能息息相关的接口,符合"单一原则"和"开放封闭原则"。
那接下来我来看下业务中的埋点事件:
scss
//埋点属性通常都是 key-value 的形式Map形式
事件名:test_eventName_key
属性值(map):
{
key_a:value_a
key_b:value_b } 模块x(比如:车型、地址...)
key_c:value_c
...
key_d:value_d
key_e:value_e } 模块x(比如:支付方式、发票...)
key_f:value_f
...
key_z:value_z } 模块x(比如:额外服务、订单备注、货物信息...)
}
埋点数据通常情况下覆盖了当前业务Page
中的大部分的模块,这在货拉拉APP中十分场景,比如:首页、确认下单页、订单详情页、等待应答页等页面,每个页面包含的组件少则5-6,多则10+种小模块,如果埋点要求上报这些模块内的数据,那就需要将这些数据先采集组合成map
,然后上报给埋点平台,那么采集过程中,大部分的逻辑都落在了各个类的 Protocol
中,因为模块之间的交互都是通过接口来传递的,所以Protocol
中就会出现一些埋点相关的接口。
scss
@protocol xxx {
func trace(x,y)
...
}
因为埋点的触发逻辑可能在任意的类中任意地方出现,因此每个类都要做好获取上述所有埋点数据的能力准备,这就造成了我们业务代码中,本来就比较复杂的业务逻辑,再混入一些埋点相关的逻辑,就会变得更加的难以维护。
2.3 痛点二:埋点属性采集方式不统一
埋点事件中具体属性值的采集,严格来说应该是声明和修改的地方保持单一,通常由所属的模块负责,比如车型的名称和国标ID,都是由车型模块进行维护,其他模块使用的时候通过统一的方式读取即可,这样如果变量值有改动,则各个引用的地方会同步修改,而现实中这些值在上述讲到的模块传递问题中,会出现不同模块,传递的形式也不同,有的可能是作为方法的参数获取的,有的则是通过其他对象持有获取的,维护起来会特别麻烦。
2.4 小结
开发中上述痛点,是主要的影响点,数据的采集和组装占据了大部分的埋点开发工作量,同时这些痛点也会对架构有所腐蚀,因为需求的迭代和周期,有时候并不能同步到埋点中,这就导致维护滞后等副作用。
3. 埋点方案设计
在正式介绍方案之前呢,我们先看了解下目前业内APP埋点的一些主流方案:
代码埋点 | 可视化埋点 | 自动埋点 | |
---|---|---|---|
优势 | 灵活性强,精准控制,轻量 | 无需开发,实时生效,不依赖版本发布 | 实现简单,自动化,实时数据分析 |
劣势 | 工作量大、易出错,调试困难,无法动态下发 | 埋点数据存在局限性 | 灵活性差,数据冗余,隐私问题 |
适用场景 | 需要精细控制收集数据和埋点数据结构的情况。 | 比较规范和简单的页面,主要分析一些点击事件场景 | 数据收集需求较为标准的场景,如应用的日常运营、用户行为分析等 |
上述三类方案,基本涵盖了业内主流的埋点方案,而还有一种是采用混合(代码埋点 + 自动埋点)埋点,这也是一种主流的方案之一,货拉拉用户端也是采用了这种模式,通用的页面浏览和控件点击都通过自动埋点实现,而其余的则是通过代码埋点实现。
所以本次方案优化改造的核心是围绕代码埋点这部分进行的,宗旨是如何高效且尽量无侵入的方式,对代码埋点进行改造和优化。
3.1 方案整体概述
方案的大致核心功能点如下:
- 独立出埋点管理模块,各组件间直接通过埋点管理模块读取和注册数据
- 移除各个组件的
Protocol
文件中显式的埋点相关逻辑 - 各组件间不再直接和埋点数据进行通讯,组件间只需要通过特定方式暴露自己的埋点所需数据即可
- 埋点统一管理,引入
Scope
概念
改造后的埋点架构简图:
上述架构图中可以看出,我们对埋点的采集和上报做了比较大的改动,去除了各个组件内关于埋点数据需要暴露出来的接口API,另外还引入了"公共数据"以及"Scope"概念,通过各个模块独立的埋点数据"声明",来将该模块的数据提前暴露出来,注意这个时候数据只是被"声明"出来,并不是提前保存,只有埋点用到时,采取实时获取确定的值。
下面就对这些概念进行详细说明。
3.2 独立出埋点管理模块(TrackifyKit)
如需从业务中剥离解耦埋点这部分逻辑,那么就需要独立出一个单独的模块来承接。模块作为二方库的形式引入进来,并且由一个TraceManager
单例类来和业务进行交互,TraceManager
会对外暴露出三种类型的API,来供业务模块去调用,这三种分别是:
swift
func traceUpdateCommonData(with dataDic:Dictionary<String, Any>)
func traceRegisterCatcher(scope:TraceEventScope,
host:AnyObject,
pageVC:UIViewController?,
catcher:@escaping Catcher)
func traceTrigger(eventName:EventName,
withProperties:[PropertiesName:Any]?,
pageVC:UIViewController?)
func traceTrigger(eventName:EventName,
withProperties:[PropertiesName:Any]?,
scopeKeys:[TraceEventScope:[PropertiesName]]?,
alias:[String:String]?,
pageVC:UIViewController?)
下面对这三类API做更详细的解释:
3.3 更新公共属性
考虑到项目中会有一些公共属性需要统一上报,比如:城市定位、个人信息等相关信息,这里为了方便业务使用,可以允许业务更新一些全局的数据,你可以把他理解为一个全局的map,方便后续埋点上报组合数据的的时候使用
swift
/// 埋全局通用数据
/// - Parameter dataDic: [k,v]]
public func traceUpdateCommonData(with dataDic:Dictionary<String, Any>) {}
3.4 注册Catcher捕获器提前声明埋点字段
Catcher 概念是我们这次方案的核心,它的原理是通过业务组件,主动向 Eventbus 中注册一个 Catcher 对象,它并不会强引用业务组件类,而这个 Catcher 会在埋点触发的时候,自动去动态的获取注册类中的数据,这部分数据就会通过Catcher 对象,进入EventBus,从而和其他埋点数据进行组合、过滤、处理等操作,最终完成数据上报。
接下来看下如何在业务中注册 Catcher 捕获器:
less
/// 注册一个埋点Catcher ⚠️注意 weak self
/// - Parameters:
/// - scope: Catcher 所属的 Scope
/// - host: Catcher 所在的生命周期对象
/// - catcher: Catcher block
/// - pageVC: 关联的页面
@objc public func traceRegisterCatcher(scope:TraceEventScope,
host:AnyObject,
pageVC:UIViewController?,
catcher: @escaping Catcher) {
}
业务中所有的类,只要是需要提供一些埋点属性,都可以在类的初始化时机,调用上面API,来进行埋点属性注册,通过这个注册,将内部需要的属性值提前准备好,等待埋点管理Eventbus内部使用。
swift
func registerTraceCatcher() {
TraceManager.shared.traceRegisterCatcher(scope: "vehicleScope",
host: self,
pageVC: nil) { [weak self] in
guard let self = self else { return nil }
return [
"key":"vaule",
...
...
]
}
}
这个方法首先需要入参一个 scope
参数,这个可以根据业务模块进行不同粒度的划分,host
则是当前类对象,这个参数主要是在埋点管理器内部通过 host 对已经释放的业务类进行 catcher
清理使用,pageVC
参数是应对导航栈中可重复push
多个同一类,则不同示例的页面,为了做socpe 区分,相同对象的内部数据必须进行区分获取。
3.5 埋点上报
说完了catcher 其实埋点的数据准备工作已经讲完了,各个模块类的内部已经通过 traceRegisterCatcher
方式将各个埋点事件所需要的数据提前准备好了,接下来就看下埋点触发的逻辑。
下面是埋点触发所需API:
swift
/// 埋点上报
/// - Parameters:
/// - eventName: 埋点事件名
/// - withProperties: 埋点属性[k,v]
/// - scopeKeys: scope-keys 对
/// - alias:字段别名
/// - pageVC: 关联的页面
@objc public func traceTrigger(eventName:EventName,
withProperties:[PropertiesName:Any]?,
scopeKeys:[TraceEventScope:[PropertiesName]]?,
alias:[String:String]?,
pageVC:UIViewController?) {
}
入参分析:
eventName: 埋点的事件名,也是埋点信息中必不可少。
withProperties:埋点的属性值,这里的属性值不要求必须传入埋点所有的必要字段,只需要传入当前类Context 中能够直接获取到的字段,那其余的字段怎么获取呢?这就用到我们前面 3.4 讲的Catcher 捕获器了,通过它去其他模块试试获取数据。
scopeKeys: 这个参数需要传入的类型是 Map<scope,[PropertiesName]>
,key 代表 scope 也就是每个类在调用 traceRegisterCatcher
方法注册 catcher
的时候声明的 scope
参数,有了这个scope ,埋点管理器才知道需要去哪个类中获取埋点字段,因此前提是你要提前在各个类中调用 traceRegisterCatcher
方法。[PropertiesName]
这个数组就是需要的字段名称 key,根据埋点文档传入即可。
alias: 这个入参则是一个修改字段名称的别名方式,可能各个埋点会有相同数据,但是字段上报的key会不相同,这里可以做下别名配置。
pageVC: 这个入也是和 traceRegisterCatcher
方法的入参中 pageVC 一一对应,用来区分导航栈中相同类不同实例的页面。
接下来再看下埋点上报的一个demo实例:
less
let scopeKeys = [ module_a_scope:[ "properties_name_x", ... ],
module_b_scope:[ "properties_name_x", ... ],
module_c_scope:[ "properties_name_x", ... ]
]
TraceManager.shared.traceTrigger(eventName: "confirmorder_click",
withProperties: ["module_name":moduleName],
scopeKeys: scopeKeys)
这样上报埋点,字段的获取全部通过埋点管理器的 scope 去各个类中注册的 catcher 里面实时获取,然后在管理器内部进行过滤、组合、更名等操作后,再交给埋点平台上报,整个流程不需要再各个类的业务Interface
类中声明埋点相关的逻辑,这部分逻辑统一由管理器去管理,实现了和业务逻辑解耦的效果。
3.6 渐进式改造
新的方案并不需要项目中所有的埋点都进行同时改造,而是可以和之前的代码埋点逻辑并存,开发人员可以根据节奏逐渐替换,货拉拉用户端在接入该方案后,埋点代码逻辑更加的清晰,且和业务解耦,业务层只负责提供属于自己的那部分数据等待获取,而埋点触发的地方也只需要根据不同的key去取相应的数据即可。
某业务模块:
swift
// MARK: -- Module_A_Presenter 模块
@class Module_A_Presenter {
init() {
registerTraceCatcher()
}
}
// MARK: -- 埋点注册
extension Module_A_Presenter {
func registerTraceCatcher() {
TraceManager.shared.traceRegisterCatcher(scope: "Module_A_Presenter_Scop",
host: self,
pageVC: nil) { [weak self] in
guard let self = self else { return nil }
return [
"key_1":self.key_1,
...
]
}
}
}
这样就完成了该模块的改造,而无需像之前那种方式,把这些数据通过 Interface
暴露出去,造成埋点逻辑和业务逻辑混杂在一起。另外声明式的这种埋点方式,也大大提高了埋点逻辑的可维护性,各自模块各自维护,数据收集再分别去各个模块获取,减少了模块之间的交互频率。
4. 总结
以上内容就是本期给大家分享的一个基于"声明式"的管理复杂业务埋点的方案,该方案实现起来并不复杂,主要是改变出一种新的埋点思路,来去解决现有代码埋点的问题,俗话说"没有最好的架构,只有最适合的架构",适合的架构都是一点点优化重构堆砌出来的,就像本文所分享的方案,优化埋点的上报形式也是对项目架构的一种优化,让我们的产品迭代和维护都更加的高效。