Flutter 重构去哪儿QTalk

【Flutter 重构去哪儿QTalk】QTalk 是去哪儿网内部的一个 IM 沟通工具,同时集成了很多内部的系统,比如 OA 审批,门禁打卡,请假审批,预定会议室,驼圈(驼厂朋友圈)等功能;方便内部办公沟通、交流的同时,也为无纸化办公,流程审批等提供了支持。
一、原有产品框架 Flutter 重构去哪儿QTalk
文章图片

在决定 Flutter 重构之前,我们盘点了现有的 QTalk 工程架构的问题,主要表现为:

  • 各端差异性大:Android、iOS 以及 QT 开发框架 (一个 C++桌面端跨平台解决方案) 三端逻辑代码差异大,代表性的有 Web 加载逻辑,移动端 React Native 页面加载逻辑等,排查问题根源时 3 端都会有不同的情况,解决方案也不相同。
  • 研发效率低:需要维护 3 套代码,在现有人力资源下,保证功能完整按时上线已经比较吃紧,还需要及时解决线上各种问题。
  • 架构层次较差:各端在架构设计上分层各不相同且不清晰,数据流推送方向复杂是主要的两个问题。
  • 原生代码复杂度高:蓝色区域代表了使用原生平台能力的代码,它们在各平台相互之间不可复用且容易在版本升级中出现适配问题,在实现需求的时候容易出现各端表现不一致返工的情况。
为了降低开发成本,提高开发效率,尽可能的将代码在各个平台进行复用,于是我们决定重构 QTalk。
二、为什么要选 Flutter Flutter 的优势是渲染性能高与抹平了各端差异,根源在于 Flutter 采用了自主渲染引擎把控了渲染流程,保证了效率,相当于一个应用跑在了游戏引擎里。
以往就有人希望用 cocos2d 或者 unity 来制作应用,达到跨端一致与节省工时的目的,但是游戏引擎渲染是逐帧渲染,原生(iOS、Android)渲染方式是业务驱动,即有模型改动的情况下才渲染,相比之下游戏的渲染方式对性能消耗过大,包大小多倍增加,而 Flutter 通过对渲染流程的改造基本解决了这些问题,Flutter 在渲染时与原生渲染一样,都会产生渲染树,只有渲染树发生改变的时候,重绘制才会启动,而绘制一般也只发生在有改变的区域。
因 QTalk 开发资源紧缺,所以需要一个跨平台框架来提升效率。同时 QTalk 也是公司内平时沟通的主要方式,页面流畅性需要有保障。QTalk 常用的长短连接、长列表、Web 等,Flutter 官方和社区也有一个良好的支持。混合开发在 Flutter 2.0 中也得到官方引擎的支持,所以我们决定使用 Flutter 来开发新版 QTalk。
三、Flutter 版 QTalk 框架 Flutter 重构去哪儿QTalk
文章图片

可以看到,数据层来源于推送或 http 或者长连接,处理完成后变成 Flutter 中的 IMMessage 类型对象,在各个模块中处理数据库存储与交互逻辑层将数据处理完毕之后可以使用订阅者模式分发到各个界面使用,而上层的UI层使用Flutter进行开发,屏蔽了各层的差异,达到了最大。
相比于旧的架构,新的架构带来了如下的优势:
  • 业务表现层基本抹平了各端差异,我们用一套代码实现了5端的UI ( Android、iOS、Mac、Windows、Linux), UI 整体代码复用率达到 80% 以上,避免了原有各端的表现差异带来的UI适配额外工作量。
  • 逻辑与数据层除了个别能力(例如推送)必须使用原生代码,其余功能都 Dart 的统一实现,在维护和做新需求时工时减少约 50%。
  • 在整个 APP 数据流动过程中,所有关于界面的数据都使用单向数据流,同时合理分层,降低了应用复杂度,所有组件都不需要保存状态,只负责根据数据源渲染。
