原文发表于 阅文技术公众号 《{已开源} 阅文 Flutter 混合开发利器 MixStack》

Github开源地址:MixStack

MixStack混合栈是我当时刚进入公司不久参与的该项目,对于混合栈的开发说起来很简单,但踩了无数的坑,经常被各种神奇的bug和场景折腾得睡不好觉。还好,最终都挺过来了,项目从启动到开源最后再到文章,不知道被老大哥喷了多少遍,可以说这是我工作生涯中,灰暗而又有收获,非常有意义的一段时间,遂记录该文章于博客。

一、前言

Flutter 是一款谷歌的移动 UI 框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。Flutter 可以与现有的代码一起工作,相比于其他跨平台的框架,如 React Native、Weex 等,Flutter 具有如下的优势:跨平台表现一致 谷歌直接在 iOS/Android 等平台直接建立了独立的渲染引擎,保证了界面渲染的高效且多端有接近原生性能的表现。快速开发 开发过程中提供了高效的热重载,开发便捷。已有工具链已经比较强大。

二、随之而来的问题

当我们尝试将 Flutter 集成到项目中的时候,却发现现实比想象骨感许多,这里我们以 Android 平台的探索为例进行说明。

对于 Android 平台,官方封装了 FlutterAcitivity 以及 FlutterFragment 两种 Flutter 原生容器,当我们直接使用时,能正常打开 Flutter 页面,但是表现却并不如我们所期待。

其中最明显的就是,官方会为每个 FlutterAcitivity 和 FlutterFragment 生成一个新的 FlutterEngine 实例。每个 FlutterEngine 拥有不同的 Isolate,也就是说不同的 Dart 运行环境完全独立,其内存状态不进行共享。对于大多数 App 来说,多数页面有一些全局状态(例如登录态)。如果基于 Flutter 默认的设计,两个不同容器里的 Flutter 页面通信将变得异常复杂。另外每个 FlutterEngine 实例的内存开销本身就非常大,例如同一张图片由于内存状态不共享,在 n 个 Engine 中会存在 n 份,这显然是不可接受的。

这就是当我们想把 Flutter 与现有业务代码混合使用时会遇到的

问题1:多 FlutterEngine 实例间内存状态不共享

对于多 FlutterEngine 实例带来的问题远不止这些,官方提供给了我们一种解决方案:共享 FlutterEngine,全局只有一个 FlutterEngine 实例,将其放入缓存管理中。当我们尝试使用这种方案连续打开两个 FlutterActivity,页面却会冻住,停止渲染.

问题2:Flutter 原生容器在共享 FlutterEngine 情况下渲染生命周期错误

图片

另外,在业务场景中,常有类似这样的页面跳转情况:Native Activity->Flutter Activity->Native Activity->Flutter Activity->Native Activity ,其中 Flutter Activity 中拥有几个不同的 Flutter Page,如下图所示:

对于 FlutteActivity 或者 FlutterFragment 中的 Flutter 页面来说我们需要将其与原生页面对齐,页面栈变成下图会更容理解:

当我们处于 FlutterActivity A 中页面时,对于一些业务场景有顶部 NavigationBar、Back 按键以及手势返回的时候,用户基于常见的栈“先进后出”的原则,对于 FlutterActivity A 中的页面,期望的是先关闭掉其最上层的 Flutter 页面,再依次关闭f3、f2、f1。最终关闭掉该 FlutterActivity 展示 Natvie Activity A。然而实际的情况是:如果不修改相关逻辑,会直接 finish 掉整个 Activity,那么f1、f2、f3……会被同时关闭。

问题3:Flutter 原生容器中 Flutter 页面栈管理与预期不符

图片

在每个 FlutterActivity 中存在一个 FlutterView,FlutterView 承载了不同的 Flutter 页面也就是不同的 FlutterWidget,其Flutter 内部不同的 Widget 可以通过Navigator控制,我们需要将其做到与原生对齐,形成一套用户无感,开发者较浅感知的页面栈管理方案。

随着深入了解会发现更多的问题,页面切换闪屏、App 启动白屏、Flutter Dialog 展示底部白屏、状态栏高度颜色不正确等等等等。

三、解决方案

业内 Flutter 混合栈的研究已经开始很久了,但仍没有一款真正的完美解决方案,我们希望这次推出的 MixStack 是这样的方案。经过一系列的探索,我们解决了上述问题,并在实际上线的 App 红袖中进行了验证,取得了较好的效果。

接下来具体讲解MixStack的实现

1.共享FlutterEngine

为了解决问题1:多 FlutterEngine 实例间内存状态不共享,我们选择对 FlutterEngine 进行共享,保持全局只有一个 FlutterEngine。

这样使得所有相关操作都在同一个Isolate,对于之前所说的全局状态(例如登录态)以及类似同一份图片缓存存在多份的问题也就迎刃而解了。

2.正确的渲染生命周期

