关于flutter:Flutter-重构去哪儿QTalk

7次阅读

共计 8927 个字符,预计需要花费 23 分钟才能阅读完成。

QTalk 是去哪儿网外部的一个 IM 沟通工具,同时集成了很多外部的零碎,比方 OA 审批,门禁打卡,销假审批,预约会议室,驼圈(驼厂朋友圈)等性能;不便外部办公沟通、交换的同时,也为无纸化办公,流程审批等提供了反对。

一、原有产品框架


在决定 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 框架


能够看到,数据层来源于推送或 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 视角里,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 <Factory<OneSequenceGestureRecognizer>>{},
          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 即插即用,合作开发时升高了因为人员变动造成代码在页面层面造成凌乱的可能性。


如图,咱们在编写代码时只须要关怀的每一个页面外部的单向数据流,对页面数据合并没有感知,而每个页面由 5 个文件组成:Action、Effect、Reducer、State 和 View,把应用各种形式对数据做解决与页面刷新宰割开来,从工程层面和页面层面都保护了代码的秩序。

Eventbus

Eventbus 的作用是用于数据库对象与 IMMessage module 对象,数据层与逻辑层沟通。通过事件总线来触发事件和监听事件,它是一种单例治理散发数据的模式,轻量级,全局可用,能够在没有渲染 context 对象参加的状况下传递数据,分治数据逻辑与业务。

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 不同,桌面端反对右键行为等。

图中 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 类型的文件,缩小平台差异性。


适配 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 的事件。


当 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 能够抉择重发消息。

正文完
 0