作者簡介
雍光Assuner、菜嘰、執卿、澤卦;餓了麼物流移動組
前言
Flutter 作為當下最火的跨平臺技術,提供了媲美原生效能的 app 使用體驗。Flutter 相比 RN 還自建了自己的 RenderObject 層和 Rendering 實現,“幾乎” 徹底解決了多端一致性問題,讓 dart 程式碼真正有效的落實 “一處編寫,處處執行”,接近雙倍的提升了開發者們的搬磚效率。前面為什麼說 "幾乎",雖然 Flutter 為我們提供了一種快捷構建使用者介面和互動的開發方案,但涉及到平臺 native 能力的使用,如推送、定位、藍芽等,也只能 "曲線救國",藉助 Channel 實現, 這就免不了我們要分別寫一部分 native 程式碼 和 dart 程式碼做 “技術對接”,略略破壞了這 “完美” 的跨平臺一致性。另外,大部分公司的 app 都不是完全重新建立起來的 Flutter app,更多情況下,Flutter 開發的頁面及業務最終會以編譯產物作為一個模組整合到主工程。主工程原先已經有了大量優秀的工具或業務相關庫,如可能是功能強大、做了大量優化的網路庫,也可能是一個到處使用的本地快取庫,那麼無疑,需要使用的 native 能力範圍相比平臺自身的能力範圍擴大了不少,channel 的定義和使用變得更加高頻。
很多開發者都使用過 channel, 尤其是 dart 呼叫 native 程式碼的 Method Channel。 在 dart 側,我們可以例項化一個 Channel 物件:
static const MethodChannel examleChannel = const MethodChannel('ExamplePlugin');
複製程式碼
使用該 Channel 呼叫原生方法 :
final String version = await examleChannel.invokeMethod('nativeMethodA', {"a":1, "b": "abc"});
複製程式碼
在 iOS 平臺,需要編寫 ObjC 程式碼:
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"ExamplePlugin" binaryMessenger:[registrar messenger]];
[channel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult _Nonnull result) {
if ([call.method isEqualToString:@"nativeMethodA"]) {
NSDictionary *params = call.arguments;
NSInteger a = [params[@"a"] integerValue];
NSString *b = params[@"b"];
// ...
}
}];
複製程式碼
在 Android 平臺,需要編寫 Java 程式碼:
public class ExamplePlugin implements MethodCallHandler {
/** Plugin registration. */
public static void registerWith(Registrar registrar) {
final MethodChannel channel = new MethodChannel(registrar.messenger(), "ExamplePlugin");
channel.setMethodCallHandler(new ExamplePlugin());
}
@Override
public void onMethodCall(MethodCall call, Result result) {
if (call.method.equals("nativeMethodA")) {
// ...
}
}
}
複製程式碼
由上我們可以發現,Channel 的使用 有以下缺點:
- Channel 的名字、呼叫的方法名是字串硬編碼的;
- channel 只能單次整體呼叫字串匹配的程式碼塊,引數限定是單個物件;不能呼叫 native 類已存在的方法,更不能組合呼叫若干個 native 方法.
- 在native 字串匹配的程式碼塊,仍然需要手動對應取出引數,供真正關鍵方法呼叫,再把返回值封裝返回給dart.
- 定義一個Channel 呼叫 native 方法, 需要維護 dart、ObjC、Java 三方程式碼
- flutter 除錯時,native 程式碼是不支援熱載入的,修改 native 程式碼需要工程重跑;
- channel 呼叫可能涵蓋了諸多細碎的原生能力,native 程式碼處理的 method 不宜過多,且一般會依賴三方庫;多個channel 的維護是分散的;
繼續分析,我們得出認知:
- 跨平臺,定位一個方法的硬編碼是絕對免不了的;
- native 裡字串匹配的程式碼塊裡,真正的關鍵方法呼叫是不可或缺的;
- 方法呼叫必須支援可變引數
為此,我們實現了一個 dart 到 native 的超級通道 --- dna,試圖解決 Channel 的諸多使用和維護上的缺點,主要有以下能力和特性:
- 使用 dart程式碼 呼叫 native 任意類的任意方法;意味著channel 的 native程式碼 可以寫在 dart 原始檔中;
- 可以組合呼叫多個 native 方法確定返回值,支援上下文呼叫,鏈式呼叫;
- 呼叫 native 方法的引數直接順序放到不定長度陣列,native 自動順序為引數解包呼叫;
- 支援 native 程式碼的 熱載入,不中斷的開發體驗.
- 更加簡單的程式碼維護.
dna 的使用
dna
在Dart程式碼
中:
-
定義了
NativeContext 類
,以執行Dart 程式碼
的方式,描述Native 程式碼
呼叫上下文(呼叫棧);最後呼叫context.execute()
執行對應平臺的Native 程式碼
並返回結果。 -
定義了
NativeObject 類
,用於標識Native 變數
.呼叫者 NativeObject 物件
可藉助所在NativeContext上下文
呼叫invoke方法
傳入方法名 method
和引數陣列 args list
,得到返回值NativeObject物件
。
NativeContext 子類
的API是一致的. 下面先詳細介紹通過 ObjCContext
呼叫 ObjC
,再區別介紹 JAVAContext
呼叫 JAVA
.
Dart 呼叫 ObjC
ObjCContext
僅在iOS平臺會實際執行.
1. 支援上下文呼叫
(1) 返回值作為呼叫者
ObjC程式碼
NSString *versionString = [[UIDevice currentDevice] systemVersion];
// 通過channel返回versionString
複製程式碼
Dart 程式碼
ObjCContext context = ObjCContext();
NativeObject UIDevice = context.classFromString('UIDevice');
NativeObject device = UIDevice.invoke(method: 'currentDevice');
NativeObject version = device.invoke(method: 'systemVersion');
context.returnVar = version; // 可省略設定最終返回值, 參考3
// 直接獲得原生執行結果
var versionString = await context.execute();
複製程式碼
(2) 返回值作為引數
ObjC程式碼
NSString *versionString = [[UIDevice currentDevice] systemVersion];
NSString *platform = @"iOS-";
versionString = [platform stringByAppendingString: versionString];
// 通過channel返回versionString
複製程式碼
Dart 程式碼
ObjCContext context = ObjCContext();
NativeClass UIDevice = context.classFromString('UIDevice');
NativeObject device = UIDevice.invoke(method: 'currentDevice');
NativeObject version = device.invoke(method: 'systemVersion');
NativeObject platform = context.classFromString("NSString").invoke(method: 'stringWithString:', args: ['iOS-']);
version = platform.invoke(method: 'stringByAppendingString:', args: [version]);
context.returnVar = version; // 可省略設定最終返回值, 參考3
// 直接獲得原生執行結果
var versionString = await context.execute();
複製程式碼
2. 支援鏈式呼叫
ObjC程式碼
NSString *versionString = [[UIDevice currentDevice] systemVersion];
versionString = [@"iOS-" stringByAppendingString: versionString];
// 通過channel返回versionString
複製程式碼
Dart 程式碼
ObjCContext context = ObjCContext();
NativeObject version = context.classFromString('UIDevice').invoke(method: 'currentDevice').invoke(method: 'systemVersion');
version = context.classFromString("NSString").invoke(method: 'stringWithString:', args: ['iOS-']).invoke(method: 'stringByAppendingString:', args: [version]);
context.returnVar = version; // 可省略設定最終返回值, 參考3
// 直接獲得原生執行結果
var versionString = await context.execute();
複製程式碼
*關於Context的最終返回值
context.returnVar
是 context
最終執行完畢返回值的標記
- 設定context.returnVar: 返回該NativeObject對應的Native變數
- 不設定context.returnVar: 執行到最後一個invoke,如果有返回值,作為context的最終返回值; 無返回值則返回空值;
ObjCContext context = ObjCContext();
context.classFromString('UIDevice').invoke(method: 'currentDevice').invoke(method: 'systemVersion');
// 直接獲得原生執行結果
var versionString = await context.execute();
複製程式碼
3.支援快捷使用JSON中例項化物件
或許有些時候,我們需要用 JSON
直接例項化一個物件.
ObjC程式碼
ClassA *objectA = [ClassA new];
objectA.a = 1;
objectA.b = @"sss";
複製程式碼
一般時候,這樣寫 Dart 程式碼
ObjCContext context = ObjCContext();
NativeObject objectA = context.classFromString('ClassA').invoke(method: 'new');
objectA.invoke(method: 'setA:', args: [1]);
objectA.invoke(method: 'setB:', args: ['sss']);
複製程式碼
也可以從JSON中生成
ObjCContext context = ObjCContext();
NativeObject objectA = context.newNativeObjectFromJSON({'a':1,'b':'sss'}, 'ClassA');
複製程式碼
Dart 呼叫 Java
JAVAContext
僅在安卓系統中會被實際執行. JAVAContext
擁有上述 ObjCContext
Dart調ObjC
的全部特性.
- 支援上下文呼叫
- 支援鏈式呼叫
- 支援用JSON中例項化物件
另外,額外支援了從構造器中例項化一個物件
4. 支援快捷使用構造器例項化物件
Java程式碼
String platform = new String("android");
複製程式碼
Dart 程式碼
NativeObject version = context
.newJavaObjectFromConstructor('java.lang.String', ["android "])
複製程式碼
快捷組織雙端程式碼
提供了一個快捷的方法來 初始化和執行 context.
static Future<Object> traversingNative(ObjCContextBuilder(ObjCContext objcContext), JAVAContextBuilder(JAVAContext javaContext)) async {
NativeContext nativeContext;
if (Platform.isIOS) {
nativeContext = ObjCContext();
ObjCContextBuilder(nativeContext);
} else if (Platform.isAndroid) {
nativeContext = JAVAContext();
JAVAContextBuilder(nativeContext);
}
return executeNativeContext(nativeContext);
}
複製程式碼
可以快速書寫兩端的原生呼叫
platformVersion = await Dna.traversingNative((ObjCContext context) {
NativeObject version = context.classFromString('UIDevice').invoke(method: 'currentDevice').invoke(method: 'systemVersion');
version = context.classFromString("NSString").invoke(method: 'stringWithString:', args: ['iOS-']).invoke(method: 'stringByAppendingString:', args: [version]);
context.returnVar = version; // 該句可省略
}, (JAVAContext context) {
NativeObject versionId = context.newJavaObjectFromConstructor('com.example.dna_example.DnaTest', null).invoke(method: 'getDnaVersion').invoke(method: 'getVersion');
NativeObject version = context.newJavaObjectFromConstructor('java.lang.String', ["android "]).invoke(method: "concat", args: [versionId]);
context.returnVar = version; // 該句可省略
});
複製程式碼
dna 原理簡介
核心實現
dna
並不涉及dart物件到Native物件的轉換
,也不關心 Native物件的生命週期
,而是著重與描述原生方法呼叫的上下文,在 context execute
時通過 channel
呼叫一次原生方法,把呼叫棧以 JSON
的形式傳過去供原生動態解析呼叫。
如前文的中 dart 程式碼
ObjCContext context = ObjCContext();
NativeObject version = context.classFromString('UIDevice').invoke(method: 'currentDevice').invoke(method: 'systemVersion');
version = context.classFromString("NSString").invoke(method: 'stringWithString:', args: ['iOS-']).invoke(method: 'stringByAppendingString:', args: [version]);
context.returnVar = version; // 可省略設定最終返回值, 參考3
// 直接獲得原生執行結果
var versionString = await context.execute();
複製程式碼
NativeContext的execute()
方法,實際呼叫了
static Future<Object> executeNativeContext(NativeContext context) async {
return await _channel.invokeMethod('executeNativeContext', context.toJSON());
}
複製程式碼
在 原生的 executeNativeContext
對應執行的方法中,接收到的 JSON
是這樣的
{
"_objectJSONWrappers": [],
"returnVar": {
"_objectId": "_objectId_WyWRIsLl"
},
"_invocationNodes": [{
"returnVar": {
"_objectId": "_objectId_KNWtiPuM"
},
"object": {
"_objectId": "_objectId_qyfACNGb",
"clsName": "UIDevice"
},
"method": "currentDevice"
}, {
"returnVar": {
"_objectId": "_objectId_haPktBlL"
},
"object": {
"_objectId": "_objectId_KNWtiPuM"
},
"method": "systemVersion"
}, {
"object": {
"_objectId": "_objectId_UAUcgnOD",
"clsName": "NSString"
},
"method": "stringWithString:",
"args": ["iOS-"],
"returnVar": {
"_objectId": "_objectId_UiCMaHAN"
}
}, {
"object": {
"_objectId": "_objectId_UiCMaHAN"
},
"method": "stringByAppendingString:",
"args": [{
"_objectId": "_objectId_haPktBlL"
}],
"returnVar": {
"_objectId": "_objectId_WyWRIsLl"
}
}]
}
複製程式碼
我們在 Native
維護了一個 objectsInContextMap
, 以objectId
為鍵,以 Native物件
為值。
_invocationNodes
便是方法的呼叫上下文, 看單個
這裡會動態呼叫 [UIDevice currentDevice]
, 返回物件以 returnVar中儲存的"_objectId_KNWtiPuM"
為鍵放到 objectsInContextMap
裡
{
"returnVar": {
"_objectId": "_objectId_KNWtiPuM"
},
"object": {
"_objectId": "_objectId_qyfACNGb",
"clsName": "UIDevice"
},
"method": "currentDevice"
},
複製程式碼
這裡 呼叫方法的物件的objectId
是 "_objectId_KNWtiPuM"
,是上一個方法的返回值,從objectsInContextMap
中取出,繼續動態呼叫,以 returnVar的object_id為鍵
儲存新的返回值。
{
"returnVar": {
"_objectId": "_objectId_haPktBlL"
},
"object": {
"_objectId": "_objectId_KNWtiPuM" // 會在objectsInContextMap找到中真正的物件
},
"method": "systemVersion"
}
複製程式碼
方法有引數時,支援自動裝包和解包的,如 int<->NSNumber..
, 如果引數是非 channel
規定的15種基本型別,是NativeObject
, 我們會把物件從 objectsInContextMap
中找出,放到實際的引數列表裡
{
"object": {
"_objectId": "_objectId_UiCMaHAN"
},
"method": "stringByAppendingString:",
"args": [{
"_objectId": "_objectId_haPktBlL" // 會在objectsInContextMap找到中真正的物件
}],
"returnVar": {
"_objectId": "_objectId_WyWRIsLl"
}
複製程式碼
...
如果設定了最終的returnVar
, 將把該 returnVar objectId
對應的物件從 objectsInContextMap
中找出來,作為 channel的返回值
回撥回去。如果沒有設定,取最後一個 invocation
的返回值(如果有)。
* Android 實現細節
動態呼叫
Android實現主要是基於反射,通過 dna 傳遞過來的節點資訊呼叫相關方法。 Android流程圖
大致流程如上圖, 在 flutter 側通過鏈式呼叫生成對應的 “Invoke Nodes“, 通過對 ”Invoke Nodes“ 的解析,會生成相應的反射事件。
例如,當flutter端進行方法呼叫時:
NativeObject versionId = context
.newJavaObjectFromConstructor('me.ele.dna_example.DnaTest', null)
.invoke(method: 'getDnaVersion');
複製程式碼
我們在內部會將這些鏈路生成相應的結構體通過統一 channel 的方式傳入原生端, 之後根據節點資訊進行原生端的反射呼叫。 在節點中儲存有方法所在類的類名,方法名,以及引數型別等相關資訊。我們可以基於此通過反射,獲取該類名中所有相同方法名的方法,然後比對引數型別,獲取到目標方法,從而達到過載的實現。 方法呼叫獲取到的結果會回傳回去,作為鏈式呼叫下一個節點的呼叫者進行使用,最後獲取到的結果,會回傳給 flutter 端。
繞過混淆
難點
Dna做到這裡還有一個難點需要攻克,就是如何繞過混淆。Release版本都會對程式碼進行混淆,原有的類,方法,變數都會被重新命名。上文中,Dna實現原理就是從flutter端傳遞類名和方法資訊到Android native端,通過反射進行方法呼叫,Release版本在編譯中,類名和方法名會被混淆,那麼方法就會無法找到。 如果無法解決混淆這個問題,那麼Dna就只能停留在debug階段,無法真正上線使用。
方案
我們通常會通過自定義混淆規則,去指定一些必要的方法不被混淆,但是在這裡是不適用的。原因如下: 1.我們不能讓使用者通過自定義混淆規則,來指定本地方法不被混淆。這個會損害程式碼的安全性,而且操作過於複雜。 2.自定義混淆規則通常只能避免方法名不被混淆,卻無法影響到引數,除非將引數的類也進行反混淆。Dna通過引數型別來進行過載功能的實現,因此這個方案不被接受。 我們想要的方案應當具有以下特性: • 使用簡單,避免自定義混淆規則的配置 • 安全,低侵入性 針對上述要求,我們提出了幾種方案:
- 通過 mapping 反連結來實現
- 通過將整個呼叫鏈封裝成協議傳到 Native 層,然後通過動態生成代理程式碼的方式來將呼叫鏈封裝成方法體
- 通過註解的方式,在編譯期生成每個呼叫方法的代理方法
目前我們使用方案三進行操作,它的顆粒度更細,更利於複用。 混淆的操作是針對.classes檔案,它的執行在javac編譯之後。因此我們在編譯期間,對程式碼進行掃描,生成方法代理檔案,將目標方法的資訊儲存起來,然後進行輸出。在執行時,我們查詢到代理檔案,通過比對其中的方法資訊獲取到代理方法,通過代理方法執行我們想要執行的目標方法。具體實現方式,我們需要通過APT(Annotation Processing Tool 註解處理器)進行實現。
方案流程實現
下面,我們舉一個?,來說明具體的實現。 我們想要呼叫DnaVersion類中的getVersion方法,首先我們為它加上註解。
@DnaMethod
public String getVersion() {
return android.os.Build.VERSION.RELEASE;
}
複製程式碼
接下來,在DnaProcessor中,Dna通過繼承AbstractProcessor方法,對程式碼進行掃描,讀取DnaMethod所註解的方法:getVersion(),並獲取它的方法資訊,生成代理方法。 編譯期間,Dna會在DnaVersion類同包名下生成一個Dna_Class_Proxy的代理類,並在其中生成getVersion的代理方法,代理方法名是類名_方法的格式。這裡程式碼生成是通過開源庫JavaPoet實現的。
@DnaParamFieldList(
params = {},
owner = "me.ele.dna_example.DnaVersion",
returnType = "java.lang.String"
)
public static Object DnaVersion_getVersion(DnaVersion owner) {
return owner.getVersion();
}
複製程式碼
自動生成的 getVersion 的代理方法 從代理方法中可以看出,它會傳入呼叫主體,來進行實際的方法呼叫。代理方法通過DnaParamFieldList註解配置了三個引數。params用於儲存引數的相關資訊,owner 用於儲存類名,returnType 用於儲存返回的物件資訊。 在執行時,Dna會通過反射找到 Dna_Class_Proxy 檔案中的 DnaVersion_getVersion 方法,通過DnaParamFieldList中的引數配置來確定這是否是目標方法,然後通過執行代理方法來達到 getVersion 方法的實現。 我們會對配置自定義混淆規則來避免代理類的混淆:
-keep class **.Dna_Class_Proxy { *; }
複製程式碼
混淆後的代理檔案:
public class Dna_Class_Proxy {
@a(a = {}, b = "me.ele.dna_example.DnaVersion")
public static b Dna_Constructor_ProxyDnaVersion() {
return new b();
}
}
複製程式碼
可以看到,Dna不會影響到原有程式碼的混淆,而是通過代理類以及註解儲存的資訊,定位到我們的目標方法。從而達到了在release 混淆包中,通過方法名呼叫目標方法的功能。 如果想要使用Dna,那麼需要在原生程式碼上註解DnaMethod,而在Android Framework下的程式碼是預設不混淆的,同時也無法進行註解。Dna會對Framework下的程式碼進行反射呼叫,而不是走代理方法呼叫,從而達到了對於Framework程式碼的適配。
*iOS 實現細節
iOS 中不需要程式碼混淆,可通過豐富的 runtime 介面呼叫任意類的方法:
-
使用 NSClassFromString 動態獲得類物件;
-
使用 NSSelectorFromString 獲得要呼叫方法的 selector;
-
使用 NSInvocation 動態為某個物件呼叫特定方法,引數的不定陣列會根據 selector 的type encoding 為物件依次嘗試解包,轉為非物件型別;也會為返回值嘗試裝包 轉為物件型別。
上下文呼叫細節
-
建立 objectsInContextMap,存放 context json 中 所有 object_id 和 native 實際物件的對映關係;
-
順序解析context json 中 invocationNodes 陣列中的 invocationNode 為 NSInvocation 物件,並呼叫; 單個 NSInvocation 物件呼叫產生的返回值,將以 invocationNode 中約定的 object_id 放到 objectsInContextMap 中,下一個 invocation 的呼叫者或者引數,可能會從之前方法呼叫產生的物件以Object_id為鍵在 objectsInContextMap 中取出來。
-
為 dna channel 返回最終的返回值.
謝謝觀看!如有錯誤,請指出!另外,歡迎吐槽!
dna 地址
github.com/Assuner-Lee… 後續會遷移到 eleme 賬號下