四、遇到的问题 4.1 混合栈 QT 中大部分页面都是可以使用 Flutter 重构的 IM 业务页面,但是另外一些页面面临更新频繁,维护方不合适放在 IM 团队的问题,例如 QT 发现页,使用 ReactNative 开发,QT 只作为入口展示,所以我们需要一套混合 ReactNative 页面与 Flutter 的技术方案,现在 Flutter 的主流混合技术栈有 2 种:
  1. Flutterboost 单引擎实现混合页面开发。
  2. Flutter2.0 中官方发布的 FlutterEngineGroup 使用多引擎解决问题,优化了内存占用和数据共享方式。
我们在 QT 中对 2 种混合方式都进行了尝试,最终发现的它们各有利弊,如下表:
方案 Flutterboost FlutterEngineGroup
优势 ioslate 共享内存,页面间数据传递方便 官方支持,代码侵入小,性能几乎不受影响
劣势 升级成本大,增加一个页面消耗比较大,iOS 内存消耗大(新版有改善),工程结构需要根据 boost 大改 ioslate 层不能共享内存,直接互相调用比较麻烦
不过,我们并没有使用上面的两种方案,而是利用 Flutter2.0 混合视图的新特性,走自己的第三条路线:使用 PlatformView 的把 React Native 页面与 Flutter 页面混合起来,使用 Flutter 的路由能力支持这个页面跳转。
Flutter 重构去哪儿QTalk
文章图片

这样做的好处是,在移动端和 Flutter 视角里,ReactNative 页面的生命周期都耦合在了 ReactNative 页面内部,使用的时候可以当做一个单纯的 view 看待,所以我们可以在不介入 Native 页面生命周期的情况下,只把 Native 端当做一个桥来传递 Flutter 与 ReactNative 页面参数,React Native 页面原本与 Native 的交互方式不变,只加了Native与 Flutter 之间的 PlatformChannel 参数传递。
Native与 Flutter通讯使用的是Channel,因此我们进行如下的封装:
//Flutter 调用原生 const MethodChannel _channel = const MethodChannel('com.mqunar.flutterQTalk/rn_bridge'); //注册channel _channel.invokeMapMethod('onWorkbenchShow', {}); //原生调用Flutter _channel.setMethodCallHandler((MethodCall call) async { var classAndMethod = call.method.split('.'); var className = classAndMethod.first; if (mRnBridgeHandlers[className] == null) { throw Exception('not found method response'); }RNBridgeModule bridgeModule = mRnBridgeHandlers[className]!; return bridgeModule.handleBridge(call); });

