手把手带你实现西瓜视频的责任链埋点框架

前言

数据埋点通常是产品经理、数据分析师基于业务或产品需求对用户行为的每一个事件确定埋点需求。再由客户端上报埋点数据,后端记录数据进行一系列处理,并汇总后提供给产品经理、数据分析师进行数据分析或模型训练,帮助优化产品运营策略。

现有的 Android 埋点方案都存在些弊端,之前看到西瓜视频团队分享了基于责任链的埋点框架,感觉思路还不错。不过只分享实现思路和部分代码,没提供一套可用的框架,其中的埋点线索更是让很多人不理解怎么去实现。

所以个人就尝试封装一下,理解其核心思想后进行了改进和优化,最后仅用了 200 多行代码实现,简化用法的同时还兼顾了 Kotlin 和 Java 用法。接下来和大家分享西瓜视频的责任链埋点思路以及个人改进后的实现方案。

埋点思路

埋点需求

行为分析埋点通常需要包括某一事件发生时的前因、后果,以及事件发生对象的特征。在复杂的数据分析、模型训练等需求中,不仅仅需要获知某个事件的发生次数,对埋点上下文尤为关注。此处上下文指的通常有 2 类,分别是:

  • 事件发生的页面信息和页面位置信息;
  • 用户经过怎样的路径来到当前页面,也就是“来源”信息;

比如西瓜视频点击收藏的埋点场景,要求包含收藏影片的信息,所在的场景信息等。

图片

  1. 如果收藏事件发生在列表页,会上报如下的内容:
{
  "event": "click_favorite",
  "params": {
    "video_id": "123",                  // 影片ID
    "video_type": 2,                    // 影片类型
    "page_name": "feed",                // 当前页面
    "tab_name": "long_video"            // 当前所在的底Tab
    "channel_name": "lvideo_recommend", // 当前所在的频道
  }
}
  1. 如果收藏事件发生在详情页,会上报如下的内容:
{
  "event": "click_favorite",
  "params": {
    "video_id": "123",                       // 影片ID
    "video_type": 2,                         // 影片类型
    "page_name": "detail",                   // 当前页面
    "from_page": "feed",                     // 来源页面
    "from_tab_name": "long_video"            // 来源底Tab
    "from_channel_name": "lvideo_recommend", // 来源频道
  }
}

现有方案

直接传参

通过平台支持的参数传递方式,逐个定义并且读写参数。直接传参有非常显著的缺陷:

  • 每增加一个参数,都需要写大量的重复代码,工程代码膨胀;
  • 模块间约定了很多埋点参数的协议,耦合程度高,难以维护;
  • 一些场景的嵌套层次深,经过很多层的参数传递,非常容易漏报埋点参数;

单例传参

通过一个单例进行埋点参数的维护,程序中的任何位置都能方便地读和写埋点参数。这种方式带来的好处是不需要在每个类都定义大量的埋点参数,只需要访问单例进行修改和读取。会比前面的直接传参更简单,但这种方案治标不治本,同样有明显的弊端:

  • 单例的数据可能被多个位置写入,且一旦被覆盖就没法恢复,导致埋点参数上报错误;
  • 存放和清理的时机难以控制,清理早了会导致埋点参数缺失,忘记清理可能导致后面的埋点获取不到参数;

全埋点/无埋点

指埋点 SDK 通过编译时插桩、运行时反射或动态代理的方式,自动进行埋点事件的触发和上报,理论上能够搜集到所有页面、视图的曝光、点击等事件,无须客户端工程师手动进行埋点开发工作。理想很丰满,现实很骨感,看似很完美的方案也是有些弊端:

  • 仅能上报有限的简单事件类型,无法完成复杂事件的上报;
  • 全场景的数据上报,可能产生大量的无用数据,消耗大量传输、存储、计算资源;
  • 把复杂度从开发转嫁给了产品经理、数据分析师,消费成本较高;

西瓜视频的责任链方案

分析数据与视图节点的关系可以发现,埋点参数恰好就分布在视图树的责任链中。

