Hand-in-hand with you to realize the Chain of Responsibility Embedding Framework of Watermelon Video

foreword

Data tracking is usually where product managers and data analysts determine tracking requirements for each event of user behavior based on business or product requirements. Then, the client reports the buried point data, the back-end records the data for a series of processing, and provides it to product managers and data analysts for data analysis or model training after aggregation to help optimize product operation strategies.

The existing Android tracking solutions have some drawbacks. I saw the Xigua Video team shared the tracking framework based on the chain of responsibility before , and I feel that the idea is not bad. However, it only shares the implementation ideas and some codes, and does not provide a set of usable frameworks, and the buried clues make many people do not understand how to implement it.

So I tried to encapsulate it, and after understanding its core idea, I improved and optimized it, and finally implemented it with only more than 200 lines of code, simplifying the usage and taking into account the usage of Kotlin and Java. Next, I will share with you the idea of ​​​​the responsibility chain of Xigua Video and the implementation plan after personal improvement.

Bury some ideas

Buried demand

Behavior analysis buried points usually need to include the antecedents, consequences of an event, and the characteristics of the object of the event. In complex data analysis, model training and other requirements, it is not only necessary to know the number of occurrences of an event, but also to pay special attention to the context of buried points. The context here usually refers to two categories, namely:

  • The page information and page location information where the event occurred;
  • What path did the user take to reach the current page, that is, the "source" information;

For example, the buried point scene of the watermelon video click on the collection is required to include the information of the collection video, the scene information, etc.

picture

  1. If the favorite event occurs on the list page, the following content will be reported:
{
  "event": "click_favorite",
  "params": {
    "video_id": "123",                  // 影片ID
    "video_type": 2,                    // 影片类型
    "page_name": "feed",                // 当前页面
    "tab_name": "long_video"            // 当前所在的底Tab
    "channel_name": "lvideo_recommend", // 当前所在的频道
  }
}
  1. If the favorite event occurs on the details page, the following content will be reported:
{
  "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", // 来源频道
  }
}

Existing program

direct parameter

Through the parameter passing method supported by the platform, define and read and write parameters one by one. Passing parameters directly has very significant drawbacks:

  • Every time a parameter is added, a lot of repetitive code needs to be written, and the engineering code is bloated;
  • A lot of protocols for buried point parameters are agreed between modules, which has a high degree of coupling and is difficult to maintain;
  • Some scenes are deeply nested, and after many layers of parameter transmission, it is very easy to miss the buried point parameters;

singleton pass

Through a singleton to maintain the buried point parameters, any location in the program can easily read and write the buried point parameters. The advantage of this method is that it does not need to define a large number of buried parameters in each class, and only needs to access the singleton for modification and reading. It will be simpler than the previous direct parameter transfer, but this solution also has obvious drawbacks:

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

全埋点/无埋点

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

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

西瓜视频的责任链方案

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

picture picture

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

picture

picture

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

各方案的优缺点

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

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

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

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

实现方案

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

参考文献

总结

This article analyzes the drawbacks of the existing burying scheme and the advantages of the Xigua Video Responsibility Chain burying scheme. The chain of responsibility idea is quite interesting. After that, I will take everyone to realize the scheme, and also share some personal improvements. place. Finally, I shared the tracker , a good open source library implemented by myself. It only took more than 200 lines of code to implement it, and took into account the usage of Kotlin and Java. You are welcome to try it out. If you find it helpful, I hope you can click a star to support it~ I will share more articles related to packaging and easy-to-use open source libraries for everyone.

about me

An interest-driven program "Craftsman" . Some perfectionists, like encapsulation, and have a certain personal opinion on encapsulation. GitHub shares some open source libraries that help build development frameworks. If you have any questions or needs, you can submit issues or add me WeChat for direct feedback. If you have other packaging-related issues, you can also contact me to discuss.

Articles explaining packaging ideas

I am participating in the recruitment of the creator signing program of the Nuggets Technology Community, click the link to register and submit .

Guess you like

Origin juejin.im/post/7121735217701715981