https://github.com/alibaba-flutter/aspectd

问题背景

随着Flutter这一框架的快速发展,有越来越多的业务开始使用Flutter来重构或新建其产品。但在我们的实践过程中发现,一方面Flutter开发效率高,性能优异,跨平台表现好,另一方面Flutter也面临着插件,基础能力,底层框架缺失或者不完善等问题。

举个栗子,我们在实现一个自动化录制回放的过程中发现,需要去修改Flutter框架(Dart层面)的代码才能够满足要求,这就会有了对框架的侵入性。要解决这种侵入性的问题,更好地减少迭代过程中的维护成本,我们考虑的首要方案即面向切面编程。

那么如何解决AOP for Flutter这个问题呢?本文将重点介绍一个闲鱼技术团队开发的针对Dart的AOP编程框架AspectD。

AspectD:面向Dart的AOP框架

AOP能力究竟是运行时还是编译时支持依赖于语言本身的特点。举例来说在iOS中,Objective C本身提供了强大的运行时和动态性使得运行期AOP简单易用。在Android下,Java语言的特点不仅可以实现类似AspectJ这样的基于字节码修改的编译期静态代理,也可以实现Spring AOP这样的基于运行时增强的运行期动态代理。
那么Dart呢?一来Dart的反射支持很弱,只支持了检查(Introspection),不支持修改(Modification);其次Flutter为了包大小,健壮性等的原因禁止了反射。

因此,我们设计实现了基于编译期修改的AOP方案AspectD。

设计详图

典型的AOP场景

下列AspectD代码说明了一个典型的AOP使用场景:

aop.dart

import 'package:example/main.dart' as app;
import 'aop_impl.dart'; void main()=> app.main();

aop_impl.dart

import 'package:aspectd/aspectd.dart';

@Aspect()
@pragma("vm:entry-point")
class ExecuteDemo {
@pragma("vm:entry-point")
ExecuteDemo(); @Execute("package:example/main.dart", "_MyHomePageState", "-_incrementCounter")
@pragma("vm:entry-point")
void _incrementCounter(PointCut pointcut) {
pointcut.proceed();
print('KWLM called!');
}
}

面向开发者的API设计

PointCut的设计

@Call("package:app/calculator.dart","Calculator","-getCurTime")

PointCut需要完备表征以怎么样的方式(Call/Execute等),向哪个Library,哪个类(Library Method的时候此项为空),哪个方法来添加AOP逻辑。
PointCut的数据结构:

@pragma('vm:entry-point')
class PointCut {
final Map<dynamic, dynamic> sourceInfos;
final Object target;
final String function;
final String stubId;
final List<dynamic> positionalParams;
final Map<dynamic, dynamic> namedParams; @pragma('vm:entry-point')
PointCut(this.sourceInfos, this.target, this.function, this.stubId,this.positionalParams, this.namedParams); @pragma('vm:entry-point')
Object proceed(){
return null;
}
}

其中包含了源代码信息(如库名,文件名,行号等),方法调用对象,函数名,参数信息等。
请注意这里的@pragma('vm:entry-point')注解,其核心逻辑在于Tree-Shaking。在AOT(ahead of time)编译下,如果不能被应用主入口(main)最终可能调到,那么将被视为无用代码而丢弃。AOP代码因为其注入逻辑的无侵入性,显然是不会被main调到的,因此需要此注解告诉编译器不要丢弃这段逻辑。
此处的proceed方法,类似AspectJ中的ProceedingJoinPoint.proceed()方法,调用pointcut.proceed()方法即可实现对原始逻辑的调用。原始定义中的proceed方法体只是个空壳,其内容将会被在运行时动态生成。

Advice的设计

@pragma("vm:entry-point")
Future<String> getCurTime(PointCut pointcut) async{
...
return result;
}

此处的@pragma("vm:entry-point")效果同a中所述,pointCut对象作为参数传入AOP方法,使开发者可以获得源代码调用信息的相关信息,实现自身逻辑或者是通过pointcut.proceed()调用原始逻辑。

Aspect的设计

@Aspect()
@pragma("vm:entry-point")
class ExecuteDemo {
@pragma("vm:entry-point")
ExecuteDemo();
...
}

Aspect的注解可以使得ExecuteDemo这样的AOP实现类被方便地识别和提取,也可以起到开关的作用,即如果希望禁掉此段AOP逻辑,移除@Aspect注解即可。

AOP代码的编译

包含原始工程中的main入口

从上文可以看到,aop.dart引入import 'package:example/main.dart' as app;,这使得编译aop.dart时可包含整个example工程的所有代码。

Debug模式下的编译