图片 图片

结合跳转链路,逻辑上也是个树状结构。

图片

图片

所以我们需要的埋点上下文参数,理论上都可以通过节点的关系找到,然后通过责任链能很方便地收集到埋点参数。

各方案的优缺点

现有的三种埋点方案都有明显的缺点,全埋点或无埋点看似很美好,却只是个半自动方案,能自动上报的只有简单事件,复杂的事件只能手动处理,这又回到了直接传参或单例传参。

个人不推荐单例传参,因为不太可控,可能会被覆盖,清理时机不好把控,清早了丢数据。

直接传参是最稳的,但是会有大量的重复代码,并且嵌套过深可能会漏传参数。

而西瓜视频的责任链方案是直接传参的一种升级版,也是会传递参数,不过通过视图树和跳转链路建立的责任链自动收集埋点参数,代码量远比直接传参少很多。该方案也能作为全埋点或者无埋点的一种补充。

实现方案

Tracker 是基于西瓜视频的责任链埋点思路实现的轻量级埋点框架。个人理解其核心思想后进行了改进和优化,最后仅用了 200 多行代码实现,使用起来更加简单,并且兼顾了 Kotlin 和 Java 用法。

Gradle

在根目录的 build.gradle 添加:

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}

添加依赖:

dependencies {
    implementation 'com.github.DylanCaiCoding:Tracker:1.0.1'
}

用法

在 Application 初始化,传入一个 TrackHandler 实例。

initTracker(UMTrackHandler())
class UMTrackHandler : TrackHandler {
  override fun onEvent(context: Context, eventId: String, params: Map<String, String>) {
    MobclickAgent.onEvent(context, eventId, params) // 以友盟统计为例
  }
}

给 Activity、Fragment、View 设置埋点节点,通过视图树的层级关系(比如:Activity -> Fragment -> ViewHolder -> Button)建立节点的上下级责任链关系。

// In Activity or Fragment
trackNode = TrackNode("channel_name" to "recommend") 
holder.itemView.trackNode = TrackNode("video_id" to item.id, "video_type" to item.type)

设置来源节点和页面节点建立页面间的来源关系。

val intent = Intent(activity, DetailsActivity::class.java).putReferrerTrackNode(view)
activity.startActivity(intent)
activity.trackNode = PageTrackNode("page_name" to "details")

这样就能建立类似下图的责任链。

图片

后续就能通过任意控件去上报责任链上的埋点参数。

view.postTrack("click_favorite")

完整的 Kotlin、Java 用法请查看使用文档。本库有模拟西瓜视频埋点需求的示例代码,大家可以克隆项目运行 sample-javasample-kotlin,点击各个位置的收藏按钮查看埋点日志。

封装思路

下面带着大家完整地封装一次责任链埋点框架,会讲清楚每个类或函数是如果设计考虑的,还有个人做了哪些改进优化。

埋点参数

用于收集埋点参数吗,可以直接使用 HashMap ,不过个人综合考虑后还是定义了一个 TrackParams 类对 HashMap 进行了包装。

class TrackParams {
  private val map = mutableMapOf<String, String>()

  fun put(key: String, value: Any?): TrackParams = apply { map[key] = value.toString() }
  
  fun putAll(params: Map<String, String>): TrackParams = apply { map.putAll(params) }

  fun get(key: String): String? = map[key]

  fun toMap(): Map<String, String> = map

  override fun toString() = map.toString()
}

这么做主要有两方面考虑:

  • 屏蔽 HashMap 的 remove(key) 函数,避免在其它节点把已设置的埋点参数给删除了。
  • 兼顾 Java 的用法,put(key, value) 函数会返回当前的引用,在 Java 类可以链式调用连续设置多个埋点参数,使用起来更加方便。

埋点节点

原方案定义了 ITrackModelITrackNode 两个接口,一个是填充埋点参数,一个是建立上下责任链关系。