在 Flutter 端,使用 Native 传递过来的 React Native页面 View 与 FlutterView 混合生成一个新的页面,这个页面可以接受 Flutter 栈的调用,与Flutter 其他页面互相传参与切换都与纯 Flutter 页面没有区别,这样在路由层面规避了各个端互相调用的适配问题。对应的获取 React Native页面的代码如下:
Widget getReactRootView( ReactNativePageState state, Dispatch dispatch, ViewService viewService) { //安卓与iOS分别处理 if (defaultTargetPlatform == TargetPlatform.android) { return PlatformViewLink( viewType: VIEW_TYPE, surfaceFactory: (BuildContext context, PlatformViewController controller) { return AndroidViewSurface( controller: controller as AndroidViewController, gestureRecognizers: const >{}, hitTestBehavior: PlatformViewHitTestBehavior.opaque, ); }, onCreatePlatformView: (PlatformViewCreationParams params) { return PlatformViewsService.initSurfaceAndroidView( id: params.id, viewType: VIEW_TYPE, layoutDirection: TextDirection.ltr, creationParams: state.params, creationParamsCodec: StandardMessageCodec(), ) ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) ..create(); }, ); } else if (defaultTargetPlatform == TargetPlatform.iOS) { return UiKitView( viewType: VIEW_TYPE, creationParams: state.params, creationParamsCodec: const StandardMessageCodec()); } else { return Text("Placeholder"); } }

这样,我们只增加了很少的代码,就解决了 Flutter 混合栈低效及难开发的问题。
4.2 数据传递 Flutter 在初期尝试了 provider,BLoC,mobx 等数据流管理方案,它们的优缺点我们列了一个表。
provider BLoC mobx redux fish-redux
优势 性能高,官方支持 处理异步事件效率高,分层清晰 状态操作简单,代码少,容易上手 单数据流,视图和业务逻辑分离 redux 优点基础上,具有自动合并 reducer,隔离组件的功能,扩展性强
劣势 容易在 view 中写逻辑容易使 view 与model 产生耦合 状态共享时容易写出错误逻辑 数据合并效率低,过于自由的使用方式容易使代码耦合 1.redux store 的集中与页面组件分治之间的矛盾 2.reducer 需要手动合并 相对于 mobx 写起来繁琐一些
下面是一些具体的使用体验:
  • provider: provider是最初选择的数据管理方案,由官方提供,使用的时候 model 类需要继承 ChangeNotifier,使用 Consumer 包裹需要改变的组件,一个新开发 Flutter 的同学上手很难把页面逻辑与页面 UI 分开,导致耦合严重,需要制定代码规范,而且如果 Consumer 包裹范围过大,一不小心就会影响性能表现,造成不必要的卡顿。
  • Bloc:分离了逻辑与 UI,但是引入这个方案对代码的侵入比 provider 大,而在指定代码规范 StreamProvider 可以完全实现 Bloc 的功能,另外相对于 Redux 类型的管理方案,它没有合并到store的繁琐写法跟限制,同时也为共享数据或者多个数据同时影响同一个 view 时的混乱埋下伏笔,所以我们没有采用。
  • Mobx:Mobx 的优点表现为,不用在更新数据时写 notify 代码,但是它是双向数据绑定,自由度比较大,在没有代码规范的情况下,容易把 get 与 set 的动作顺序搞混,而且在性能层面根据我们的测试,在有大量数据改变的情况下,它的数据传递与合并会造成程序效率降低。
  • Redux :Redux方案中使用纯函数 dispatcher 来修改 state,相对于双向绑定的方式它会分离使用者更新数据与使用数据的操作,会有模板规范使用者,但是 combineReducers 这个操作会使页面复用变得困难,需要写很多的额外代码。
  • fish-redux :fish-redux是由 redux 定制修改版本,逻辑的隔离粒度更细,自动实现了合并reducer,解耦页面的功能,另外它也存在一些问题,比如全局变量使用会耦合所有用到的页面,写法繁琐等。
我们开始希望使用 fish-redux 全局 store 来充当长链接和 http 接口的回调的触发器,使用过程里发现 fish-redux 的 globalstore 需要先在 route 中与将要使用的 page 绑定,每个使用到global属性的页面也需要增加属性接受绑定,这样与页面分治的目的相悖了,重用这个页面的时候也会因为跟global的关系造成额外的开发量。依据以上的状态管理框架的使用体验,我们最终决定使用了两种方式来管理与传递数据:Fish-redux和Eventbus。
Fish-redux Fish-redux 用在逻辑层 IMMessage module 对象逻辑构建与表现层中,使 QT 原有的多方向型庞杂的数据架构变得整齐划一便于梳理,各个页面层级开发时拆解为独立的 page,扩展可以使用connector 即插即用,协作开发时降低了因为人员变动造成代码在页面层面造成混乱的可能性。
Flutter 重构去哪儿QTalk
文章图片

如图,我们在编写代码时只需要关心的每一个页面内部的单向数据流,对页面数据合并没有感知,而每个页面由 5 个文件组成:Action、Effect、Reducer、State和View,把使用各种方式对数据做处理与页面刷新分割开来,从工程层面和页面层面都维护了代码的秩序。
Eventbus Eventbus的作用是用于数据库对象与 IMMessage module 对象,数据层与逻辑层沟通。通过事件总线来触发事件和监听事件,它是一种单例管理分发数据的模式,轻量级,全局可用,可以在没有渲染 context 对象参与的情况下传递数据,分治数据逻辑与业务。
Flutter 重构去哪儿QTalk
文章图片

4.3 ListView改造 Flutter 现在版本的 Listview 在生成每个 item 时,不会根据 model 预取高度,而是在渲染完成以后再统计 item 高,这样就造成了几个后果。
  • ListView 不支持按 index 跳转,在 item 不等高的情况下没有简单的方式直接跳转到对应 index。
  • 跳转不在屏幕内的位置时,ListView 因为还不知道这个位置是不是在可滑动范围内,所以只能先尝试跳转,如果最终的跳转位置大于可滑动范围,就会产生弹跳。
  • scrollToEnd 方法,如果 List 末尾 item 不在屏幕内,则按照屏幕内的item平均高度估计末尾index所在位置,滑动之后,如果最终滑动停留位置不在最后一个item上,还要进行二次甚至三次跳转。
我们解决的方案也很简单:引入 scrollable_positioned_list 控件,本质上是生成 2 个 ListView,一个 ListView 负责计算高度,一个 ListView 会真正渲染到界面上,跳转时先让第一个 List 跳转,算出最终的 index 高度,然后第二个 List 跳转精确的位置,而针对弹跳的问题,我们需要修改 ListView ,在跳转过程中发现有位移过大的情况,马上进行修正,示例代码如下:
void _jumpTo({@required int index, double offset}) { ... // 使用偏移量offset var jumpOffset = 0 + offset; controller.jumpTo(jumpOffset); // 渲染之后发现溢出,进行修正 WidgetsBinding.instance.addPostFrameCallback((ts) { var offset = min(jumpOffset, controller.position.maxScrollExtent); if (controller.offset != offset) { controller.jumpTo(offset); }); }

4.4 获取 iOS 键盘高度 iOS 键盘高度计算不准确,导致切换键盘与表情时高度不一致,使聊天界面抖动
原因:因为有些机型在 safeArea 的 bottom 高度不为 0,一般写法会直接将聊天页面写入一个 safeArea 中,而键盘弹出时 safearea 的 bottom 又会清0,导致键盘高度跳动。
解决方案:初始化 App 后,本地记录 safeArea 的bottom 高度,然后在聊天界面中去掉 safeArea 包装,使用本地记录的高度,给底部输入框增加高度避免与 iOS 导航栏重合。
4.5 混合项目断点调试 原因:Dart 与 Native 代码分别进行编译,在运行时只能 link 一方的代码,编译器无法解析另一方产生的库。
解决方案:首先在 Xcode 或者 Android Studio中,由 Native 端启动 App,然后打开编译 Dart 代码的ide或者终端,使用 flutter attach 命令连接你的 Dart 代码到运行中的应用,这时候就可以同时调试 Native 与 Dart 与代码了。
五、QT 桌面端遇到的问题 5.1 移动端界面的复用 之前提到过我们的数据管理方案可以使各个页面解耦,page 作为一个整体可以被其他组件复用,桌面端就是利用这种设计模式,只需要给移动端各个page 增加 connector 就可以把移动端 view 集成为一个桌面端主页面,对应的逻辑层只需要根据桌面端的特性做一部分适配,例如调用API不同,桌面端支持右键行为等。
Flutter 重构去哪儿QTalk
文章图片

图中 Page 与 Component 都是 fish-redux 中提供的基本逻辑与 UI 单元,它们可以任意的互相组合,它们满足了 QTalk 多端复用 UI 与逻辑的需求,也是选型的重要依据。
//各子页面适配器代码 SessionListComponent.component.dart SessionListState { .... } SessionListConnector { //被this的属性改变之前调用,这个组件的state来自上层组件的state的属性 get { return HomePCPage.scState } //自身属性发生改变以后调用,同步上层组件的state set { HomePCPage.scState = this.state; } } //桌面端主页合成代码 HomePCPage.page.dart HomePCPage { .... dependencies: //重载了+号用于增加子组件属性,返回一个带有connector的组件给上层page使用 slot:SessionListConnector() + SessionListComponent(), }

5.2 多 Window PC 端有很多原生平台相关能力 Flutter-desktop 尚未拥有,比如多窗口、录屏、web 使用、拖拽文件共享和menubar 配置等。
解决方案:引入 NativeShell 框架,采用多引擎方式解决 PC 端遇到的多窗口问题,改变工程结构,在 dart 启动 main 函数之前增加一个 rust 类来管理窗口,调用 rust 中的各平台系统库来把各种语言(c++ c# oc等)写成系统api统一成 rust 类型的文件,减少平台差异性。
Flutter 重构去哪儿QTalk
文章图片

适配 NativeShell 中也遇到过很多问题,列举 2 个例子:
打包脚本空安全报错 cargo 是 rust 包管理器,NativeShell 使用 cargo 为桌面端打包,NativeShell 默认打包脚本里不允许没有适配 null safety 的库加入工程,我们重新梳理了打包脚本并且在加入了在 Flutter 编译时非空判断,最终顺利在rust 环境里打出了 Mac 与 Windows 的包。
Mac 客户端打包问题 NativeShell 打包过程里,每个 Window 都会产生一个子工程,壳工程直接引用了子工程目录,最终的包里会含有大量中间产物,造成包体积特别大,我们改造了这个流程,只把子工程产生的 dll 与 framework 加入最终产物中,打出了正常大小的包。我们还与作者沟通,提出了 PR,最终这些代码和建议合并到了制作方打包工具当中。
5.3 多窗口造成主 isolate 指令排队 说明这个问题之前我们先了解一下 Flutter 的事件循环原理:Dart 应用中,有一个事件循环和 两个队列:事件队列(event queue )和 微任务队列(microtask queue)。
  • event queue: 包含了所有的外部事件:I/O、鼠标点击、绘制、定时器、Dart isolate 中的消息等等。
  • microtask queue:事件处理代码有时需要在当前 event 之后,且在下一个 event 之前做一些任务。
总的来说,event queue 包含了来自于 Dart 和系统的事件。当前,microtask queue 中仅仅包含了来自于 Dart 的事件。
Flutter 重构去哪儿QTalk
文章图片

当 main() 退出,event loop 开始工作。首先是执行所有 microtask,它实际上是一个 FIFO 队列。接着,它将取出并处理第一个 event queue 中的事件。接着,开始执行循环:执行所有 microtask,接着执行 event queue 中下一个事件。一旦两个 queue 都空了,也就是说没有事件了,就可能会被宿主(比如浏览器)处理了。
如果 event loop 正在执行 microtask 队列中的事件,那么 event queue 中的事件处理将被停止,这就意味着图像绘制、处理鼠标点击,处理 I/O 等等这些事件将无法执行,虽然你可以事先知道 task 执行的顺序,但是,你无法知道 event loop 什么时候从队列中取出任务。Dart 的事件处理系统是基于一个单线程循环模型,而不是基于时间系统。举个例子,当你创建一个延时任务,时间在一个你指定的时间入队。然而,在它前面的事件没有被处理完,它无法被处理。
PC 与移动端大部分业务逻辑可复用,但是仍然有少量渲染流程存在差异,最多遇到的情况是多个子窗口同时向主窗口发送消息,这些消息在主isolate中会被加入 event queue,消息量过大的话,就会使得主 isolate event queue 中事件过多,容易造成主isolate所在页面卡顿。
针对以上的情况,我们增加了一个分发层来解决这个问题,原有各个逻辑控件在处理完毕数据之后,向分发层发送通知,分发层会统计前一渲染帧向主isolate 的操作请求数量,如果超过了阈值就先加入命令队列中,等待下一渲染帧再发送请求,如果命令在队列里堆积过长,则暂停接受队列请求,同时发送失败通知到子 isolate,子 isolate 可以选择重发消息。
Flutter 重构去哪儿QTalk
文章图片

    推荐阅读