作者简介
雍光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 任意类的任意方法;意味着要调用native代码 可以写在 dart 源文件中,同时大大减少channel的数量和创建成本;
- 可以组合调用多个 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 账号下