interface ITrackModel {
    fun fillTrackParams(trackParams: TrackParams)
}
interface ITrackNode: ITrackModel {
    fun parentTrackNode(): ITrackNode?
    fun referrerTrackNode(): ITrackNode?
}

由于可以使用视图树的层级关系建立页面内的责任链关系,那么其中一个接口是没太大必要的。并且提供两个接口的话还会增加学习成本,用户还要了解什么情况下用哪个接口。所以个人只保留了填充埋点参数的功能,只定义了一个 TrackNode 接口。

fun interface TrackNode {
  fun fillTackParams(params: TrackParams)
}

定义为 fun interface 是因为后面希望用 SAM 的特性简化使用代码,等下会讲到。

给 View 添加 trackNode 扩展属性,通过 getTag()setTag() 获取和保存变量。

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <item name="tag_track_node" type="id" />
</resources>
var View.trackNode: TrackNode?
  get() = getTag(R.id.tag_track_node) as? TrackNode
  set(value) {
    setTag(R.id.tag_track_node, value)
  }

这样我们就能把埋点节点保存到 View 中,之后可以遍历父控件看下有没埋点节点,有就收集参数。

可能有人会给 trackNode 属性设置 this 后实现接口,比如:

class VideoViewHolder(view: View) : RecyclerView.ViewHolder(view), TrackNode {

  private lateinit var item: Video

  init {
    itemView.trackNode = this
  }

  fun bind(item: Video) {
    this.item = item
  }

  override fun fillTackParams(params: TrackParams) {
    params.put("video_id", item.id).put("video_type", item.type)
  }
}

个人不太推荐这样的用法,代码不够直观。看到有设置埋点节点,想看下埋了什么参数还需要再找一个函数,有的类代码特别多话还得搜一下。所以个人让接口支持了 SAM,这样用 Lambda 表达式创建接口对象,设置 trackNode 属性就能看到埋了什么参数,比设置 this 直观很多。

holder.itemView.trackNode = TrackNode { params ->
  params.put("video_id", item.id).put("video_type", item.type)
}

但是个人觉得用法还不够简洁,还可以再优化一下,封装一个函数传入可变的 Pair 对象实例化 TrackNode 接口。

fun TrackNode(vararg params: Pair<String, String>): TrackNode =
  TrackNode { it.putAll(mapOf(*params)) }

通常函数名是小写开头,如果大写开头会有警告,但是这里并不会。因为函数名是返回值的类名,Kotlin 是不反对这种用法的,你可以理解为另外声明了一个 TrackNode 的构造函数。

之后就能用键值对创建节点对象了,代码又更加简洁直观了。

holder.itemView.trackNode = TrackNode("video_id" to item.id, "video_type" to item.type)

通常这么设置就行了,如果要做些判断操作,比如需要根据前面埋的参数来决定后面埋什么参数,就改用 Lambda 表达式的用法读取已埋的参数。

还有在 Activity 或 Fragment 设置埋点参数是个常见的需求,我们可以再增加 Activity.trackNodeFragment.trackNode 的扩展属性。

var Activity.trackNode: TrackNode?
  get() = window.decorView.trackNode
  set(value) {
    window.decorView.trackNode = value
  }

var Fragment.trackNode: TrackNode?
  get() = view?.trackNode
  set(value) {
    view?.trackNode = value
  }

这样在 Activity 或 Fragment 设置埋点参数就更加方便了。

trackNode = TrackNode("channel_name" to "recommend")

收集埋点参数

埋点节点设置好后,我们就可以通过点击的按钮或者其它控件去遍历父控件,判断有没 trackNode 属性,有的话就调用接口的 fillTackParams(params) 函数收集埋点参数。那么我们给 View 增加一个收集参数的扩展函数:

fun View.collectTrack(): Map<String, String> {
  var view: View? = this
  val params = TrackParams()
  val nodeList = mutableListOf<TrackNode>()
  while (view != null) {
    view.trackNode?.let { nodeList.add(it) }
    view = view.parent as? View
  }
  nodeList.reversed().forEach { node -> node.fillTackParams(params) }
  return params.toMap()
}