关于问题2:Flutter 原生容器在共享 FlutterEngine 情况下渲染生命周期错误。默认的 FlutterActivity 和 FlutterFragment 对于共享 FlutterEngine 的支持不太好,我们对其进行了相关修改。基于任何时候只能看到一个 Flutter 原生容器的假设,且 FlutterEngine 同一时刻也只能渲染一个 FlutterView,因此我们约定:对每一个 FlutterView 在可见的时候对其进行渲染相关的准备,在即将不可见的时候使其从渲染上脱离。那该如何实现 Flutter 原生容器顺畅交互呢?我们先看两个容器间切换的生命周期,通常情况下 Activity A 启动 Activity B 生命周期如下图所示:

  • FlutterEngine 在原生层面定义了LifecycleChannel,主要作用是向 Flutter 发送渲染生命周期相关的事件。LifecycleChannel主要发送了四种状态事件:

    1. AppLifecycleState.resumed、
    1. AppLifecycleState.inactive、
    1. AppLifecycleState.paused、
    1. AppLifecycleState.detached。
  • 阅读源码后可知,FlutterEngine 在 AppLifecycleState.resumed 执行了启动渲染的操作。

  • 而 AppLifecycleState.paused、 AppLifecycleState.detached停止了渲染操作,也就是说处于这两个生命周期中,页面内的内容不会被重绘。

  • 那么我们可知要使 FlutterEngine 渲染交互正常需要满足以下要求:

  • (1) FlutterEngine 生命周期处于 AppLifecycleState.resumed

  • (2) FlutterEngine attach 在当前可见的 FlutterView

  • 那么对于 FlutterActivity,只需在 FlutterActivity onResume() 的时候执行以上操作,使得 FlutterEngine 能够渲染当前页面。当 FlutterActivity 需要启动另一个 FlutterActivity 时,FlutterEngine 需要将其从当前 FlutterView detach 并停止渲染。整个流程如下图所示:

  • 同理,FlutterActivity 打开原生的 Activity 的时候,我们依然需要在 onPause() 的时候,对 FlutterView 进行 detach 操作,保证在新打开原生 Activity 之后,如有再打开的其他的 FlutterActivity 也能够维护正常生命周期。

  • 在多数 App 中都有 Tab 类的界面,一般都是:NavigationBar 加 Fragment 的组合,那当这种场景中混合了多个 FlutterFragment,我们又该如何做呢,Fragment组合如下图所示:

  • 我们同样基于之前的假设,在 FlutterFragment onResume() 的时候,attach 到FlutterEngine,使得 FlutterEngine 能够渲染当前页面,onPause 的时候 detach。

  • 对于多个 Tab 之间 FlutterFragment 的切换,只多了一步操作,在 onHiddenChanged() 的时候,对 FlutterEngine 进行相应的操作,需要留意的是,在 onPause() 或者 onResume() 操作的时候需要添加 isHidden() 判断,保证当前渲染的是可见的那个 FlutterFragment。

  • FlutterFragment 的渲染流程如下图所示:

  • 至此我们成功地解决了问题2:原生 Flutter 容器在共享 FlutterEngine 情况下渲染生命周期错误

  • (3) Flutter 页面相关的约定

  • 前面两点的实现,使我们能够成功让 Flutter 原生容器如正常原生页面一般使用,但依然有问题3:原生 Flutter 容器中 Flutter 页面栈管理与预期不符 未解决。为了解决问题3, 我们需要额外增加一些约定,将每一个原生栈中的 Flutter 原生容器映射为 Flutter 中的一个容器,我们称为 PageContainer。

  • 如下图所示:

  • image-20210310195300378

每个 PageContainer 有以下特性:

1、包含独立 Navigator,PageContainer 间互不影响

2、有一个根页面,通过根路径(rootRoute)形式传入

在每个 Flutter 原生容器实现接口传入 rootRoute 属性,在页面可见的时候,MixStack 会向 Flutter 通信,告知即将显示的页面。

3、默认对于原生环境的 Inset 无感,通过额外 API 传入(降低性能开销)

Flutter 对于渲染画布的尺寸变化非常敏感,某些情况下会导致 Widget 状态异常,例如 Tab 滑动位置丢失等等问题,所以 MixStack 默认为 Flutter View 尺寸不变化,推荐将影响 View 内 Flutter 组件排版的 inset 变化通过 API 传入。

MixStack 基于 Channel 通信告知 Flutter 当前所有页面信息 (pages) 及当前需要显示 Flutter 页面 (currentPage),Flutter 基于信息更新 Widget。每个 PageContainer 与 Flutter 原生容器的 hashCode 有唯一映射关系,从而保证页面状态持久化。

另外我们在 iOS 和 Android 上接管了返回指令,并与 Flutter 端进行同步,满足返回预期。

最后的效果就如我们所期望的:

图片

四、总结

至此我们解决了上述三大问题,构建出了一套能够正确维护原生⻚面与 Flutter ⻚面交互的完整解决方案。

当然关于项目中集成使用 Flutter 所带来的麻烦并不局限于上述问题,Flutter 本身也存在一些 Bug,MixStack 已经将相关的问题在库内解决或者提交官方补丁。经过线上 App 的实际验证,对首⻚多FlutterFragment 、 Flutter 各种弹窗 、各种 Flutter 容器与原生之间的切换等情况均表现正常。

目前 MixStack 已经全量在阅文旗下红袖 App 上使用,他们在极短时间内借助 MixStack 能力平滑移除了对 RN 的依赖,同时仍正常交付日常功能需求。

一款成熟的 App 将现有的⻚面完全转化为 Flutter ⻚面,改造的成本太大,周期太⻓,或者有一些业务场景⻚面需要 Native 的能力,需要一个能方便管理原生与 Flutter 混合⻚面的管理工具,那么 MixStack 将是一个非常好的选择。