1. 程式人生 > >看!閒魚又開源了一個 Flutter 開發利器

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

image

阿里妹導讀:隨著 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、設計詳圖

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, 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 註解即可。

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

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 中提供的 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 的命名引數,可用於指定插入邏輯的具體行號。

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<--> 模型轉換等,都提供了一種新的視角與可能。

作者:正物

原文連結

本文為雲棲社群原創內容,未經