看!閒魚又開源了一個 Flutter 開發利器

阿里妹導讀:隨着 Flutter 這一框架的快速發展,有愈來愈多的業務開始使用 Flutter 來重構或新建其產品。但在咱們的實踐過程當中發現,一方面 Flutter 開發效率高,性能優異,跨平臺表現好,另外一方面 Flutter 也面臨着插件,基礎能力,底層框架缺失或者不完善等問題。今天,閒魚團隊的正物帶咱們解決一個問題:如何解決 AOP for Flutter?

問題背景

咱們在實現一個自動化錄製回放的過程當中發現,須要去修改 Flutter 框架( Dart 層面)的代碼纔可以知足要求,這就會有了對框架的侵入性。要解決這種侵入性的問題,更好地減小迭代過程當中的維護成本,咱們考慮的首要方案即面向切面編程。node

那麼如何解決 AOP for Flutter 這個問題呢?本文將重點介紹一個閒魚技術團隊開發的針對 Dart 的 AOP 編程框架 AspectD。git

AspectD:面向 Dart 的 AOP 框架

AOP 能力到底是運行時仍是編譯時支持依賴於語言自己的特色。舉例來講在 iOS 中,Objective C 自己提供了強大的運行時和動態性使得運行期 AOP 簡單易用。在 Android下,Java 語言的特色不只能夠實現相似 AspectJ 這樣的基於字節碼修改的編譯期靜態代理,也能夠實現 Spring AOP 這樣的基於運行時加強的運行期動態代理。那麼 Dart 呢?一來 Dart 的反射支持很弱,只支持了檢查( Introspection ),不支持修改( Modification );其次 Flutter 爲了包大小,健壯性等的緣由禁止了反射。編程

所以,咱們設計實現了基於編譯期修改的 AOP 方案 AspectD。數據結構

一、設計詳圖

二、典型的 AOP 場景

下列 AspectD 代碼說明了一個典型的 AOP 使用場景:app

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")async

PointCut 須要完備表徵以什麼樣的方式( Call/Execute 等),向哪一個 Library,哪一個類(Library Method 的時候此項爲空),哪一個方法來添加 AOP 邏輯。PointCut 的數據結構:ide

@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 的設計

@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 的內部結構

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 庫中包含了一下這段手勢相關代碼:

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 後,只須要簡單的幾句便可解決:

@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 構建(即fluttertools) 並不支持這個過程,因此仍是須要對構建過程作細微修改。在 AspectJ 中,這一過程是由非標準 Java 編譯器的 Ajc 來實現的。在 AspectD 中,經過對fluttertools 打上應用 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<--> 模型轉換等,都提供了一種新的視角與可能。



本文做者:正物

閱讀原文

本文來自雲棲社區合做夥伴「阿里技術」,如需轉載請聯繫原做者。

相關文章
相關標籤/搜索