前言
数据规范
结语
随着Flutter的接入,越来越多重要的业务场景已由Flutter承担,且Flutter的比重在今后也会逐渐扩大,那么Flutter的基础建设就显得尤为重要。
在开发者的角度,我们希望它高效简便;
在数据分析者角度,我们希望它合理稳定;
在产品设计者角度,我们希望它准确有效。
一条有效的埋点信息应该包含四种信息:基础信息,业务信息,rtpCnt和rtpRefer。
包含了一些基本信息,如版本号,操作系统,设备名称等,该信息由Android和iOS在进行埋点上报时自动生成,无需Flutter做任何操作。
业务信息是埋点的主体,包含了事件类型、页面参数、PM设置的自定义信息等,这些信息往往是实际埋点开发时需要接触和注意的,主要有以下字段:
//埋点事件名称
event
ext {
//业务的自定义信息
…
//页面参数信息
pageParam
}
rtpCnt代表了当前埋点信息对应的操作的唯一标识,通过rtpCnt可以唯一地确定与用户交互的具体元素,它是由五个部分组成的字符串,格式如下:A.B.C.D.E
rtpRefer代表了上一次用户操作行为产生的埋点信息的rtpCnt,其格式与rtpCnt相同,所谓用户操作行为具体指用户的点击行为和页面的曝光行为。通过rtpRefer可以追溯到用户行为链路的任何位置。
针对不同的用户行为,我们定义了不同的埋点事件,大体分为两个事件:点击(click)和曝光(show),某些业务场景还需要曝光时间(stayTime)。
在不同的业务场景下,对于不同的功能它们又细分为多个元素:页面(page)、元素(element)、弹框(dialog)、列表元素(list)等。将元素与事件两两组合,就能确定一个具体埋点事件,比如pageShow代表页面曝光,listClick代表列表元素被点击等等。
除了页面,其他元素都具有主观语义,在不同需求下,一个元素可能既是element,又是list,因此页面之外的元素类型都应该由开发者根据具体需求去填写,而不是自动生成。
整套埋点方案的前端应该满足除必要业务参数外,不为开发者带来额外负担的需求,因此遵循Flutter组合大于继承的思想会带来益处,模拟Flutter本身的语法特性更便于使用者理解和上手,所以整套埋点方案的前端使用了一个widget来呈现,以下简称这个widget为TrackerPack。
在Flutter中,几乎所有的手势事件都会经过GestureRecognizer处理,点击、滑动、长按等手势事件都是对GestureRecognizer的封装,而我们平时开发最常接触的就是它的子类:GestureDetector。不仅仅是单纯的使用手势组件,我们经常使用的各种按钮其实都是对GestureDetector的封装。
因此,在TrackerPack中添加一个GestureDetector并在其手势回调处处理埋点事件和点击事件是个可行的方案。
在集成了GestureDetector后,TrackerPack可以当做一个手势组件或者一个按钮来使用,它支持添加child,对于使用者来说,它相当于只增加了业务入参的GestureDetector,因此在平时的业务开发中,在需要点击事件时,完全可以把TrackerPack当做GestureDetector来使用。
在此方案之前,所有的点击事件埋点都是通过在点击事件发生的回调处手动埋入,除了可能会出错外,还有可能会破坏埋点链路的时序性。比如在页面跳转的回调执行后才生成埋点数据,会导致下一页的曝光事件在点击事件之前发生,导致时序错乱。而在TrackerPack内部的点击回调会优先处理点击埋点相关任务,再去执行真正的点击回调,这样保证了点击事件的同步性与时序性,相比于手动在点击回调中添加埋点事件更可控和稳定。
因此,对于任何元素的点击埋点,我们都可以通过TrackerPack包裹该元素来实现,如果你没有实现点击回调,TrackerPack则不会添加GestureDetector,避免了资源浪费,如果你实现了点击回调,那它的使用成本和资源消耗将与GestureDetector几乎一样。
对于一个元素的曝光捕获,我们首先能想到的是根据一个元素的生命周期来实现,Flutter中的Widget根据是否有状态可分为Stateless和Stateful两种,而只有StatefulWidget的子类才具有比较丰富的生命周期,比如在项目早期,几乎所有的曝光埋点都是在initState方法中实现,而且这强制要求使用StatefulWidget,甚至有些埋点是在Widget初始化后直接上报一次曝光事件。不幸的是,这些方法都不能很好的满足需求,如果再次曝光时,Widget没有重新初始化,那么就不会再次上报曝光事件。而且不管是哪种方法,在Widget初始化时就会触发埋点上报,不管Widget是否真正的显示到了屏幕中。除此之外,如果页面刷新,写在初始化方法中的埋点会在页面刷新后重复上报。
使用Widget提供的API似乎行不通,那么我们就要向更深层追寻。我们知道,Widget本身并不真正参与绘制,它是作为Element的配置信息而存在的,而Element负责联系各组件并建立关系树,它负责管理与协调Widget与RenderObject,因此它也不具备曝光感知的功能。更深层的,我们就会发现RenderObject负责布局与绘制相关,似乎我们可以从绘制入手,我们可以通过重写paint方法来绘制图像,而在paint方法中,我们可以获得一个类型为PaintingContext的入参,它既可以获取canvas来绘制图像,也可以直接对Layer进行操作。而Layer似乎可以满足我们的需求。
什么是Layer?
我们日常开发中接触更多的是Widget、Element、RenderObject这三者,而Layer是由RenderObject构建,它负责描述图像信息,其实在我们熟知的Element Tree和RenderObject Tree之外,还有一个非常重要的树形结构,那就是Layer Tree。这三个树形结构是有联系的,他们的关系如下图:但是仅仅监听方法是不够的,由于每帧画面的呈现都对应了大量的Element Tree、RenderObject Tree变动,因此Layer Tree也会随之变动,如果祖先Layer执行attach,那么子Layer也会执行attach,detach也同理,如果一个祖先节点更换了Layer,那么子Layer们就会多次执行attach和detach。
判断未曝光的Layer是否达到了曝光条件;
判断已曝光的Layer是否达到了消失条件。
对于情况1,什么都不做,让Layer继续在队列中等待下次检查。
对于情况2,执行Layer的曝光回调,并把Layer标记为已曝光状态。
对于情况3,会把Layer移出曝光队列,在移出队列前会对Layer的状态进行检查,如果Layer被标记为已曝光,那么会执行它的消失回调,如果Layer为其他状态,则什么都不做。
整套机制大致如下图:
还有最后一个问题,我们如何获取组件的Layer并操作它的attach/detach?其实我们不需要获取,我们可以利用RenderProxyBox的将一个自定义Layer组合到组件的Layer中,然后监控这个自定义Layer即可,因为两者的周期完全是一样的。
至此,一套对于组件曝光和曝光时间监测的方案就完成了,它也被封装到了TrackerPack中,只要标记为需要曝光事件,那么整套机制将会自动实现。
在捕获到埋点事件后,底层会根据设置来生成本次埋点事件的RTP信息。
每条业务线标识需在埋点库初始化时传入,生成埋点信息时会去读取并自动拼接。
平台标识由埋点库自动生成,暂时只有iOS和Android对应的标识,埋点库会自动判断。
埋点库根据传入的context可以查询到页面信息。在页面初始化时,底层库会自动在页面外包裹一层InheritedWidget,并记录当前页面的类名,查询时可以使用context找到InheritedWidget,并获取到当前页面的类名,其时间复杂度为O(1)。同时也做了兜底方案,根据context向上遍历直到查询到页面节点,其时间复杂度为O(n)。如果context不合法,则会直接置为“0”。
但只知道页面类名还是不够的,因为pageShow和pageStayTime两个事件依赖原生实现,而在原生中生成Flutter容器埋点事件时,只能获取到当前容器的路由。原生与Flutter维护两套映射表或者原生主动去查询类名都是成本比较高的方法,而且后期很难维护。因此需要在Flutter中使用路由来作为页面的唯一标识,这样Flutter与原生就能统一。
Flutter中无法直接由类名查询其路由,类名与路由的唯一联系就是在路由跳转中,但是只有路由到类名的映射,是通过注解生成代码实现的,我们可以利用这套机制,只需要在代码注解生成中加入类名到路由的映射规则,就可以实现无需增加任何业务代码的前提下,完成路由与类名的互相映射。
最后通过查询到的路由到映射表中查询对应的哈希值作为C节点的数据。
需开发者根据具体业务需求来制定并传入,如果不传入则默认为“0”,传入的值会根据配置好的映射表来映射具体的哈希值最终作为D节点的数据。
由埋点库自动生成和拼接。
Flutter埋点库会使用一个静态变量来记录最后一次用户操作产生的埋点信息的rtpCnt来当做下一个埋点事件的rtpRefer。原生客户端也使用相似的方案,这在每端内部可以良好运行,但是一旦Flutter与原生客户端(或者相反)产生交互,那么rtpRefer将是错误的,因为各端自理的rtpRefer是不同的,假设在Flutter产生一次点击事件并跳转的到原生页面,原生页面产生的pageShow事件的rtpRefer应该是这次点击事件的rtpCnt,但由于信息未同步,本次pageShow的rtpRefer将是进入Flutter页面前的那次事件的rtpCnt。
为了解决同步问题,需要在Flutter与原生客户端互相跳转时携带当前埋点库记录的rtpCnt,如果rtpCnt合法则替换自身埋点库当前的rtpCnt。需要注意的是,RTP信息同步必须发生在跳转事件之前,因为如果当跳转事件产生后再去同步RTP信息,跳转的新页面的show事件的rtpRefer已经生成,且是个错误的数值。不过TrackerPack已经将这个时序正确封装,正确使用TrackerPack就无需担心RTP信息不同步的问题,这也是将手势埋点封装到TrackerPack中的一大优势。
最后就是埋点信息的上报,由于原生客户端已经有了一套成熟的埋点上报机制,而且埋点信息还需要一些如系统版本、设备名等基本信息,从原生客户端获取基本信息再在Flutter上报与将埋点信息发送给原生上报两种方案的资源消耗几乎相同,所以埋点的上报采用了将信息发送到原生并由原生来上报的方案。
每个埋点事件从发生到上报的流程大致如下:
随着整套埋点方案的逐步落地,最初面临的问题都得到了有效的解决,但随着日后业务规模扩大、种类增多,可能会诞生更多与当前规范不同的埋点需求,如何在能提供足够的自由度的前提下,保证规范性与准确性,仍是有待探究的课题。
实现一套完美的埋点方案还任重而道远,欢迎有疑问,有建议,有想法的同学积极交流,共同参与到基础建设中来。
牛年邀牛人
一起战斗、一起成长
技术、产品、UED、运营、职能等海量岗位
玩物得志期待你的加入