dna --- 一个 dart 到 native 的超级通道

avatar

作者简介

雍光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 任意类的任意方法;意味着要调用native代码 可以写在 dart 源文件中,同时大大减少channel的数量和创建成本;
  2. 可以组合调用多个 native 方法确定返回值,支持上下文调用,链式调用;
  3. 调用 native 方法的参数直接顺序放到不定长度数组,native 自动顺序为参数解包调用;
  4. 支持 native 代码的 热加载,不中断的开发体验.
  5. 更加简单的代码维护.

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 最终执行完毕返回值的标记

  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 账号下