之后就能利用视图树收集埋点参数并上报了。

holder.btnFavorite.setOnClickListener { view ->
  val params = view.collectTrack()
  MobclickAgent.onEvent(view.context, "click_favorite", params) // 上报友盟
}

上报操作其实最好可以集中到一个地方处理,假设想国内用友盟上报,国外用 Firebase 上报,只需改一处就行。

我们定义一个 TrackHandler 接口来集中上报收集埋点参数。

fun interface TrackHandler {
  fun onEvent(context: Context, eventId: String, params: Map<String, String>)
}

增加个初始化函数缓存 TrackHandler 对象,再提供个扩展函数把收集到的埋点参数转发给 TrackHandler 对象。

private lateinit var application: Application
private var trackHandler: TrackHandler? = null

fun initTracker(app: Application, handler: TrackHandler) {
  application = app
  trackHandler = handler
}

fun View.postTrack(eventId: String) {
  trackHandler?.onEvent(application, eventId, collectTrack())
}

后续上报埋点参数只需调用一下 View.postTrack(eventId) 扩展函数即可。

class UMTrackHandler : TrackHandler {
  override fun onEvent(context: Context, eventId: String, params: Map<String, String>) {
    MobclickAgent.onEvent(context, eventId, params)
  }
}

// 初始化
initTracker(this, UMTrackHandler())

// 上报埋点参数
holder.btnFavorite.setOnClickListener { view ->
  view.postTrack("click_favorite")
}

建立页面间的责任链

前面只是实现了收集页面内的埋点参数,我们肯定不可能只收集单个 Activity 的埋点,需要把每个页面的埋点参数传递下去。所以需要用一个 View 来收集视图树上的埋点参数,并用 Intent 传递下去。

那么我们增加一个 Intent.putReferrerTrackNode(view) 扩展,设置一个来源节点。

private const val KEY_TRACK_PARAMS = "track_params"

fun Intent.putReferrerTrackNode(view: View): Intent =
  putExtra(KEY_TRACK_PARAMS, view.collectTrack() as Serializable)

然后我们需要在下一个 Activity 接收埋点参数,那么给 Activity 设置一个特殊的 TrackNode,该节点填充埋点数据时会先将之前传递的埋点参数设置了,并且还支持 key 值的映射,比如上个页面上报的 page_name, 到下个页面会自动改成上报 from_page

我们定义一个 PageTrackNode 类来完成以上事情:

class PageTrackNode(
  activity: Activity,
  private val referrerKeyMap: Map<String, String> = emptyMap(),
  private val trackNode: TrackNode = TrackNode { }
) : TrackNode {

  constructor(activity: Activity, vararg params: Pair<String, String>) :
      this(activity, emptyMap(), * params)

  constructor(activity: Activity, referrerKeyMap: Map<String, String>, vararg params: Pair<String, String>) :
      this(activity, referrerKeyMap, TrackNode { it.putAll(mapOf(*params)) })

  @Suppress("UNCHECKED_CAST")
  private val referrerParams = activity.intent.getSerializableExtra("KEY_TRACK_PARAMS") as? Map<String, Any?>

  override fun fillTackParams(params: TrackParams) {
    referrerParams?.forEach {
      params.put(referrerKeyMap.getOrElse(it.key) { it.key }, it.value)
    }?.let {
      trackNode.fillTackParams(params)
    }
  }
}

这里让 PageTrackNodeTrackNode 那样支持用键值对或者 Lambda 表达式创建对象,用法更加统一,学习成本更低。

之后就能建立页面间的责任链关系了,有两个步骤,给 Intent 设置来源节点,然后给下一个 Activity 设置页面节点。

val intent = Intent(activity, DetailsActivity::class.java)
  .putReferrerTrackNode(view) // 设置来源节点
activity.startActivity(intent)
val referrerKeyMap by lazy {
  mapOf("page_name" to "from_page", "tab_name" to "from_tab_name", "channel_name" to "from_channel_name")
}

