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

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

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

1、設計詳圖

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

2、典型的 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!');
}
}

3、面向開發者的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> sourceInfos;
final Object target;
final String function;
final String stubId;
final List<dynamic> positionalParams;
final Map<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;
}
}
/<dynamic>/<dynamic>/<dynamic>

其中包含了源代碼信息(如庫名,文件名,行號等),方法調用對象,函數名,參數信息等。請注意這裡的 @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;
}
/<string>

此處的 @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 註解即可。

4、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 文件進行操作。

5、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
看!閒魚又開源了一個 Flutter 開發利器

★ 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 過程。

6、AspectD 支持的語法

不同於 AspectJ 中提供的 Before\\Around\\After 三種預發,在 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;
}
}
/<string>/<string>

★ 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;
}
/<string>/<string>

★ 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> gestures = <type>{};


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;
},
);
}
/<tapgesturerecognizer>/<type>/<type>

如果我們想要在 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 方法如下所示:

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

此外,Inject 的輸入參數相對於 Call/Execute 而言,多了一個 lineNum 的命名參數,可用於指定插入邏輯的具體行號。

7、構建流程支持

雖然我們可以通過編譯 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 模型轉換等,都提供了一種新的視角與可能。

"


分享到:


相關文章: