实现 Flutter 全埋点技术

发表时间: 2024-01-16 15:01

前言

目前,Flutter App(以下简称 App)的全量日志的模块埋点功能采用业务层手动埋点的方式实现,这种方式不仅增加了研发成本,同时也限制了后续的扩展和维护。因此,可以基于 Dart AOP 实现 Flutter 全埋点功能来补齐全量日志。该方式不依赖于业务层,可以在端上自动采集并上报数据,并通过一定规则筛选出所需数据,用于分析和模拟用户行为,帮助排查线上疑难问题。这种方法不仅能够提高我们的效率,而且能够加快问题的排查速度,从而提高 App 的稳定性。

实现原理

随着 App 的不断迭代,项目复杂度也不断提升。在该过程中,为了准确找出问题并排查,我们需要使用一些技术手段来辅助。在 Flutter 方面,Hook 能力是 App 缺少的基础能力之一。因此,实现一套通用的 Dart AOP 基础工具变得尤为重要。我们可以在关键的代码调用点注入自定义逻辑,以实现数据收集、性能监控等功能,这种切面编程的技术被称为 AOP(Aspect-Oriented Programming),它可以帮助我们更好地管理和组织代码,提高代码的可维护性和复用性。

前端编译

要想实现 Flutter 侧 Hook 能力,首先要简单了解一下前端编译。

  • CFE(Common Front-End):通用前端编译器,当执行 Dart 代码时,通过词法分析(Scanner)和语法分析(parser)构建一颗 AST(Component)树,再经过一系列的 Transformer 优化(TFA、Desugaring、Tree Shaking)后,将优化后的 AST 树二进制写入到 Dill 文件中;
  • TFA(Type Flow Analysis):全局类型流分析和相关转换,比如简化参数传递等;
  • Desugaring:语法脱糖,比如将 Async/Await 转换成基于 Future 实现;
  • Tree Shaking:树摇,从 Kernel 产物中摘除未使用的 Classes、Procedures、Fields等;
  • AST (Abstract Syntax Tree):抽象语法树,是一种用于表示源代码结构的树形结构,每个节点代表一个语法单元,例如表达式、函数、变量等。它在编译器和解释器中扮演着非常重要的角色,是代码优化、代码转换和运行的基础。通过构建 AST,我们可以对代码的结构和语义进行全面的分析和处理,同时也为开发人员提供了一种理解代码表达方式和程序执行方式的框架,简单看下 Component 结构。Dart 2.18.6 AST 源码点这里。

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);}
  1. 执行 Dart 代码时,先进行词法分析和语法分析来构建 AST Outline,接着第二次会构建完整 AST;
  2. 运行语法糖脱糖、Tree-shaking 和 TFA 等来进行优化;
  3. 将优化后的 AST 二进制写入 Dill 文件中。

Dart AOP


设计思路

通过对前端编译流程的简单梳理,我们已经知道要想实现编译期的 Dart 切面能力,需要在 Transfromer 优化之前注入 AOP 能力,因为 Transfromer 优化中会发生 Tree Shaking,如果在此之后才注入可能会因为没有用到而被树摇摇掉。设计流程如下:

  1. Dart 编译成 Kernel 前注入自定义 AopTransformer,通过 AopTransformer 提取自定义注解信息,遍历 AST 节点,对注解中声明的节点进行修改;
  2. 编译 host_release,生成新的 frontend_server.dart.snapshot 来替换 App 对应 SDK 的原前端编译器快照;
  3. 针对原方法新建一个带有切面注解信息的 Hook 方法,当程序执行到原方法时,其实执行的是对应的桩方法。

注意:AOP 之前,B 方法调用 A 方法:B -> A。

支持的 Hook 方式有两种:

闲鱼有一套开源的面向 Dart 的 AOP 框架 AspectD,不直接使用它的原因如下:

  • AspectD 支持的 SDK 版本过低且对外不再维护,当 Flutter SDK 升级到 3.3.10 后,AST 中的部分 API 发生了较大变更,其中代码生成相关逻辑需要进行较大的调整来适配新 API,无法直接使用;
  • AspectD 没有支持空安全(Null Safety)这个很重要的语法特性;
  • 缺少调用方的作用域能力:实际开发中可能存在这样一种场景,插件 A 和 插件 B 都有打印功能,只想 Hook 插件 B 的打印的话,目前缺少这个能力;
  • 方法调用替换会生成重复的桩方法:不同的调用方执行同一个原始方法的调用替换(Call)时,生成了多个重复的桩方法,应只保留一个桩方法即可;
  • AspectD 使用 Flutter_tools 调用工具链较为繁琐,可以直接编译并替换前端编译器快照,化繁为简。

方案描述可能比较抽象,可以参考以下 Demo 来加深理解。

