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

餓了麼物流技術團隊發表於2020-03-04

作者簡介

雍光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 的使用 有以下缺點:

  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 的諸多使用和維護上的缺點,主要有以下能力和特性:

  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流程圖

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

大致流程如上圖, 在 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 註解處理器)進行實現。

dna --- 一個 dart 到 native 的超級通道
方案流程

實現

下面,我們舉一個?,來說明具體的實現。 我們想要呼叫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 賬號下

相關文章