// 给 Activity 设置页面节点
trackNode = PageTrackNode(this, referrerKeyMap, "page_name" to "details")

这样我们就补充了下图的红色线关系,指明从哪个 View 到哪个 Activity,整个责任链关系就完整了。

图片

之后就能通过任意一个 View 去收集责任链上的埋点参数了。

埋点线索

埋点线索是西瓜视频埋点方案最难理解的一个部分,但这又是非常重要的功能,用法如下:

//实现ITrackModel接口
class RecordInfo : ITrackModel {
    var isRecord = false

    override fun fillTrackParams(params: TrackParams) {
        params.put("is_record", isRecord.toYesOrNo())
    }
}

// 在某个合适的时机,比如进入拍摄页面,开启埋点thread,添加TrackModel
node.startTrackThread().putTrackModel(RecordInfo())

// 任意节点上更新thread
node.trackThread?.getTrackModel(RecordInfo::class.java).isRecord = true

// 上报埋点
view.newTrackEvent("click_publish") // 通过newTrackEvent创建Event实例
    .with(RecordInfo::class.java) // 声明需要上报TrackThread中的RecordInfo
    .emit() // 最终计算并上报埋点

个人总结了下埋点线索有两个作用:

  • 共享参数。在后续的所有关联节点中,都能够通过已经建立的责任链,访问到埋点线索进行读写和更新。
  • 可选择性上报。有些非来源的埋点参数不适合埋在 View 中,比如用 result 参数上报失败原因,这是登录注册等可失败的操作才需要的埋点参数,不应该让其它埋点事件收集到。

原文没有具体地讲解怎么实现的,个人就尝试自己实现一下。原以为和传递页面间的埋点参数很类似,只是让传递的参数变得可选。可以用类名和节点做个映射,上报时可以传入类名去找到对应的线索节点。但实现出来之后发现并没有真正地做到共享。即使把节点对象用 intent 传出去,到下个 Activity 取出的也不是同一个对象。如果在之后的页面修改了埋点线索的参数,回到前面页面上报的会是没有被修改的,这就谈不上共享了。

想了很久好像只能改成用单例缓存,使用单例的话要在页面销毁的时候清理缓存,这就要监听声明周期。后面发现西瓜视频好像就是这个思路,因为原文提到了“任意起始节点都可以初始化一个 TrackThread”,起始节点指的是 Activity,好像也是限制在 Activity 才能开启埋点线索,这样也就能监听生命周期。

那么就来实现一下吧,给 Activity 增加一个设置线索节点的扩展,把节点缓存到 View 和单例中,在 Activity 销毁时清理单例的缓存。

private val allThreadNodes by lazy { mutableMapOf<String, TrackNode>() }

fun ComponentActivity.putThreadTrackNode(trackNode: TrackNode) {
  val threadNodeSet = window.decorView.getTag(R.id.tag_thread_nodes) as? MutableSet<TrackNode> ?: mutableSetOf()
  threadNodeSet.add(trackNode)
  window.decorView.setTag(R.id.tag_thread_nodes, threadNodeSet)
  allThreadNodes[trackNode.javaClass.name] = trackNode
  lifecycle.addObserver(object : DefaultLifecycleObserver {
    override fun onDestroy(owner: LifecycleOwner) {
      allThreadNodes.remove(trackNode.javaClass.name)
    }
  })
}

为什么要在两个地方做缓存呢?这就要讲一种特殊的情况,比如页面是 A-> B-> C-> D-> E 跳转的,有可能后续的是新流程不需要前面的埋点参数,在 C-> D 没有建立责任链关系,此时就有 A-> B-> C 和 D-> E 两条责任链。如果在 A、D 页面都设置了同样的线索节点,那么从 D 回到 C 页面时会把该线索节点的缓存清掉,那么在 A-> B-> C 的责任链就取不出线索节点,上报时可能会缺失参数。

所以应该要在 View 中缓存该责任链添加过了哪些线索节点,即使单例在其它责任链清理了缓存,我们仍然能从 View 中获取到线索节点。单例的作用只是确保后续页面使用同一个线索节点对象。

