一
前言
目前,Flutter App(以下简称 App)的全量日志的模块埋点功能采用业务层手动埋点的方式实现,这种方式不仅增加了研发成本,同时也限制了后续的扩展和维护。因此,可以基于 Dart AOP 实现 Flutter 全埋点功能来补齐全量日志。该方式不依赖于业务层,可以在端上自动采集并上报数据,并通过一定规则筛选出所需数据,用于分析和模拟用户行为,帮助排查线上疑难问题。这种方法不仅能够提高我们的效率,而且能够加快问题的排查速度,从而提高 App 的稳定性。
二
实现原理
前端编译
frontend_server.dart 前端编译关键伪代码如下:
Future<bool> compile() {// 1.kernelForProgram(source)源码编译为AST树// 词法分析、语法分析、构建AST Outline summaryComponent = await kernelTarget.buildOutlines(...);// 构建完整AST树 component = await kernelTarget.buildComponent(...);// 2.运行优化transformer:TFA、Desugaring、Tree Shaking result = await runGlobalTransformations(component);// 3. 序列化为二进制await writeDillFile(result);}
Dart AOP
设计思路
通过对前端编译流程的简单梳理,我们已经知道要想实现编译期的 Dart 切面能力,需要在 Transfromer 优化之前注入 AOP 能力,因为 Transfromer 优化中会发生 Tree Shaking,如果在此之后才注入可能会因为没有用到而被树摇摇掉。设计流程如下:
注意:AOP 之前,B 方法调用 A 方法:B -> A。
支持的 Hook 方式有两种:
闲鱼有一套开源的面向 Dart 的 AOP 框架 AspectD,不直接使用它的原因如下:
方案描述可能比较抽象,可以参考以下 Demo 来加深理解。
分别使用 @Call 和 @Execute 注解对 hello() 方法执行切面操作:
打印日志信息:
伪代码如下:
技术难点
在经过 AOP 之后,B 方法调用 A 方法时会经过一层代理,也就是我们的 Hook 方法,然后才会调用到 A 方法,这个过程中就存在了对原方法参数的传递。
为了能够把参数传递给原方法,在调用点进行替换时,会构造一个 PointCut 对象,将位置参数放入到 PointCut 对象的 List 属性中,将命名参数放入到 PointCut 对象的 Map 属性中,然后将 PointCut 对象作为参数传递给 Hook 方法。在替换方法调用时,还会为 PointCut 生成一个 Stub 桩方法,而这个 Stub 方法则是调用原来的 A 方法,即通过 A 方法参数列表定义,在 Stub 方法中分别取出 PointCut 对象的 List 属性和 Map 属性中存储的实参,来拼接成 A 方法调用所需的 Arguments,然后在 Stub 方法中生成 A 方法调用的 Invocation。
所以,最终方法调用的实参都会存储到 PointCut 对象的 List 属性与 Map 属性中,然后在 Stub 方法中取出并回调原方法。这种方式本身没有问题,但是当参数是可选参数时就会出现问题。假如 A 方法中的参数 a 是可选参数,默认值是 "hello world",B 方法在调用 A 方法时并没有为可选参数 a 传值,理论上可选参数 a 的值是默认值 "hello world",但是 Stub 方法生成 Invocation 时,是通过 A 方法的参数列表定义去拼接参数的,这里会存在一定变数。
由于 B 方法没有传入可选参数 a,当 PointCut 对象构造时,Map 属性中并没有存入可选参数 a,所以,Stub 方法在拼接参数时,从 Map 属性中获取的可选参数 a 的值将是 null,这个 null 值是作为 Arguments 中的一员,这样最终的 A 方法调用将会使用 null 值,而不是默认值 "hello world"。
为了解决这个问题,需要在 Stub 方法中生成 A 方法调用所需的 Arguments 时,对 PointCut 对象的 Map 属性中的参数进行判断。通过 A 方法参数列表定义从 Map 属性中提取实参时,先判断对应参数是否为可选参数,如果是可选参数,通过 Map 的 containsKey() 方法来判断 Map 属性中是否存在该可选参数。假如这个参数是可选参数,而且 Map 属性中也不存在该参数,那么我们接下来该怎么办呢?其实,我们在遍历 A 方法的参数列表定义时,可以获取到对应参数的变量声明,通过这个变量声明可以获取到对应初始值的表达式。假如 Map 属性中不包含对应的可选参数,我们可以使用对应可选参数的初始值表达式拼接到 Arguments 中,这样就保证了 Arguments 是固定的,也保证了可选参数在没有传值的情况下依旧可以使用到默认值。
总结:判断 Map 属性中是否存在可选参数时,我们需要先构造出 Map 对象的 containsKey() 的 Invocation,然后再构建条件表达式(ConditionalExpression),将 containsKey() 的 Invocation 作为条件值,条件表达式两个分支分别放入 Map 取值的表达式与可选参数初始值的表达式。
方法调用替换时,不同调用方执行同一个原方法的调用替换时,都会生成一个 Stub 方法,以便 pointCut.proceed() 能够通过 Stub 方法来回调原方法。
假如,一个方法有 N 个调用点,那么我们就要为每个调用点都生成一个 Stub 方法,这显然不合理,因为都是对同一个方法的调用,且方法调用所需的 Arguments 都是通过 PointCut 对象的 List 属性与 Map 属性中取出来拼接的,所以众多的方法调用其实都可以复用一个 Stub 方法来完成原方法的回调。
三
全埋点
用户操作路径
路径追踪
关键字段的拼接规则如下:
源码分析
BuildContext 定义了一些如获取 State、Widget、RenderObject、父子 Element 等重要的接口;Element 实现了 BuildContext 中的关键方法,比如实现了 visitAncestorElements (访问祖先元素)方法等,且通过 Element.Widget 获取与之对应的 Widget,根据此 Widget 可获取到具体路径;RenderObjectElement 继承 Element,在 mount() 方法中初始化 _renderObject 对象;在 mount() 和 update() 方法中,通过断言将当前 Element 传入到 renderObject 的 debugCreator 属性中保存。因此,可以通过 debugCreator 属性获取到对应的 Element,再通过 Element 获取到对应的 Widget。由于 debugCreator 属性赋值定义在断言中,只在Debug 模式时能获取到 Widget,因此需要分别 Hook mount() 和 update() 方法来支持 Release 和 Profile 模式时获取对应 Widget 信息的能力。
关键实现
Widget_Inspctor 在 Debug 模式的编译期间,通过一个特定的 Transform,让最底层 Widget 实现了抽象类 xxHasCreationLocation,在 Widget 所有子类的构造方法中新增一个 xxLocation 类型的命名参数,同时会修改对应的构造方法调用点即传入 xxLocation 对象,最终可通过 Widget 对象获取到 Widget 构造时所在文件路径和代码行数。基于此,可以在非 Debug 模式复用此逻辑(为了保留 Debug 模式时本身支持的 Dev-Tools 能力,Debug 模式不做修改)
修改源码
track_widget_constructor_locations.dart
当前 Element 是否添加到 Path 中,用于去除中间无效冗余的组件路径:
事件与手势
理解手势
PointerEvent(指针事件)表示用户交互的原始触摸数据,例如 PointerDownEvent、PointerCancelEvent、PointerUpEvent 等;当手指触摸屏幕的时候,发生触摸事件,Flutter 会确定触发的位置上有哪些组件,并将触摸事件交给最内层的组件去响应,事件会从最内层的组件开始,沿着组件树向根节点向上一级级冒泡分发。
处理 PointerEvent 是从 GestureBinding 的 handlePointerEvent() 方法开始:
dispatchEvent() 方法遍历 _path 中的每个 HitTestEntry,取出其 target 进行事件分发,而 HitTestTarget 除了几个Binding,其具体都是由 RenderObject 实现的,所以也就是对每个 RenderObject 节点进行事件分发,也就是我们说的“事件冒泡”,冒泡的第一个节点是最小 child 节点(最内部的组件),最后一个是 GestureBinding。
所以,handlePointerEvent() 方法主要就是不断通过 hitTest() 方法计算出所需的 HitTestResult,然后再通过 dispatchEvent() 对事件进行分发。
关键实现
通过分析手势事件,选择以下两个切入点:
业务信息
设计思路
以下叙述均以新版 Bloc 为例。
在 App 中,存在多种设计模式。以新版 Bloc 为例,与业务相关的信息保存在一个 State 类中。我们可以通过获取当前 State 对象中的所有信息来还原模拟用户操作。然而,Flutter 缺少动态能力,无法通过反射机制动态获取 State 对象的所有信息。因此,我们可以为每个 State 对象生成 toString() 方法,以获取对象中的所有信息(方法返回的是 Map 对象转成的字符串)。然而,手动编写大量的 toString() 代码不仅侵入了业务层代码,而且效率极低。为了解决这些问题,我们可以尝试在编译期提前生成 State 对象的 toString() 方法,以更高效地获取业务流程信息。当 Hook 方法被调用时,我们可以通过调用 toString() 方法获取到 State 对象所有信息并上报。
如何判断当前的类是否为需要的 State 类呢?
如何生成 toStringProcedure 呢?
注意:需要存在一个 toStringProcedure 模版,不会凭空创建。
关键实现
最终效果
四
其他收益
虽然可以 Hook 系统方法来处理问题或配置自定义内容,但也需要选择合理的合适的时机去触发,不可以过度使用。
五
总结
使用 Dart AOP 实现的 Flutter App 全埋点功能具有多重优势。首先,它不依赖于业务层,可以在端上自动采集并上报数据,从而不会对业务代码造成额外的负担。其次,通过 AOP 的方式,我们可以在代码中简单地插入埋点逻辑,而不需要修改原有代码,从而大大缩短了开发时间。此外,基于 AOP 的实现方式还能够方便后期的维护工作,当需要新增或修改埋点逻辑时,只需修改 AOP 配置即可,而不需要对业务代码进行大规模的修改。因此,基于 Dart AOP 实现的 Flutter App 全埋点功能不仅能够提升开发效率,还能够方便后期的维护工作,为项目的稳定性和可维护性提供了有力支持,希望以后可以通过 AOP 技术解决更多难题。
作者:巴里小短腿
来源:微信公众号:得物技术
出处
:https://mp.weixin.qq.com/s/Hyb_iOhhmbCZOPxdZ0BsQw