在aop.dart中引入import 'aop_impl.dart';这使得aop_impl.dart中内容即便不被aop.dart显式依赖,也可以在Debug模式下被编译进去。

Release模式下的编译

在AOT编译(Release模式下),Tree-Shaking逻辑使得当aop_impl.dart中的内容没有被aop中main调用时,其内容将不会编译到dill中。通过添加@pragma("vm:entry-point")可以避免其影响。

当我们用AspectD写出AOP代码,透过编译aop.dart生成中间产物,使得dill中既包含了原始项目代码,也包含了AOP代码后,则需要考虑如何对其修改。在AspectJ中,修改是通过对Class文件进行操作实现的,在AspectD中,我们则对dill文件进行操作。

Dill操作

dill文件,又称为Dart Intermediate Language,是Dart语言编译中的一个概念,无论是Script Snapshot还是AOT编译,都需要dill作为中间产物。

Dill的结构

我们可以通过dart sdk中的vm package提供的dump_kernel.dart打印出dill的内部结构。

dart bin/dump_kernel.dart /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill.txt

Dill变换

dart提供了一种Kernel to Kernel Transform的方式,可以通过对dill文件的递归式AST遍历,实现对dill的变换。

基于开发者编写的AspectD注解,AspectD的变换部分可以提取出是哪些库/类/方法需要添加怎样的AOP代码,再在AST递归的过程中通过对目标类的操作,实现Call/Execute这样的功能。

一个典型的Transform部分逻辑如下所示:

  @override
MethodInvocation visitMethodInvocation(MethodInvocation methodInvocation) {
methodInvocation.transformChildren(this);
Node node = methodInvocation.interfaceTargetReference?.node;
String uniqueKeyForMethod = null;
if (node is Procedure) {
Procedure procedure = node;
Class cls = procedure.parent as Class;
String procedureImportUri = cls.reference.canonicalName.parent.name;
uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(
procedureImportUri, cls.name, methodInvocation.name.name, false, null);
}
else if(node == null) {
String importUri = methodInvocation?.interfaceTargetReference?.canonicalName?.reference?.canonicalName?.nonRootTop?.name;
String clsName = methodInvocation?.interfaceTargetReference?.canonicalName?.parent?.parent?.name;
String methodName = methodInvocation?.interfaceTargetReference?.canonicalName?.name;
uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(
importUri, clsName, methodName, false, null);
}
if(uniqueKeyForMethod != null) {
AspectdItemInfo aspectdItemInfo = _aspectdInfoMap[uniqueKeyForMethod];
if (aspectdItemInfo?.mode == AspectdMode.Call &&
!_transformedInvocationSet.contains(methodInvocation) && AspectdUtils.checkIfSkipAOP(aspectdItemInfo, _curLibrary) == false) {
return transformInstanceMethodInvocation(
methodInvocation, aspectdItemInfo);
}
}
return methodInvocation;
}

通过对于dill中AST对象的遍历(此处的visitMethodInvocation函数),结合开发者书写的AspectD注解(此处的aspectdInfoMap和aspectdItemInfo),可以对原始的AST对象(此处methodInvocation)进行变换,从而改变原始的代码逻辑,即Transform过程。

AspectD支持的语法

不同于AspectJ中提供的BeforeAroundAfter三种预发,在AspectD中,只有一种统一的抽象即Around。
从是否修改原始方法内部而言,有Call和Execute两种,前者的PointCut是调用点,后者的PointCut则是执行点。

Call

import 'package:aspectd/aspectd.dart';

@Aspect()
@pragma("vm:entry-point")
class CallDemo{
@Call("package:app/calculator.dart","Calculator","-getCurTime")
@pragma("vm:entry-point")
Future<String> getCurTime(PointCut pointcut) async{
print('Aspectd:KWLM02');
print('${pointcut.sourceInfos.toString()}');
Future<String> result = pointcut.proceed();
String test = await result;
print('Aspectd:KWLM03');
print('${test}');
return result;
}
}

Execute

import 'package:aspectd/aspectd.dart';

@Aspect()
@pragma("vm:entry-point")
class ExecuteDemo{
@Execute("package:app/calculator.dart","Calculator","-getCurTime")
@pragma("vm:entry-point")
Future<String> getCurTime(PointCut pointcut) async{
print('Aspectd:KWLM12');
print('${pointcut.sourceInfos.toString()}');
Future<String> result = pointcut.proceed();
String test = await result;
print('Aspectd:KWLM13');
print('${test}');
return result;
}

Inject

仅支持Call和Execute,对于Flutter(Dart)而言显然很是单薄。一方面Flutter禁止了反射,退一步讲,即便Flutter开启了反射支持,依然很弱,并不能满足需求。
举个典型的场景,如果需要注入的dart代码里,x.dart文件的类y定义了一个私有方法m或者成员变量p,那么在aop_impl.dart中是没有办法对其访问的,更不用说多个连续的私有变量属性获得。另一方面,仅仅对方法整体进行操作可能是不够的,我们可能需要在方法的中间插入处理逻辑。
为了解决这一问题,AspectD设计了一种语法Inject,参见下面的例子:
flutter库中包含了一下这段手势相关代码:

@override
Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; if (onTapDown != null || onTapUp != null || onTap != null || onTapCancel != null) {
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
..onTap = onTap
..onTapCancel = onTapCancel;
},
);
}

