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 参考资料
https://blog.csdn.net/yunqiinsight/article/details/92814036
http://gityuan.com/2019/09/14/flutter_frontend_server/