Flutter Ast语法树操纵与Aop集成

2,755 阅读5分钟

一、前言

Aop(切面编程)是终端开发常用的一种能力。在iOS侧,Objective C运行时和动态特性支撑了简单易用的Aop实现方式;在Android侧,也有AspectJ和Asm这样基于字节码修改的插桩框架。

但与之相对的跨端Flutter,却缺少这样的Aop能力支持。 官方推荐的SourceGen代码生成库也不足以满足开发的实际需要,(依赖注解、有侵入性、不能覆盖三方库、非自动生成,需要有调用切入点)。

Flutter本身不能够支持类似的Aop框架吗?不,恰恰相反,我们能看到Flutter的编译流程是分步骤的,在得到最终产物前会编译出包含整个项目信息的Ast语法树对象,也能在DartSdk frontend_server里找到通过语法树Transform插桩代码的影子。

1.png

--
但矛盾点在于:官方并没有开放这种支持,网络上关于语法树的索引也甚少。

本篇文章会梳理编译时Flutter语法树的使用与集成的知识脉络,让项目可以无痛接入Aop能力。文章分为两部分:第一部分介绍语法树操纵的方式,建立Ast树的感性认知;第二部分讲Aop的集成方式。

由于细节较多,文章中会引用官方FlutterSdk和DartSdk的仓库文件,也准备了一个最小工程的Demo

二、语法树操纵可以做到的事情

让我们暂且放下陌生的概念,先来看下Ast语法树。 如前言中的图,它是生成app.dill和最终产物前的中间对象,也是Flutter Aop所直接操纵的对象。

Flutter在编译时,会生成位于工程目录/.dart_tool/flutter_build/xxx/app.dill的dill中间文件。该文件的生成依据就是Ast语法树,而Ast语法树则是由lib/main.dart为入口从代码解析而来。

Ast是一种树结构,根节点是Component。

2.png

图中画出了较重要的语法树节点,绝大部分的节点定义可以查看ast.dart。该文件定义的节点极多,仅代码就有1.5w行,推荐作为字典来使用。需要强调的是:

1. 语法树包含代码的全部信息

这意味着可以在切面层次上为所欲为,包括类、方法、变量与注解,都可以进行操纵。Ast树可以看做Dart代码的另一种表现形式,可以操作的插桩技巧就是无限制的。

因语法树是app包的前置产物,所以它的信息是完整的。为了得到Ast树对象进行操纵,这里梳理DartSdk的编译路径,如下:

源码关键路径: Ast语法树编译的起点在于DartSdk 中的frontend_server,解析的关键节点在于frontend_end的generateKernelInternal (...)中。该方法以工程入口(一般是lib/main.dart)为起点,把所有import文件和基础依赖一并打入(完全没有引用的文件不参与编译)。

后调用kernelTarget.buildComponent()进行解析编译,在buildComponent结尾调用runBuildTransformations()方法,最后走到FlutterTarget进行Transform插桩,这也是我们获取根节点Component的位置

2. 语法树是符合直觉的

看源码可能是必经之路,但好消息是对语法树的处理并不需要对所有节点都字字清晰,它整体是符合直觉的。实际的插桩方式只需要一步一步调试,并不困难。

一个简单的例子,如下是一段代码示例,和大概的自然语言描述。

// 代码示例
class Helper {
  String printType(dynamic object, [bool needPrefix = false]) {
    if (needPrefix) {
      return "Type is: ${object.runtimeType}";
    } else {
      return object.runtimeType.toString();
    }
  }
}

//自然语言描述
我们有一个Class Helper节点,里面有一个printType方法节点。
方法节点中接收两个参数:object和needPrefix,并有一段代码段。
代码段是由{}包裹,具体用了一个if表达式判断needPrefix,true则执行xxx(一条或多条)语句并返回;否则执行xxx(一条或多条)语句并返回。

而对应的语法树实际跟自然语言描述是一样的:

7.png

--

3. 插桩的基本思路(三个关键点)