如果我们想要在onTapCancel之后添加一段对于instance和context的处理逻辑,Call和Execute是不可行的,而使用Inject后,只需要简单的几句即可解决:

import 'package:aspectd/aspectd.dart';

@Aspect()
@pragma("vm:entry-point")
class InjectDemo{
@Inject("package:flutter/src/widgets/gesture_detector.dart","GestureDetector","-build", lineNum:452)
@pragma("vm:entry-point")
static void onTapBuild() {
Object instance; //Aspectd Ignore
Object context; //Aspectd Ignore
print(instance);
print(context);
print('Aspectd:KWLM25');
}
}

通过上述的处理逻辑,经过编译构建后的dill中的GestureDetector.build方法如下所示:

此外,Inject的输入参数相对于Call/Execute而言,多了一个lineNum的命名参数,可用于指定插入逻辑的具体行号。

构建流程支持

虽然我们可以通过编译aop.dart达到同时编译原始工程代码和AspectD代码到dill文件,再通过Transform实现dill层次的变换实现AOP,但标准的flutter构建(即flutter_tools)并不支持这个过程,所以还是需要对构建过程做细微修改。
在AspectJ中,这一过程是由非标准Java编译器的Ajc来实现的。在AspectD中,通过对flutter_tools打上应用Patch,可以实现对于AspectD的支持。

kylewong@KyleWongdeMacBook-Pro fluttermaster % git apply --3way /Users/kylewong/Codes/AOP/aspectd/0001-aspectd.patch
kylewong@KyleWongdeMacBook-Pro fluttermaster % rm bin/cache/flutter_tools.stamp
kylewong@KyleWongdeMacBook-Pro fluttermaster % flutter doctor -v
Building flutter tool...

实战与思考

基于AspectD,我们在实践中成功地移除了所有对于Flutter框架的侵入性代码,实现了同有侵入性代码同样的功能,支撑上百个脚本的录制回放与自动化回归稳定可靠运行。

从AspectD的角度看,Call/Execute可以帮助我们便捷实现诸如性能埋点(关键方法的调用时长),日志增强(获取某个方法具体是在什么地方被调用到的详细信息),Doom录制回放(如随机数序列的生成记录与回放)等功能。Inject语法则更为强大,可以通过类似源代码诸如的方式,实现逻辑的自由注入,可以支持诸如App录制与自动化回归(如用户触摸事件的录制与回放)等复杂场景。

进一步来说,AspectD的原理基于Dill变换,有了Dill操作这一利器,开发者可以自由地对Dart编译产物进行操作,而且这种变换面向的是近乎源代码级别的AST对象,不仅强大而且可靠。无论是做一些逻辑替换,还是是Json<-->模型转换等,都提供了一种新的视角与可能。

写在最后

AspectD作为闲鱼技术团队新开发的面向Flutter的AOP框架,已经可以支持主流的AOP场景并在Github开源,欢迎使用。Aspectd for Flutter
如果你在使用过程中,有任何问题或者建议,欢迎提issue或者PR.或者直接联系作者

本文作者:闲鱼技术-正物

原文链接

本文为云栖社区原创内容,未经允许不得转载。

