Flutter 是谷歌推出的移动 UI 框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面,被越来越多的开发者选择和使用。拍乐云也提供了功能强大的 Pano Flutter SDK,性能稳定且易用,覆盖语音通话、视频通话、互动白板、互动直播、云端录制等各种功能。在之前的一篇《Pano Flutter SDK 全新发布》中,我们给大家介绍了SDK的详细接入流程,今天将继续聊聊我们 Pano Flutter SDK 的设计思路与实践经验。
#1
总体结构

SDK 分为三层结构,底层为 Pano 原生 SDK(iOS&Android)。基于原生SDK 之上为桥接层,由于 Flutter 与 RN 中与原生层通信均为异步通信,且需使用特定的通信方式(Flutter 使用平台通道方案,RN 则使用原生模块方案),所以需要将跨平台调用进行转换才能调用原生 SDK 方法。因此桥接层将分为两个部分,原生 SDK 桥接与跨平台(Flutter&RN)桥接,以达到最大化代码复用的目的,将原生 SDK 接口二次封装成通用异步接口,在其上分别对接 Flutter 和 RN 的通信接口。SDK 最顶层则为跨平台层,对接原生层通信接口封装出 Flutter 或 RN 平台的功能接口。
虽然最终的结构比较简洁明了,但是由于 Flutter 或 RN 对于视图更新机制与原生开发存在较大差异,以及跨平台层与原生层数据结构的不同等问题,导致 SDK 的设计与实现中存在许多涉及数据转换、对象映射、内存管理等难点或坑点,接下来将结合 SDK 的设计思路与实践经验,针对其中几个典型的问题谈谈解决方案或需要注意的地方。
#2
工作流程
Pano Flutter SDK 提供的 API 基本上与原生 SDK 保持一一对应的关系,以便开发人员可以轻松的将对接原生 SDK 开发经验应用到 Flutter 中。但由于 Flutter 特殊的平台通道(Platform Channel)方案以及视图更新机制,所以并不是简单的将原生 SDK 接口进行透传封装,SDK 的调用流程如下图所示:

