作者:閑魚技術-正物

問題背景

隨著Flutter這一框架的快速發展,有越來越多的業務開始使用Flutter來重構或新建其產品。但在我們的實踐過程中發現,一方面Flutter開發效率高,性能優異,跨平臺表現好,另一方面Flutter也面臨著插件,基礎能力,底層框架缺失或者不完善等問題。

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

面向切面編程即AOP(Aspect Oriented Programming),它可以在編譯時(或運行時),動態地將代碼切入到類的特定方法、特定位置上,從而在不修改源代碼的情況下給已有代碼動態統一地添加功能。

那麼如何解決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.

或者直接聯繫作者


推薦閱讀:
相關文章