有了上面两点认知后,就可以具体来进行插桩操作,这节将讲述插桩过程中的三个关键点。

正如前文2.1中所说,我们可以在DartSdk FlutterTarget获取到Component根节点对象。插桩操作可以自行对其遍历,但更多的使用DartSdk所提供的Visitor。同Android ASM字节码插桩框架一样,这实际上就是访问者模式的运用。

这是第一个关键点:使用Visitor模式作用是在不改变原有功能体系下,又把每个节点类型呈现出来,方便拓展。

如下段代码:

//使用RecursiveVisitor
component.visitChildren(MyRecursiveVisitor());

//RecursiveVisitor通过递归,可以展示全部的节点
class MyRecursiveVisitor extends RecursiveVisitor {
	//类
	@override
	void visitClass(Class node) {..}
  //方法
  @override
  void visitProcedure(Procedure node) {..}
 
   ...
}

而另一个关键点是:对于语法树插桩的操作,要抛弃原有的代码概念,对语法树节点进行变换。

因为语法树操纵是编译时插桩,所以当生成语法树后,就不能再插入Dart代码,而是应该插入对应代码的语法节点。插入的节点可以根据ast.dart构建,或者提前准备好该节点来简化。

如Demo中实现了方法参数类型打印,这段插桩的节点就是提前准备的 [位置]

3.png

最后一个关键点:在于如何遵循语法树的规则

初次接触语法树操纵,也许会对其中构建规则无从下手。

比如需要在方法中插入一条语句,那就应该知道Procedure是代表方法的节点,在Procedure.FunctionNode.statements为方法的执行语句列表,也是语句插入的位置。

这些知识是必要而零碎的,但所幸如前文2.2所说,语法树是整体符合直觉的。另一个解决该问题的建议是「模仿」,可以先在一个位置写好代码,调试观察其语法树结构。(调试可以查看Demo的Readme文档)

如何验证插桩结果: 如本节开始所述,app.dill作为中间产物,可以通过反编译来查看插桩后的结果。 dart --enable-experiment=non-nullable xxx/dart/sdk/pkg/vm/bin/dump_kernel.dart app.dill app.dill.txt dump_kernel.dart 可以在DartSdk中找到(使用FlutterSdk所对应的版本)

三、编译流程及Aop集成调试

(本节开箱即用可直接阅读3.4)

但一切还没结束,仅知道对语法树操纵的知识是不够的。因为前文所讲述的内容是在DartSdk里直接操作的,而在实际开发过程中,不可能每次插桩修改都重新编译FlutterEngine及DartSdk,然后指定产物来支撑业务开发。

我们需要一种简单,利于修改的Aop集成方式。

这意味着,期望如Demo的工程结构一样,在项目中只存放必要的对Component操纵的代码,就可以把Aop的修改集成进Flutter编译流程里。如图:

4.png

1. 前提:理解FlutterSdk集成方式

在开发中,项目仅需依赖FlutterSdk,但实际上FlutterSdk并没有集成Dart和engine的源码,它所集成的产物目录如下:

FlutterEngine:位于/xxx/flutter/bin/cache/artifacts/engine
DartSdk:位于/xxx/flutter/bin/cache/dart-sdk

frontend_server.snapshot: 位于/xxx/flutter/bin/cache/artifacts/engine/darwin-x64/frontend_server.dart.snapshot

FlutterSdk使用的大部分产物是snapshot,这是DartVm所支持的二进制快照,也可以用命令手动生成。

// 生成frontend_server启动的内存快照
dart --deterministic --snapshot=./out/frontend_server.dart.snapshot --snapshot-kind=kernel /xxx/lib/flutter_frontend_server/starter.dart

这就是Aop项目集成的前提,我们可以通过替换自己的frontend_server snapshot来达到DartSdk修改的目的。

2. Flutter编译流程的替换

那在何处替换frontend_server snapshot呢?需要梳理Flutter的编译流程,重点在于flutter_tools。