分别使用 @Call 和 @Execute 注解对 hello() 方法执行切面操作:

打印日志信息:

伪代码如下:

技术难点

调用方的作用域能

App 中,插件 A 和插件 B 里都有打印功能,但若只想对插件 B 的打印进行 hook,那就必须可精细化的控制 hook 范围。根据上面的原理分析,@Execute 修改了原方法,插桩后只有一个变更点,保证了所有方法都能被 hook 到,所以无法支持调用方的作用域能力,无法精准控制 hook 范围;而 @Call 不会修改原方法,只是替换了方法调用点,即将原方法调用替换为 hook 方法调用,所以插桩 N 次就会生成 N 个变更点。因此,在方法调用替换前首先判断当前 class 的 uri,通过正则匹配定义的 scope,如果满足,才可以进行插桩。

可选参数的默认值

在经过 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 方法来完成原方法的回调。

全埋点

用户操作路径

当用户触发点击事件时,我们可以通过命中点击的最小 Widget 来回溯出该 Widget 在树中的层次结构;通过获取到的层次结构,我们可以去除中间无效和冗余的组件路径,并按照一定的拼接规则来获取用户的操作路径。简言之,当用户点击某个 Widget 时,我们可以追踪到它在 Widget 树中的位置,并根据这个位置信息剔除无效和重复的组件路径,从而得到有效的用户操作路径。这种操作路径的获取方法可以帮助我们了解用户在 App 中的具体操作流程,从而更好地理解和分析用户行为,更准确更及时的定位问题。

路径追踪

关键字段的拼接规则如下:

  • 用户操作路径:控件类:Dart文件名:行数:列数;
  • 组件路径 ID (从根节点到子节点):Widget 名字[位置]/ ... / Widget 名字[位置]。

源码分析

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 信息的能力。

关键实现

  • Release 和 Profile 模式创建 DebugCreator

  • 组件路径优化

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() 方法开始:

  1. 创建 HitTestResult 对象:PointerEvent 为 PointerDownEvent、PointerSignalEvent、PointerHoverEvent、PointerPanZoomStartEvent 时创建 HitTestResult 对象,该对象内部有一个 _path 字段,表示 HitTestEntry 集合。
  2. 命中测试,调用 RendererBinding 的 hitTest() 方法:调用 hitTest() 方法进行命中测试,该方法将自身作为参数创建 HitTestEntry 对象,然后将 HitTestEntry 对象添加到 HitTestResult 的 _path 中,HitTestEntry 中只有 HitTestTarget 属性字段。即创建的 HitTestEntry 添加到 HitTestResult 的 _path 中,被当做事件分发冒泡排序中的一个路径节点。

    1. 调用 RenderView 的 hitTest() 方法(从根节点 RenderView 开始命中测试);
    2. 调用父类的 hitTest() 方法,即 GestureBinding 的 hitTest() 方法。
  1. 事件分发:经过一系列的 hitTest 后,调用到 GestureBinding 的 dispatchEvent() 方法。

dispatchEvent() 方法遍历 _path 中的每个 HitTestEntry,取出其 target 进行事件分发,而 HitTestTarget 除了几个Binding,其具体都是由 RenderObject 实现的,所以也就是对每个 RenderObject 节点进行事件分发,也就是我们说的“事件冒泡”,冒泡的第一个节点是最小 child 节点(最内部的组件),最后一个是 GestureBinding。

所以,handlePointerEvent() 方法主要就是不断通过 hitTest() 方法计算出所需的 HitTestResult,然后再通过 dispatchEvent() 对事件进行分发。

关键实现

通过分析手势事件,选择以下两个切入点:

  • 获取到点击的控件:通过拦截 GestureBinding 的 dispatchEvent() 方法,获取到传给该方法的 PointerEvent 和 HitTestResult 参数;
  • 拦截点击事件:拦截 GestureRecognizer 中的 invokeCallback() 方法,可以通过传递的参数,得到是不是点击状态(判断 eventName == "onTap")。

业务信息

即使我们获取了用户的操作路径信息,如果缺少关键业务代码,也无法快速排查问题。因此,在全埋点中,我们需要上报与业务流程相关的日志。为了避免对业务层代码的侵入,我们可以通过 Hook 来获取业务内容,并将其上传到全量日志。那么,如何获取业务信息呢?

设计思路

以下叙述均以新版 Bloc 为例。

在 App 中,存在多种设计模式。以新版 Bloc 为例,与业务相关的信息保存在一个 State 类中。我们可以通过获取当前 State 对象中的所有信息来还原模拟用户操作。然而,Flutter 缺少动态能力,无法通过反射机制动态获取 State 对象的所有信息。因此,我们可以为每个 State 对象生成 toString() 方法,以获取对象中的所有信息(方法返回的是 Map 对象转成的字符串)。然而,手动编写大量的 toString() 代码不仅侵入了业务层代码,而且效率极低。为了解决这些问题,我们可以尝试在编译期提前生成 State 对象的 toString() 方法,以更高效地获取业务流程信息。当 Hook 方法被调用时,我们可以通过调用 toString() 方法获取到 State 对象所有信息并上报。