重磅开源|AOP for Flutter开发利器——AspectD的更多相关文章

  1. Flutter开发环境(Window)配置及踩坑记录

    Flutter 是 Google 用以帮助开发者在 iOS 和 Android 两个平台开发高质量原生 UI 的移动 SDK.Flutter 兼容现有的代码,免费且开源,在全球开发者中广泛被使用. F ...

  2. [.net 面向对象程序设计进阶] (27) 团队开发利器(六)分布式版本控制系统Git——在Visual Studio 2015中使用Git

    [.net 面向对象程序设计进阶] (26) 团队开发利器(六)分布式版本控制系统Git——在Visual Studio 2015中使用Git 本篇导读: 接上两篇,继续Git之旅 分布式版本控制系统 ...

  3. [.net 面向对象程序设计进阶] (25) 团队开发利器(四)分布式版本控制系统Git——使用GitStack+TortoiseGit 图形界面搭建Git环境

    [.net 面向对象程序设计进阶] (25) 团队开发利器(四)分布式版本控制系统Git——使用GitStack+TortoiseGit 图形界面搭建Git环境 本篇导读: 前面介绍了两款代码管理工具 ...

  4. [.net 面向对象程序设计进阶] (23) 团队开发利器(二)优秀的版本控制工具SVN(上)

    [.net 面向对象程序设计进阶] (23) 团队开发利器(二)优秀的版本控制工具SVN(上) 本篇导读: 上篇介绍了常用的代码管理工具VSS,看了一下评论,很多同学深恶痛绝,有的甚至因为公司使用VS ...

  5. 谷歌重磅开源强化学习框架Dopamine吊打OpenAI

    谷歌重磅开源强化学习框架Dopamine吊打OpenAI 近日OpenAI在Dota 2上的表现,让强化学习又火了一把,但是 OpenAI 的强化学习训练环境 OpenAI Gym 却屡遭抱怨,比如不 ...

  6. Android零基础入门第13节:Android Studio配置优化,打造开发利器

    原文:Android零基础入门第13节:Android Studio配置优化,打造开发利器 是不是很多同学已经有烦恼出现了?电脑配置已经很高了,但是每次运行Android程序的时候就很卡,而且每次安装 ...

  7. mac 上配置flutter开发环境

    (ios,Android,Xcode,Android Studio,VScode,IDEA) 1)安装Flutter SDK 2)iOS 环境配置 3)Android Studio配置 4)VS co ...

  8. Flutter开发进阶学习指南Flutter开发进阶学习指南

    Flutter 的起源 Flutter 的诞生其实比较有意思,Flutter 诞生于 Chrome 团队的一场内部实验, 谷歌的前端团队在把前端一些"乱七八糟"的规范去掉后,发现在 ...

  9. NodeJS全栈开发利器:CabloyJS究竟是什么

    CabloyJS CabloyJS是一款顶级NodeJS全栈业务开发框架, 基于KoaJS + EggJS + VueJS + Framework7 文档 官网 && 文档 演示 PC ...

随机推荐

  1. canvas扇形进度圈动态加载

    效果图如下:动态加载的 实现代码如下: <!DOCTYPE html> <html lang="en"> <head> <meta cha ...

  2. C++使用stringstream分割字符串

    在这里查看getline的函数声明如下: 可以看到,第三个参数delim是分隔符,可以指定不同的分隔符,如果不指定的话就默认是'\n'. 举个例子:

  3. jnhs-netbeans maven Failed to execute goal org.apache.maven.plugins:maven-clean-plugin:2.4.1:clean (default-clean) on project

    w 无法完成清理 出现这种错误,通常是由于启动了另一个tomcat 进程或者运行的javaw.exe进程,导致报错. 直接运行工程启动后再清理就好了 或者 重启大法

  4. Linux下读写UART串口的代码

    Linux下读写UART串口的代码,从IBM Developer network上拿来的东西,操作比較的复杂,就直接跳过了,好在代码能用,记录一下- 两个实用的函数- //////////////// ...

  5. IbatchBolt和BaseTransactionalBolt区别

    void prepare(java.util.Map conf, TopologyContext context, BatchOutputCollector collector, T id) T id ...

  6. NKOJ1472 警卫安排

    P1472警卫安排   时间限制 : 10000 MS   空间限制 : 65536 KB 问题描述 一个重要的基地被分为n个连通的区域.出于某种神秘的原因,这些区域以一个区域为核心,呈一颗树形分布. ...

  7. 【洛谷P1827】【USACO】 美国血统 American Heritage 由二叉树两个序列求第三个序列

    P1827 美国血统 American Heritage 题目描述 农夫约翰非常认真地对待他的奶牛们的血统.然而他不是一个真正优秀的记帐员.他把他的奶牛 们的家谱作成二叉树,并且把二叉树以更线性的&q ...

  8. chrome浏览器 新打开的页面覆盖当前页面

    chrome浏览器  本应该新打开一个页面,却覆盖了当前页面. 解决办法:使用鼠标中键打开一次,后续即可正常使用.

  9. 开源中国 ThinkPHP 领奖

    开源中国 ThinkPHP 的领奖 周日早上早早就起来参考开源中国的活动. 由于今年竞争激烈 FastAdmin 没有上榜,但是没关系,因为这说明整个开源环境越来越好了,对于我们来说是利好. 因为 T ...

  10. 移动web的基础知识

    一.像素 px:CSS pixels逻辑像素,浏览器使用的抽象单位 dp,pt:设备无关像素 (物理像素) dpr:设备像素缩放比 计算公式: 1px = (dpr)*(dpr)*dp 二.viewp ...