Flutter 全埋点的实现

一、前言

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

    设计思路

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

    图片图片

  • Dart 编译成 Kernel 前注入自定义 AopTransformer,通过 AopTransformer 提取自定义注解信息,遍历 AST 节点,对注解中声明的节点进行修改;
  • 编译 host_release,生成新的 frontend_server.dart.snapshot 来替换 App 对应 SDK 的原前端编译器快照;
  • 针对原方法新建一个带有切面注解信息的 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 方法来完成原方法的回调。

    图片图片

    三、全埋点

    用户操作路径