如何判断当前的类是否为需要的 State 类呢?

  1. 自定义 CreateToStringMethodVisitor 继承 Transformer,重写访问实例调用(visitInstanceInvocation)方法;
  2. 遍历 AST,获取当前实例调用 methodInvocation 的接口目标引用(interfaceTargetReference)的节点 node;
  3. 判断该节点如果为 Procedure,获取到它的 Class 和 Library,从而获得 importUri、clsName、methodName;
  4. 由于 State 没有明显的继承关系,无法直接判断出一个类是否为 State,所以从 Emit 方法调用点出发,通过 Emit 方法调用点传入的参数来获取 State 对应的类,这么可分别对比 ImportUri、clsName、methodName 和新版 Bloc 的 Emit() 方法所在的类、Import 名字 和 Call() 方法所在的类、Import 名字,完全匹配则说明找到了 State 类的实力调用遍历实例调用的位置参数列表中的表达式,根据表达式不同的类型获取到对应的 state 的 Class;
  5. 遍历 stateClass 的 Procedures,如果没有 toStringProcedure,为当前 StateClass 生成 toStringProcedure 并插入到 Procedures 中。

如何生成 toStringProcedure 呢?

  1. 初始化一个空数组,里面存放的是映射文字条目(MapLiteralEntry)。
  2. 遍历 StateClass 的 Fields,根据当前 Field 生成一个 Key 为 Field 名字,Value 为 Field 表达式的 MapLiteralEntry,添加到 MapLiteralEntry 数组中。
  3. 如果 stateClass 有父类,需要循环向上找到 Field 并生成对应的 MapLiteralEntry 添加到数组中。
  4. 数组 MapLiteralEntry 转成 MapLiteral,创建 toStringMap实例调用 并包装成带有返回值的描述 Statement,通过这个描述 创建 FunctionNode,通过 FunctionNode 创建 toStringProcedure,添加到 StateClass 的 Procedures 中。

注意:需要存在一个 toStringProcedure 模版,不会凭空创建。

关键实现

  1. 通过对象和属性定义获取对象属性,即 StateClass 属性保存的 Field 对象。
  2. 如果当前 Field 对象是数组的话,打印出来的会是 Instance of xxxModel,我们需要获取 xxxModel 内部信息,所以需要对 xxxModel 进行 toJson()。
  3. 根据当前 Field 生成一个 Key 为 Field 名字,Value 为 Field 表达式的 MapLiteralEntry,添加到 MapLiteralEntry 数组中。
  4. 如果属性定义对象为空,那么选择以上生成的实例方法调用,否则使用 Field 对象即可。

最终效果

其他收益

Dart AOP 用途有很多,也可以解决疑难 Crash。比如前段时间,有一个线上疑难 Crash 问题持续影响了多个版本。Bugly 出现堆栈信息为 Null check operator used on a null value 的异常问题,最终定位的原因是 3.3.10 SDK 源码里,TextSelectionOverlay 类通过持有的 Context 对象寻找 RenderObject 时,返回了Nil 值,在对其进行强制解包时触发了异常。因此,小组成员选择 Hook 系统 SelectionOverlay._buildToolbar() 方法,在其内部判断对应 Context 是否已经 unmount,如果是则直接返回一个 Container。这么修改上线后问题已解决。

虽然可以 Hook 系统方法来处理问题或配置自定义内容,但也需要选择合理的合适的时机去触发,不可以过度使用。

总结

使用 Dart AOP 实现的 Flutter App 全埋点功能具有多重优势。首先,它不依赖于业务层,可以在端上自动采集并上报数据,从而不会对业务代码造成额外的负担。其次,通过 AOP 的方式,我们可以在代码中简单地插入埋点逻辑,而不需要修改原有代码,从而大大缩短了开发时间。此外,基于 AOP 的实现方式还能够方便后期的维护工作,当需要新增或修改埋点逻辑时,只需修改 AOP 配置即可,而不需要对业务代码进行大规模的修改。因此,基于 Dart AOP 实现的 Flutter App 全埋点功能不仅能够提升开发效率,还能够方便后期的维护工作,为项目的稳定性和可维护性提供了有力支持,希望以后可以通过 AOP 技术解决更多难题。

参考文献:https://juejin.cn/post/6892371163859976199


作者:巴里小短腿

来源:微信公众号:得物技术

出处
:https://mp.weixin.qq.com/s/Hyb_iOhhmbCZOPxdZ0BsQw