我们需要把线索节点传到后续的页面,那就修改一下设置来源节点的扩展和页面节点的代码。使用 Intent 传递线索节点的类名,在下一个页面用类名获取单例中的节点对象并缓存到 View 中。

private const val KEY_TRACK_THREAD_NODES = "track_thread_nodes"

fun Intent.putReferrerTrackNode(view: View?): Intent = putExtra(KEY_TRACK_PARAMS, view?.collectTrack() as? Serializable)
  .putExtra(KEY_TRACK_THREAD_NODES, view?.findThreadNodeSet()?.map { it.javaClass.name }?.toTypedArray())
  
private fun View.findThreadNodeSet(): Set<TrackNode>? =
  getTag(R.id.tag_thread_nodes) as? Set<TrackNode> ?: (parent as? View)?.findThreadNodeSet()

class PageTrackNode(
  activity: Activity,
  private val referrerKeyMap: Map<String, String> = emptyMap(),
  private val trackNode: TrackNode = TrackNode { }
) : TrackNode {

  init {
    val threadNodeSet = intent.getStringArrayExtra(KEY_TRACK_THREAD_NODES)
      ?.map { allThreadNodes[it] }?.toMutableSet()
    activity.window.decorView.setTag(R.id.tag_thread_nodes, threadNodeSet)
  }
  
  //...
}

还要提供一个更新线索节点的扩展函数。

fun <T : TrackNode> View.updateThreadTrackNode(clazz: Class<T>, callback: T.()) =
  findThreadNodeSet()?.find { it.javaClass.name == clazz.name }
    ?.let { callback.apply { (it as? T)?.invoke() } }

给上报的扩展函数增加可变的类名参数,在上报的时候可指明用到哪些线索节点。

fun View.postTrack(eventId: String, vararg clazz: Class<*>) {
  trackHandler?.onEvent(application, eventId, collectTrack(*clazz))
}

fun View.collectTrack(vararg classes: Class<*>): Map<String, String> {
  // ...
  findThreadNodeSet()?.filter { node -> classes.any { node.javaClass.name == it.name } }
    ?.forEach { node -> node.fillTackParams(params)
  return params.toMap()
}

来看下怎么使用,这里修改了原方案开启埋点线索 startTrackThread() 的用法,改成直接设置线索节点。

//实现 TrackNode 接口
class RecordTrackNode : TrackNode {
  var isRecord = false

  override fun fillTackParams(params: TrackParams) {
    params.put("is_record", it)
  }
}

// 在某个合适的时机,比如进入拍摄页面,添加线索节点
activity.putThreadTrackNode(RecordTrackNode())

// 任意 View 都可以更新线索节点
view.updateThreadTrackNode<RecordTrackNode> { isRecord = true }

// 上报埋点
view.postTrack("click_publish", RecordTrackNode::class.java) 

这么做的目的是统一节点的用法,降低学习成本,对用户来说有四种节点可以添加,普通节点、来源节点、页面节点和线索节点,而线索节点可以共享和更新。

以上就是完整封装思路和代码了,如果你不想自己封装,可以来使用个人封装好的开源库 Tracker

参考文献

总结

本文分析了现有埋点方案的弊端和西瓜视频责任链埋点方案的优势,责任链思路还是蛮有意思的,之后就带着大家一起来把该方案实现出来,其中也分享了一些个人改进的地方。最后分享了个人实现好的开源库 Tracker,仅用了 200 多行代码实现,并且兼顾了 Kotlin 和 Java 用法,欢迎大家来试用一下。如果您觉得有帮助的话,希望能点个 star 支持一下哦 ~ 个人会分享更多封装相关的文章和好用的开源库给大家。

关于我

一个兴趣使然的程序“工匠” 。有些完美主义,喜欢封装,对封装有一定个人见解。GitHub 有分享一些帮助搭建开发框架的开源库,有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈,有其它封装相关的问题也可以找我探讨一下。

讲解封装思路的文章

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7121735217701715981