# flutter无痕埋点 **Repository Path**: tonyistudio/flutter_no_trace_buried_point ## Basic Information - **Project Name**: flutter无痕埋点 - **Description**: 依赖于树遍历实现的Flutter无痕埋点项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 3 - **Created**: 2022-12-21 - **Last Updated**: 2022-12-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README

Flutter无痕埋点技术方案

## 整体架构 ## ## Flutter树结构 ### Widget树 ​ 包含各种配置信息,是平时业务开发直接接触到的树。 ### Element树 ​ 中间树,管理Widget树和RenderObject树,将Widget生成RenderObject并进行一些更新操作。 ### RenderObject树 ​ 渲染树,负责计算布局,绘制方面。Flutter引擎是根据这棵树进行渲染的 这里有一点需要注意的,RenderObject虽然是Widget创建的,但是实际上是在element(RenderObjectElement)中调用widget.createRenderObject方法,并将返回值赋值给renderObject。源码如下: ```dart @override void mount(Element parent, dynamic newSlot) { super.mount(parent, newSlot); _renderObject = widget.createRenderObject(this); // assert(() { _debugUpdateRenderObjectOwner(); return true; }()); assert(_slot == newSlot); attachRenderObject(newSlot); _dirty = false; } ``` 最终生成如下图对应关系: 1.Widget树与Element树一一对应。 2.第三个图中的RenderView和RenderBox都是继承自RenderObject,遍历定位的时候,用的是RenderBox。 ```dart class RenderView extends RenderObject with RenderObjectWithChildMixin {} abstract class RenderBox extends RenderObject {} ``` 3.虽然RenderObject树缺失了一个节点,但是当获取Element的renderObject属性的时候,如果当前节点不是RenderObjectElement,那么则会向下遍历,直到遍历到RenderObjectElement。实际上相当于缺失的节点使用了子节点的RenderBox。代码如下: ```dart RenderObject get renderObject { RenderObject result; void visit(Element element) { assert(result == null); // this verifies that there's only one child if (element is RenderObjectElement) result = element.renderObject; else element.visitChildren(visit); } visit(this); return result; } ``` ## 手势相关 ### 命中测试 ​ behavior有以下三个值。 > **HitTestBehavior.deferToChild**:`Listener`是否命中测试,取决于子`child`是否命中测试,这是默认`behavior`的默认值。 > > **HitTestBehavior.opaque**:当`Listener`的子`child`没有命中测试时,该属性值保证`hitTestSelf`返回`true`,即保证`Listener`所在区域能响应触摸事件。 > > **HitTestBehavior.translucent**:当`Listener`的子`child`没有命中测试时,并且`hitTestSelf`返回`false`时,该属性值可以保证`Listener`所在的区域能响应触摸事件(加入到命中测试列表),但是`hitTest`方法返回值还是`false`,这不能改变。 ​ Flutter中会有一个命中测试,对应的方法有hitTest,hitTestSelf, hitTestChildren这三个,hitTest内部调用了另外两个命中方法。因为我们判断的是Listener,且该Listener我们是不会进行操作的,使用的参数值是默认值,查看源码可以发现,Listener构造方法中的behavior默认值是HitTestBehavior.deferToChild。很显然这个值会导致命中测试结果是依据于children的,不符合我们的需求。 ```dart class Listener extends StatelessWidget { /// The [behavior] argument defaults to [HitTestBehavior.deferToChild]. const Listener({ Key key, ... this.behavior = HitTestBehavior.deferToChild, //默认值 Widget child, }) : assert(behavior != null), _child = child, super(key: key); } ```
上图所示如果红色点为命中点,此时我们需要获取到的是外层点击区域的Listener,但是由于behavior的默认值是HitTestBehavior.deferToChild,导致hitTest结果为false,代表导致外层点击区域未被命中。不符合我们的需求。
### 自定义命中方式 **一次完整手势定义:**一个DOWN,大于等于0个MOVE,一个UP。 由于系统提供的命中方法无法适配需求,因此自己定义命中方式如下: 同一事件序列中DOWN点和UP点均在Rect内部,关键代码如下: ```dart RenderBox box = element.renderObject; Offset origin = box.localToGlobal(Offset.zero); Rect rect = Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height); // 点击位在手势部件范围内 var upGlobalPosition = tapUpDetails.globalPosition; var downGlobalPosition = tapUpDetails.globalPosition; if (rect.contains(upGlobalPosition) && rect.contains(downGlobalPosition)) {} ``` ### 手势 #### 开启手势 - **acceptGesture** - **rejectGesture** Flutter中的手势拦截类似于一个竞技场,当有一个手势在竞争中胜出的时候,会执行它的**acceptGesture**方法,而其余失败的手势,就会执行**rejectGesture**方法。如果需要自定义一个能够响应手势的部件,则重写**rejectGesture**方法,在内部调用**acceptGesture**方法,打开手势即可。 #### 监听Listener Listener是FLutter中所有手势的祖先,它能够监听原始指针事件。 > 手势冲突只是手势级别的,而手势是对原始指针的语义化的识别,所以在遇到复杂的冲突场景时,都可以通过`Listener`直接识别原始指针事件来解决冲突。 我们这里不需要解决冲突,但也能直接拿Listener来监听页面内的所有点击事件,并对其进行处理。 具体使用方式如下: ```dart @override Widget build(BuildContext context) { return Listener( onPointerDown: (details) {}, child: MaterialApp() ); } ``` ## PV埋点实现 ### 1.一个容器对应一个Flutter页面 这种情况下,一般直接基于原生生命周期的无痕埋点即可进行PV埋点。 ### 2.一个容器对应多个Flutter页面 这种情况下因为只有一个容器,无法知道Flutter端页面路由栈,所以无法再复用原生埋点方案。 #### 2.1Flutter端的路由监测方式 ```dart class RouterTrackObserver extends NavigatorObserver { final Function(String) pvEventTrancking; final List routeList = List(); RouterTrackObserver({this.pvEventTrancking}); @override void didPush(Route route, Route previousRoute) { if (routeList.length != 0) { pvEventTrancking("${routeList.last} onStop"); } pvEventTrancking("${route.settings.name} onResume"); routeList.add(route.settings.name); } @override void didPop(Route route, Route previousRoute) { if (routeList.length != 0) { pvEventTrancking("${routeList.last} onStop"); } pvEventTrancking("${route.settings.name} onResume"); routeList.removeLast(); } } ```
存在的问题:所有路由必须注册在路由表中,不然获取到的name为null
检测到页面的resume,stop生命周期。 > 在该方案中,使用OverlayEntries类的opaque属性,dialog和bottomsheet之类的不是全屏的布局给剔除了,防止PV埋点有杂数据的介入。 **2.2树遍历方式** 通过在App外面包一层StatefulWidget方式,添加页面刷新监测 ```dart WidgetsBinding.instance.addPostFrameCallback((ts) {} // 该方法只会在flutter页面首次打开的时候执行一次 ``` ````dart WidgetsBinding.instance.addPersistentFrameCallback((ts) {} // 该方法会在页面进行刷新的时候被调用,但是会被调用多次 ```` 基于埋点需求,选择addPersistentFrameCallback方式进行监听。 **PV埋点流程梳理** - 将一层自定义的注册addPersistentFrameCallback回调的StatefulWidget包在App外层。 - 因为addPersistentFrameCallback会在页面刷新操作的时候,不断跳用且是多次的调用,所以这边需要做好去重处理。 - 配合2.1节路由监听方式,维护一个自己的路由表。push则去遍历树,pop则直接操作自定义的路由表即可。 - 遍历到哪一节点:这边选择的是遍历到Scaffold,因为再之后的节点取还是不取,对于PV唯一值没有影响,遍历到Scaffold,对于性能也能有更好的提升。 - 区分当前是push还是pop操作,进行onStop和onResume埋点。 ## PV - FAQ - 在push和pop操作的时候,初始化了context,打印日志发现树节点组成的字符串是一样的,但是考虑到日志只是打印出一部分,所有进行了md5加密,发现在每次addPersistentFrameCallback回调的时候,获取到的同一个页面的唯一值都是不同的,考虑到树在不断重构,所以这边需要做好唯一值的确认。 **这边采用两种方式来确定这个唯一值。** 1.通过以下判断来对路由表中最后一个值进行去重处理。 ```dart if (pathList.length == 0 || pathList.last != routePagePath) {} ``` 2.观察日志发现,从祖先节点到Scaffold的树节点即可确定唯一值且效率更高,因此可直接过滤掉因树重建导致的多次不同值。 - A1 A2 ## UV埋点实现 #### 向上遍历确定页面 ​ 标准Flutter布局有一个Scaffold部件,当遍历到Scaffold后,再往上遍历一层或者两层,具体情况,看Scaffold外是否还有一层CupertinoApp,如果是,则遍历两层;不是,则遍历一层,即可根据widget.runtimeType拿到自定义的className。 #### 向下遍历确定唯一值 ​ 向下遍历至最低层child,一般为Text或者Image,获取text.data或者Image.image属性,拼接到path尾部。 ## UV - FAQ - **1.列表Item问题** **问题:**列表中的所有Item的布局层级是一模一样的 **解决办法:** - 向下遍历子节点的时候,遍历到最底层。而一般在Flutter中为Text或者Image,这样的话,我们可以取Text的data属性和Image的image属性值,一般而言在列表中的item的数据是不一样的。 - 当找到最终element后,回溯往上,直到回到列表层级的时候,记录下该item在列表中的下标。 - **2.列表两两嵌套问题,或者更多层的嵌套** 当出现四个列表两两嵌套,这时候,需要再增加一层由命中节点往祖先节点的回溯,记录下当前item在每一个列表中的下标位置。 - **3.普通层叠手势问题** ​ 当出现手势层叠如下图: ​ **问题:**由于两个手势是层叠的,导致最上层的祖先节点到最下层的孙子节点的路径是一样的 ​ **解决办法:**路径中记录当前节点的父节点层级数和字节点层级数量,由于两个手势是层叠的,所以,祖先节点数量肯定是不一样的,内层要比外层大。 - **4.列表与AppBar重叠问题** ​ **问题:**当列表滑动到AppBar下面的时候,点击AppBar,按照之前的遍历逻辑,会导致最终获取到的是列表的element而不是AppBar内部的element,造成Listener命中错误 ​ **解决办法:**标题栏额外处理,判定标题栏内child被命中后,不再遍历body内child。 Scaffold. appbar list - Stack. List. Appbar ![Android团队-Flutter无痕埋点技术方案分享-徐健_问卷二维码](/Users/xujian/Library/Containers/com.tencent.WeWorkMac/Data/Library/Application Support/WXWork/Data/1688852955524986/Cache/Image/2020-07/Android团队-Flutter无痕埋点技术方案分享-徐健_问卷二维码.png)
大家可以扫码评论哦
**参考文档:** https://juejin.im/post/5efe9c5a5188252e3d41d201 (盲僧) https://juejin.im/post/5efe9c5a5188252e3d41d201#heading-2 https://www.jianshu.com/p/dd6554e1e3e3 https://www.yuque.com/xytech/flutter/vlw39o (闲鱼) https://www.jianshu.com/p/ebe3dfd1842c