flutter_tools包含Flutter的编译工具,位于/xxx/flutter/packages/flutter_tools,运行起点为/xxx/flutter/packages/flutter_tools/bin/flutter_tools.dart。

以下是的Flutter前端编译流程(Android):

5.png

推荐干预的位置, 就在KernelSnapshot调用的compile (..)方法中。

在启动frontend_server时,就可以通过传递的frontendServer参数,指定新编译的snapshot。

而这个snapshot在每次编译时都会由项目工程中的frontend_server重新构建,包含项目中的插桩逻辑,替换了编译流程。

//生成snapshot
//这段代码等价于执行 dart --deterministic --snapshot=./out/frontend_server.dart.snapshot --snapshot-kind=kernel /xxx/lib/flutter_frontend_server/starter.dart
final List<String> commands = <String>[
  globals.artifacts.getArtifactPath(Artifact.engineDartBinary),
  '--deterministic',
  //留意这里--packages参数,用以指定package.json路径,来配置frontend_server的依赖文件。
  '--packages=$rebasedFrontendServerPackageConfigJsonFile', 
  '--snapshot=$hookFlutterFrontendServerSnapshot',
  '--snapshot-kind=kernel',
  '${flutterFrontendServerDirectory.absolute.path}/starter.dart'
];
final ProcessResult processResult = await globals.processManager.run(commands);

3. 依赖与集成

通过以上的理解,就可以把Dart Sdk放入项目工程中了。但这集成代码太过庞大,期望是项目仅集成插桩部分的代码用于修改,其余部分用远程仓库提供依赖就好了。

这里处理的关键,就是依赖。

① 首先需要准备一份DartSdk,在FlutterTarget加入一些钩子,使其可以指向项目中插桩的代码。如下:

6.png

② 其次则需要将插桩所需要的依赖一起打进DartSdk。

大部分三方依赖需要gclient sync同步(FlutterEngine编译的知识),具体所需依赖可以参照:dart - package_config.json

Demo中所使用的DartSdk已经是集成好了的。[仓库]

③ 理解dart依赖,完成改造

这些三方库自身使用相对路径进行依赖,所以不能直接使用pubspec.yaml来集成。

绕过pubspec.yaml的方式就则是直接指定./dart_tools/package_config.json,这是dart依赖的最终依据。格式如下:

{
  "configVersion": 2,
  "packages": [
    ...
          {
            "name": "kernel",
            "rootUri": "file:///Users/skylerli/.pub-cache/git/DartSdkHook-143a5e426ad86a67d2d8e3b8b90c38ffc2fea1f3/pkg/kernel",
	    "packageUri": "lib/",
	    "languageVersion": "2.12"
	  },
    ...
    ]
}

所以在flutter_tools生成frontend_server snapshot时,还需要指定一份提前准备的package_config.json。通过工程下的.packages里的绝对路径推导为正确的依赖文件。

.dart_tools/package_config.json是由flutter pub get自动生成。但在DartSdk这种复杂工程中,是由dart命令生成 [位置],同时支持--package参数指定依赖文件。

以上,就完成了全部的集成工作。

4.可复用的极简Demo

但也有开箱即用的方式,推荐使用这个可复用的极简Demo。它是以最小改动以及避免额外风险为原则构建的,不仅有一个简单的插桩例子,更可以用于快速集成项目。

集成仅需两步:

① 对FlutterSdk打补丁

//flutterSdk目录
cd xxx/flutter
git apply --3way aop_flutter_sdk_2.2.0.patch
rm bin/cache/flutter_tools.stamp

② 拷贝Demo中transform文件夹到项目根目录下。

以上,即可在tranfrom工程中写入对该项目的插桩逻辑。

四、结语

本文梳理了语法树操纵和集成的脉络,但可能仍有疏漏的地方。

初次接触时可能比较模糊,但多次应用后也会觉得浅显易懂。源码主要涉及flutter_tools与dartSdk,可以多多调试比对。