建议:当原生层接收到MethodChannel的方法调用时(例如:iOS为 -[FlutterPlugin handleMethodCall:result:]),采用反射调用(例如:iOS中使用NSSelectorFromString获取selector,然后通过[NSObject performSelector:withObject:]调用)Native SDK Bridge方法,这样可以尽量将Flutter的逻辑与原生桥接层逻辑隔离,一方面做薄对接Flutter层逻辑,另一方面将需要经常跟随原生SDK变动的原生桥接层逻辑与其它跨平台框架(如RN)进行复用,减少维护成本。
注意:Flutter 中没有现成的二进制数据类型,通常采用 Uint8List来代替,但通过平台通道转换后,在iOS端会转换成FlutterStandardTypedData类型,该类型不能自动转换为NSData类型,需要通过其属性data来获取实际的NSData对象。但在从原生层调用Flutter层时,可以直接传递NSData对象,其将会在 Flutter 层被自动转换为Uint8List。
Flutter 中平台通道实际上是将传递的数据编码成消息的形式,跨线程发送到该应用所在的宿主原生层。并且 Native SDK Bridge 对接原生 SDK,将原生 SDK 方法实行完毕后的返回值通过 callback 返回时,也是将数据编码成消息通过同样方式原路返回给 Flutter 层。整个过程的消息和响应是异步的,这也就是 Flutter 层接口都设计成异步接口的原因。
注意:MethodChannel类型中,调用原生方法使用 Future<T?> invokeMethod<T>(String method, [ dynamic arguments ]),对于SDK返回Flutter支持的基本类型数据时,直接调用没有任何问题,例如当获取SDK版本号接口返回String类型,则Flutter层接口可以实现为:static Future<String> getSdkVersion() {
// iOS中NSString和Andorid中java.lang.String都可以自动转换为Flutter的String类型
return _methodChannel.invokeMethod('getSdkVersion');
}但当返回非基本类型时,返回值就需要进行转换,例如开启音频接口由于可能有多种结果,所以返回值是枚举类型 ResultCode,如果直接按照以下写法实现将会报错:Future<ResultCode> startAudio() {
return _methodChannel.invokeMethod('startAudio');// 错误:返回值为int不会自动转换Flutter的枚举类型
}需要增加转换逻辑,例如: Future<ResultCode> startAudio() {
return _methodChannel.invokeMethod('startAudio').then((value) {
return ResultCodeConverter.fromValue(value).e as T; // ResultCodeConverter为将int转换ResultCode的工具类
});
}
建议:由于 SDK 中存在大量的返回 ResultCode的方法,在每个接口实现处都增加转换代码繁琐且冗余,所以我们对于这种情况可以提取一个公共模板方法,能很大程度提升代码简洁度,例如:Future<T>_invokeMethod<T>(String method, [Map<String, dynamic> arguments]) {
if (T == ResultCode) { // 判断当前范型为ResultCode时,增加转换逻辑
return _methodChannel.invokeMethod(method, arguments).then((value) {
return ResultCodeConverter.fromValue(value).e as T;
});
} else { // 其他可以自动转换的情况则返回调用结果
return _methodChannel.invokeMethod(method, arguments);
}
}
-
原生层调用 setStreamHandler(iOS为-[FlutterEventChannel setStreamHandler:])注册 Handler 实现; -
EventChannel 初始化完成后,通过StreamHandler的onListen(iOS为 -[FlutterStreamHandler onListenWithArguments:eventSink:])回调接口获取eventSink引用并保存; -
Flutter 层调用 EventChannel 的 receiveBroadcastStream 注册监听; -
原生层通过调用 eventSink 发送事件消息。
建议:EventChannel 由于是数据流通信,跟 MethodChannel 不同之处在于没有封装出针对方法回调的模型,但目前 SDK 中原生层向Flutter 层均为方法回调,所以我们将回调数据组装成特定格式的键值对,如: {
"methodName": xxxx, // 回调方法名
"data": [xxxx,xxxx...] // 回调参数列表
}然后在 Flutter 层进行统一解析处理: void setEventHandler(RtcEngineEventHandler handler) {
_handler = handler;
...
_eventChannel.receiveBroadcastStream().listen((event) {
final eventMap = Map<dynamic, dynamic>.from(event);
final methodName = eventMap['methodName'] as String;
final data = List<dynamic>.from(eventMap['data']);
_handler?.process(methodName, data);
});
}
#3
设置原生视图
那如何将生成的原生视图对象传递给原生层 SDK?在 Flutter 创建原生视图后,会返回视图对应唯一的 id,所以最直观的方法就是在 id 返回后,分别在原生层与 Flutter 层生成对应的 MethodChannel,组成键值对缓存起来,在调用时通过 id 查找 MethodChannel,然后通过 MethodChannel 传递方法调用消息。但这样做有两个明显缺陷:
MethodChannel 没有与 Widget 直接关联,在 Widget 销毁时需要手动清除键值对中的 MethodChannel;
采用 id 作为原生视图的标识,由于缺少有效性检查,可能导致调用到无效 MethodChannel 抛出异常。
并且通常原生SDK方法中是需要原生视图作为参数传入,但由于只能通过与视图对应的MethodChannel才能在原生层访问到对应的原生视图对象,导致没法直接在Flutter层设计出类似原生SDK的方法。
建议:Pano Flutter SDK中我们为了尽量保持与原生SDK的接口一致性,采取了一种曲线救国的方案。在创建渲染视图
RtcSurfaceView(StatefulWidget)后,回调返回保存了MethodChannel的ViewModel对象:class RtcSurfaceViewModel {
final MethodChannel _methodChannel;
Future<T> invokeMethod<T>(String method, [Map<String, dynamic> arguments]) {
if (T == ResultCode) {
return _methodChannel.invokeMethod(method, arguments).then((value) {
return ResultCodeConverter.fromValue(value).e as T;
});
} else {
return _methodChannel.invokeMethod(method, arguments);
}
}
RtcSurfaceViewModel(this._methodChannel);
}然后按照需要原生视图的SDK方法,定义出对应的Flutter层接口,接收ViewModel作为参数,方法实现调用ViewModel的MethodChannel传递方法消息,例如开启视频时调用startVideo接口定义如下: Future<ResultCode> startVideo(RtcSurfaceViewModel viewModel,
{RtcRenderConfig config}) {
config ??= RtcRenderConfig();
return viewModel.invokeMethod('startVideo', {'config': config.toJson()});
}在原生层视图对应的MethodChannel接收到方法调用,通过原生层内部缓存的engine对象,调用对应的SDK方法(如startVideo),传入原生层视图完成接口调用。 这样做,一方面让MethodChannel与Widget关联,另一方面在接口调用上也使用ViewModel对象保证了传值的有效性。并且接口上也基本与原生SDK保持了一致性,降低了对接SDK的开发人员的理解成本,也兼顾了代码的维护成本。
#4
结语


