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 的使用

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