dna --- 一個 dart 到 native 的超級通道

做者簡介前端

雍光Assuner、菜嘰、執卿、澤卦;蜂鳥大前端java

前言

    Flutter 做爲當下最火的跨平臺技術,提供了媲美原生性能的 app 使用體驗。Flutter 相比 RN 還自建了本身的 RenderObject 層和 Rendering 實現,「幾乎」 完全解決了多端一致性問題,讓 dart 代碼真正有效的落實 「一處編寫,到處運行」,接近雙倍的提高了開發者們的搬磚效率。前面爲何說 "幾乎",雖然 Flutter 爲咱們提供了一種快捷構建用戶界面和交互的開發方案,但涉及到平臺 native 能力的使用,如推送、定位、藍牙等,也只能 "曲線救國",藉助 Channel 實現, 這就免不了咱們要分別寫一部分 native 代碼 和 dart 代碼作 「技術對接」,略略破壞了這 「完美」 的跨平臺一致性。另外,大部分公司的 app 都不是徹底從新創建起來的 Flutter app,更多狀況下,Flutter 開發的頁面及業務最終會以編譯產物做爲一個模塊集成到主工程。主工程原先已經有了大量優秀的工具或業務相關庫,如多是功能強大、作了大量優化的網絡庫,也多是一個處處使用的本地緩存庫,那麼無疑,須要使用的 native 能力範圍相比平臺自身的能力範圍擴大了很多,channel 的定義和使用變得更加高頻。android

    不少開發者都使用過 channel, 尤爲是 dart 調用 native 代碼的 Method Channel。 在 dart 側,咱們能夠實例化一個 Channel 對象:git

static const MethodChannel examleChannel = const MethodChannel('ExamplePlugin');
複製代碼

使用該 Channel 調用原生方法 :github

final String version = await examleChannel.invokeMethod('nativeMethodA', {"a":1, "b": "abc"});
複製代碼

在 iOS 平臺,須要編寫 ObjC 代碼:json

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 的使用 有如下缺點:緩存

  1. Channel 的名字、調用的方法名是字符串硬編碼的;
  2. channel 只能單次總體調用字符串匹配的代碼塊,參數限定是單個對象;不能調用 native 類已存在的方法,更不能組合調用若干個 native 方法.
  3. 在native 字符串匹配的代碼塊,仍然須要手動對應取出參數,供真正關鍵方法調用,再把返回值封裝返回給dart.
  4. 定義一個Channel 調用 native 方法, 須要維護 dart、ObjC、Java 三方代碼
  5. flutter 調試時,native 代碼是不支持熱加載的,修改 native 代碼須要工程重跑;
  6. channel 調用可能涵蓋了諸多細碎的原生能力,native 代碼處理的 method 不宜過多,且通常會依賴三方庫;多個channel 的維護是分散的;

繼續分析,咱們得出認知:安全

  1. 跨平臺,定位一個方法的硬編碼是絕對免不了的;
  2. native 裏字符串匹配的代碼塊裏,真正的關鍵方法調用是不可或缺的;
  3. 方法調用必須支持可變參數

爲此,咱們實現了一個 dart 到 native 的超級通道 --- dna,試圖解決 Channel 的諸多使用和維護上的缺點,主要有如下能力和特性:bash

  1. 使用 dart代碼 調用 native 任意類的任意方法;意味着channel 的 native代碼 能夠寫在 dart 源文件中;
  2. 能夠組合調用多個 native 方法肯定返回值,支持上下文調用,鏈式調用;
  3. 調用 native 方法的參數直接順序放到不定長度數組,native 自動順序爲參數解包調用;
  4. 支持 native 代碼的 熱加載,不中斷的開發體驗.
  5. 更加簡單的代碼維護.

dna 的使用

dnaDart代碼中:

  • 定義了 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.returnVarcontext 最終執行完畢返回值的標記

  1. 設定context.returnVar: 返回該NativeObject對應的Native變量
  2. 不設定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經過參數類型來進行重載功能的實現,所以這個方案不被接受。 咱們想要的方案應當具備如下特性: • 使用簡單,避免自定義混淆規則的配置 • 安全,低侵入性 針對上述要求,咱們提出了幾種方案:

  1. 經過 mapping 反連接來實現
  2. 經過將整個調用鏈封裝成協議傳到 Native 層,而後經過動態生成代理代碼的方式來將調用鏈封裝成方法體
  3. 經過註解的方式,在編譯期生成每一個調用方法的代理方法

目前咱們使用方案三進行操做,它的顆粒度更細,更利於複用。 混淆的操做是針對.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 接口調用任意類的方法:

  1. 使用 NSClassFromString 動態得到類對象;

  2. 使用 NSSelectorFromString 得到要調用方法的 selector;

  3. 使用 NSInvocation 動態爲某個對象調用特定方法,參數的不定數組會根據 selector 的type encoding 爲對象依次嘗試解包,轉爲非對象類型;也會爲返回值嘗試裝包 轉爲對象類型。

上下文調用細節

  1. 創建 objectsInContextMap,存放 context json 中 全部 object_id 和 native 實際對象的映射關係;

  2. 順序解析context json 中 invocationNodes 數組中的 invocationNode 爲 NSInvocation 對象,並調用; 單個 NSInvocation 對象調用產生的返回值,將以 invocationNode 中約定的 object_id 放到 objectsInContextMap 中,下一個 invocation 的調用者或者參數,可能會從以前方法調用產生的對象以Object_id爲鍵在 objectsInContextMap 中取出來。

  3. 爲 dna channel 返回最終的返回值.

謝謝觀看!若有錯誤,請指出!另外,歡迎吐槽!

dna 地址

github.com/Assuner-Lee… 後續會遷移到 eleme 帳號下

相關文章
相關標籤/搜索