1 项目背景
AOP 为 Aspect Oriented Programming 的缩写,意为:面向切面编程,通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。
AspectD 是咸鱼针对 Flutter 实现的 AOP 开源库,GitHub 地址如下:https://github.com/alibaba-flutter/aspectd。
十分感谢咸鱼团队开源的 AspectD 开源库,AspectD 让 flutter 具备了 aop 的能力,给了贝壳 flutter 团队很多思路,让很多想法成为可能。
2 Flutter 相关知识介绍
首先,我们来回顾一下 flutter 编译相关的一些知识。
2.1 Flutter 编译流程
如上图,flutter 在编译时,首先由编译前端将 dart 代码转换为中间文件 app.dill,然后在 debug 模式下,将 app.dill 转换为 kernel_blob.bin(其实这个文件就是 app.dill 改了个名字),在 release 模式下,app.dill 被转换为 framework 或者 so。
Flutter 的 aop 就是对 app.dill 进行修改实现的。下面我们先来了解一下 app.dill 文件。
2.2 app.dill 文件
dill 文件是 dart 编译的中间文件,是 flutter_tools 调用 frontend_server 将 dart 转换生成的。我们可以在工程的 build 目录下找到编译生成的 dill 文件。
Dill 文件本身是不可读的,我们可以通过 dart vm 中的 dump_kernel.dart 来将 dill 文件转换为可读的文件。命令如下
dart /path/to/dump_kernel.dart /path/to/app.dill/U/path/of/output.dill.txt
复制代码
比如我们创建了一个 demo 工程叫做 aop_demo,我们在 main.dart 中有以下代码:
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routes: {
'/': (context) => MyHomePage(title: 'Flutter Demo Home Page'),
'/welcome': (context) => WelcomePage(),
'/bye': (context) => ByePage(),
},
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
复制代码
在我们转换后的 output.dill.txt 文件中看到对于的代码如下:
class MyApp extends fra2::StatelessWidget {
synthetic constructor •() → main2::MyApp*
: super fra2::StatelessWidget::•()
;
@#C7
method build(fra2::BuildContext* context) → fra2::Widget* {
return new app3::MaterialApp::•(title: "Flutter Demo", theme: the3::ThemeData::•(primarySwatch: #C28264, visualDensity: the3::VisualDensity::adaptivePlatformDensity), routes: {"/": (fra2::BuildContext* context) → main2::MyHomePage* => new main2::MyHomePage::•(title: "Flutter Demo Home Page"), "/welcome": (fra2::BuildContext* context) → wel::WelcomePage* => new wel::WelcomePage::•(), "/bye": (fra2::BuildContext* context) → bye::ByePage* => new bye::ByePage::•()}, home: new main2::MyHomePage::•(title: "Flutter Demo Home Page"));
}
}
复制代码
刚才已经提到,flutter 的 aop 是基于对 dill 文件的操作,所有的操作都是基于 AST 的遍历。
2.3 AST
首先我们可以通过以下代码读取 Component(本文 Flutter 使用的是 1.12.13,后同)
final Component component = Component();
final List bytes = File(dillFile).readAsBytesSync();
BinaryBuilderWithMetadata(bytes).readComponent(component);
复制代码
其中 dillFile 为 app.dill 文件的路径。读取的 Component 中包含了我们 app 的所有的 Library,一个 Library 对应我们 flutter 项目中的一个 dart 文件。它的结构如下:
AST 在 flutter 中有很多的运用,如 analyzer 库使用 AST 对代码进行静态分析,dartdevc 使用 AST 进行 dart 和 js 转换,还有就是现有的一些热修复方案也是使用 AST 进行动态解释执行的。
2.4 访问 AST
既然 AST 有这么多运用,那如何对语法树进行分析呢?在这里我们用到的是 kernel 中的 visitor.dart 这个库。
visitor.dart 使用访问者模式,提供了丰富的语法树访问的方法。下面代码中我们列出了该库中的部分方法,可以看到,我们可以对 AST 中变量、属性、super 属性的 set 和 get,方法调用等进行访问。
R visitVariableGet(VariableGet node) => defaultExpression(node);
R visitVariableSet(VariableSet node) => defaultExpression(node);
R visitPropertyGet(PropertyGet node) => defaultExpression(node);
R visitPropertySet(PropertySet node) => defaultExpression(node);
R visitDirectPropertyGet(DirectPropertyGet node) => defaultExpression(node);
R visitDirectPropertySet(DirectPropertySet node) => defaultExpression(node);
R visitSuperPropertyGet(SuperPropertyGet node) => defaultExpression(node);
R visitSuperPropertySet(SuperPropertySet node) => defaultExpression(node);
R visitStaticGet(StaticGet node) => defaultExpression(node);
R visitStaticSet(StaticSet node) => defaultExpression(node);
R visitMethodInvocation(MethodInvocation node) => defaultExpression(node);
R visitDirectMethodInvocation(DirectMethodInvocation node) =>
defaultExpression(node);
R visitSuperMethodInvocation(SuperMethodInvocation node) =>
defaultExpression(node);
R visitStaticInvocation(StaticInvocation node) => defaultExpression(node);
R visitConstructorInvocation(ConstructorInvocation node) =>
defaultExpression(node);
复制代码
下面我们写一个简单的 demo 来实现方法调用的替换。
如下,我们在 main()函数中读取 dill 文件,然后对读取的 Component 进行访问。
void main() {
final String path =
'/Users/beike/aop_demo/.dart_tool/flutter_build/6840774ade9dd94681307ab48f4846dc/app.dill';
Component component = readComponent(path);
MethodVisitor visitor = MethodVisitor();
component.libraries.forEach((element) {
if (element.reference.canonicalName.name == 'package:aop_demo/main.dart') {
visitor.visitLibrary(element);
}
});
writeComponent(path, component);
}
复制代码
然后我们对方法调用进行访问,把_MyHomePageState 类中所有对 printCounter()方法的调用替换为调用 printCounterHook()方法。
class MethodVisitor extends Transformer {
@override
MethodInvocation visitMethodInvocation(MethodInvocation methodInvocation) {
methodInvocation.transformChildren(this);
final Node node = methodInvocation.interfaceTargetReference?.node;
if (node is Procedure && node != null) {
final Library library = node.parent.parent;
final Class cls = node.parent;
final String clsName = cls.name;
final String methodName = methodInvocation.name.name;
if (clsName == '_MyHomePageState' && methodName == 'printCounter') {
MethodInvocation hookMethodInvocation = MethodInvocation(
methodInvocation.receiver, Name('printCounterHook'), null);
return hookMethodInvocation;
}
}
return methodInvocation;
}
}
复制代码
这样我们就在不侵入业务代码的前提下做到了更改业务代码。
3 Beike_AspectD 介绍
关于 AspectD,官方已经介绍的比较详细,下面我们主要介绍一下贝壳的 Beike_AspectD。
Beike_AspectD 主要包括三部分:
3.1 切入点设计
首先我们来介绍一下切入点的设计。Beike_AspectD 支持四种切入方式:
Call:调用处作为切入点
如下面代码,我们在调用_MyHomePageState 的 printCounter()方法的代码处添加了 print 输出。
@Call("package:aop_demo/main.dart", "_MyHomePageState", "-printCounter")
@pragma("vm:entry-point")
void hookPrintCounter(PointCut pointcut) {
print('printCounter called');
pointcut.proceed();
}
复制代码
Execute:执行处作为切入点
@Execute("package:aop_demo/main.dart", "MyApp", "-build")
@pragma("vm:entry-point")
Widget hookBuild(PointCut pointcut) {
print('hookBuild called');
return pointcut.proceed();
}
复制代码
Inject:在指定代码行处插入代码
@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",
"-buildPage",
lineNum: 92)
@pragma("vm:entry-point")
void hookBuildPage() {
dynamic result; //Aspectd Ignore
dynamic route1 = this;
print(route1);
print('Building page ${result}');
}
复制代码
Add:在指定位置添加方法
@Add("package:aop_demo\\/.+\\.dart", ".*", isRegex: true)
@pragma("vm:entry-point")
String importUri(PointCut pointCut) {
return pointCut.sourceInfos["importUri"];
}
复制代码
如上面代码我们在 aop_demo 中所有的类中添加了 widgetUri()方法,返回 widget 所在文件的 importUri。
PointCut
Call、Execute、Add 模式下,我们看到在方法中返回 PointCut 对象,PointCut 包含以下信息,其中调用 procceed()就会调用原始方法实现。
class PointCut {
/// PointCut default constructor.
@pragma('vm:entry-point')
PointCut(this.sourceInfos, this.target, this.function, this.stubKey,
this.positionalParams, this.namedParams, this.members, this.annotations);
/// Source infomation like file, linenum, etc for a call.
final Map sourceInfos;
/// Target where a call is operating on, like x for x.foo().
final Object target;
/// Function name for a call, like foo for x.foo().
final String function;
/// Unique key which can help the proceed function to distinguish a
/// mocked call.
final String stubKey;
/// Positional parameters for a call.
final List positionalParams;
/// Named parameters for a call.
final Map namedParams;
/// Class's members. In Call mode, it's caller class's members. In execute mode, it's execution class's members.
final Map members;
/// Class's annotations. In Call mode, it's caller class's annotations. In execute mode, it's execution class's annotations.
final Map annotations;
/// Unified entrypoint to call a original method,
/// the method body is generated dynamically when being transformed in
/// compile time.
@pragma('vm:entry-point')
Object proceed() {
return null;
}
}
复制代码
3.2 代码转换
Beike_AspectD 将转换流程集成到 ke_flutter_tools,这样只要集成了贝壳的 flutter 库,就不用再做额外的适配。整个转换的流程如下:
下面我们以 Execute 为例子看一下 Beike_AspectD 对 dill 文件做了怎样的转换。
还是上面的 Execute 替换,我们将 dill 文件转换之后看到 build 方法的实现被替换为直接调用我们 hook 方法 hookBuild。并且在被 hook 的类中添加了方法 build_aop_stub_1,build_aop_stub1 中的实现为 build 方法中的原始实现:
method build(fra::BuildContext* context) → fra::Widget* {
return new hook::hook::•().hookBuild(new poi::PointCut::•({"importUri": "package:aop_demo/main.dart", "library": "package:aop_demo", "file": "file:///Users/beike/aop_demo/lib/main.dart", "lineNum": "1", "lineOffset": "0", "procedure": "MyApp::build"}, this, "build", "aop_stub_1", [context], {}, {}, {}));
}
method build_aop_stub_1(fra::BuildContext* context) → fra::Widget* {
return new app::MaterialApp::•(title: "Flutter Demo", theme: the::ThemeData::•(primarySwatch: #C124), home: new main::MyHomePage::•(title: "Flutter Demo Home Page", $creationLocationd_0dea112b090073317d4: #C132), $creationLocationd_0dea112b090073317d4: #C142);
}
复制代码
在 PointCut 中定义了 aop_stub1 方法,调用了 build_aop_stub_1 方法。
method proceed() → core::Object* {
if(this.stubKey.==("aop_stub_1")) {
return this.aop_stub_1();
}
return null;
}
method aop_stub_1() → core::Object* {
return (this.target as main::MyApp?).{=main::MyApp::build_aop_stub_1}(this.positionalParams.[](0) as fra::BuildContext*);
}
复制代码
所以整个调用链变成了:
方法调用-> build -> hookBuild -> PointCut.procced -> aop_stub1 -> build_aop_stub_1
4 应用场景
Beike_AspectD 在贝壳已经在性能检测、埋点、JSONModel 转换等库使用。下面我们来通过一个简单的例子看看 Beike_AspectD 如何实现页面展示统计。
@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",
"-buildPage",
lineNum: 92)
@pragma("vm:entry-point")
void hookBuildPage() {
dynamic result; //Aspectd Ignore
String widgetName = result.toString();
//widgetName为当前展示页面的名字
//后续执行页面展示上报逻辑
//.............
}
复制代码
首先我们对 MaterialPageRoute 的 buildPage 插入代码,获取当前显示 widget 的名字。但问题是 dart 中允许定义同名类,只是获取 widget 的名字还无法唯一确定页面,我们需要知道 widget 定义所在的文件,于是我们做了如下更改:
@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",
"-buildPage",
lineNum: 92)
@pragma("vm:entry-point")
void hookBuildPage() {
dynamic result; //Aspectd Ignore
String widgetName = result.toString();
String importUri = result.importUri(null);
print(widgetName + importUri);
//widgetName为当前展示页面的名字,importUri为widget所在文件的uri
//后续执行页面展示上报逻辑
//.............
}
@Add("package:aop_demo\\/.+\\.dart", ".*", isRegex: true)
@pragma("vm:entry-point")
String importUri(PointCut pointCut) {
return pointCut.sourceInfos["importUri"];
}
复制代码
我们通过 Add 给 widget 添加了获取 importUri 的方法,这样有了 importUri 和 widgetName 我们就能够唯一的确定 widget,然后就可以完成剩下的上报流程。
5 参考资料
本文转载自公众号贝壳产品技术(ID:beikeTC)。
原文链接:
Beike AspectD的原理及运用
评论