爆裂吧现实

不想死就早点睡

0%

一种无入侵的Flutter混合栈方案

前景概括

Flutter 是一个跨平台开发的工具,它极速的开发方式、Native 的表现性能、开源的代码等特点吸引了业界众多开发者的注意。不过由于 Flutter 处于萌芽发展阶段,还不是很完善,比如本文需要探讨的混合栈实现就是 Flutter 其中的一块短板,目前没有一套官方的解决方案,都是业界开发者在试水

这里简单介绍下 Flutter 的混合栈问题:Flutter 的界面是需要 Native 的容器来承载显示的,比如 Android 里面的 Activity,iOS 里面的 ViewController。现有的 app 不可能全部改成 flutter 实现,所以需要将 flutter 接入现有的 app 中,如果此时从 Native 页面跳转到 Flutter 页面,默认的方式会重新初始化一个 Flutter 实例运行,不会复用之前创建的 Flutter 实例

具体细节在之前的一篇混合栈文章 Flutter混合栈管理 里面讲述了

我之前开发过一个混合栈插件来解决 flutter 的混合开发问题,最后在使用的时候会碰到以下问题:

  • Flutter 的共享动画会失效 (Hero 动画)
  • 必须使用混合栈的 api 来打开跳转页面,无法使用系统的 Navigator

为了解决上述问题,需要重新设计 Flutter 混合栈插件,下面来探讨下怎么解决上述两个问题

一、Flutter Hero 动画失效

在之前的混合栈实现方式里面,FlutterA 页面跳转到 FlutterB 页面,是通过一个新的 Native 容器来实现跳转的,因为这个原因导致无法使用 Hero 动画(Flutter 是渲染在 Native 容器内的,无法在容器外绘制)

这里如果还是需要借助 native 容器(比如 android 的 Activity)来实现跳转,感觉很难实现。换一个思路,难道一定要借助 native 容器来跳转吗?不可以直接使用 flutter 原生的方式来跳转吗?如果在 Flutter -> Flutter 跳转的时候使用 flutter 原生的跳转,这个问题不就不存在了吗?

我觉得对于这个最大的影响点只有页面跳转动画不一致问题了,其它的页面埋点信息之类的都可以在 flutter 层解决。

Flutter 的页面跳转有自己的动画,Native 有 Native 的转场动画,这个问题在 Android 上可能严重些,Android 因为系统 rom 定制问题,不同 rom 的默认跳转动画可能不一致。解决方案是 app 不能使用系统默认动画,需要用自定义动画,然后把动画在 flutter 端实现一份!iOS 就没有问题,因为系统本身转场动画比较统一,便于适配。

我比较推荐在 flutter 跳转到 flutter 的时候走 flutter 原生的跳转,不经过 native 容器跳转,这样可以有以下好处:

  • 完全走 flutter 生命周期流程,和 native 没有关系
  • 转场的时候不再需要截图和混合栈适配
  • 可以放心安全的使用 Hero 动画

从长远的角度看,此方案兼容性比较好,适配简单,便于后续升级

脱离 native 的统一跳转,URLRoute 方案和页面埋点方案都需要重新定制。这一点需要考虑

二、如何让原生的 Navigator 具有混合栈能力

这部分我一开始并没有什么好的解决方案,直到闲鱼出了 flutter_boost,看了下里面的实现逻辑,发现它的多 Navigator 方案很不错!我们来看下怎么用多 Navigator 方案来实现混合栈能力

通常情况下我们使用 Navigator 来跳转页面是这样的:Navigator.of(context).push(...),这个 of 就是获取到距离当前 context 最近的一个 Navigator,内部的代码如下:

1
2
3
final NavigatorState navigator = rootNavigator
? context.rootAncestorStateOfType(const TypeMatcher<NavigatorState>())
: context.ancestorStateOfType(const TypeMatcher<NavigatorState>());

context.ancestorStateOfType() 是在 widget 树中从当前 context 开始自底向上搜索,除非使用 context.rootAncestorStateOfType(),否则获取到的就是最近的祖先节点

showDialog 使用的是 context.rootAncestorStateOfType() 也就是获取的是 root Navigator,这里需要注意

有了上述思路,只要我继承 Navigator,复写掉 pushpushNamed… 等方法,让它具有混合栈能力,让后插入到所有页面节点之前,那么页面中使用的 Navigator 不就是有混合栈能力的 Navigator 吗?

看似思路很简单,但是写起来还是需要熟悉 Flutter 中 Navigator 的工作流程的,基于这个流程想办法插入自己的逻辑,并且做到让业务方无感知

dart 实现原理

经过上述思考后,我大致得到了解决方案,为了兼容起见我保留了两种 (Flutter -> Flutter) 的跳转方式:

  • 借助 Native 容器来实现跳转
  • 直接使用 Flutter 原生的页面跳转

然后通过继承 class HybridNavigator extends Navigator,重写掉里面的 pushpop…等方法,插入到整棵 Widget 树中:

如图中所示,整棵 Widget 树存在多个 Navigator,此时如果在 Page X 中通过 context 获取的 Navigator 其实是 HybridNavigator。这种情况下 Flutter 端打开一个新的页面分为两种情况

情况一:
不走 native channel 的流程,直接 push 一个新的页面,对应图中 Page A -> Page B

情况二:
走 native channel 流程,通过打开一个新的 native 容器来承载页面,对应图中粗的虚线(Page B -> Page C

这种实现方式虽然可以做到对业务方透明,但是还是可能会影响一些 api 的使用,比如 popUntil 方法,因为是使用多 Navigator 方案,所以业务方获取到的 Navigator 栈中并不包含所有的页面,不过 popUntil 用的比较少(目前我还没有用到的地方),注意下就行

业界方案比较

  • 闲鱼的 flutter_boost:闲鱼的混合栈给了我很多启发,优点:进过多场景的磨练测试,支持 native 容器平级。缺点:入侵性太强,打开页面返回的数据是回调方式,android 还是使用截图方案
  • 今日头条的混合栈方案:优点:理论上可以实现卡片级别的混合开发,不需要截图方案。缺点:需要修改引擎代码,后续升级推广比较麻烦
  • 本混合栈方案:优点:接入时无需修改页面内的跳转代码,数据通信接口和原生的一模一样(await 方式等待页面返回数据),android 不需要截图。缺点:暂未实现 native 容器的平级切换显示

开发后记

目前混合栈 flutter plugin 已经开发完成接入上线,这次升级原有的混合栈主要是减少 flutter 业务代码的入侵程度(可以直接使用系统 api,对于业务方开发来说是透明的),实际开发过程中还是会碰到很多问题,比如下面几项:

1、iOS 的侧滑返回问题

在之前的混合栈方案中,只需要禁用 Flutter Page 的侧滑返回,启用 NativeController 的侧滑返回即可。现在引入了一个 Native 容器可以打开多个 Flutter Page,所以不能这么简单暴力的干了

解决办法是禁用 HybridNavigator 栈中栈底 Route 的侧滑返回,监听当前 HybridNavigator 的页面栈,如果页面数量 > 1,则禁用 native controller 的侧滑返回,否则启用 native controller 的侧滑返回

2、Flutter 回退的时候会闪一下上一个页面

HybridNavigator 栈只剩一个页面的时候,此时如果再回退会结束掉 native 容器(操作 ①),并且把当前的 HybridNavigator 从系统根 Navigator 中移除(操作 ②)。问题是结束 native 容器是通过 channel 调用的,属于异步调用,无法保证上述两个操作的顺序。

如果(操作 ②)出现在(操作 ①)之前,那么就会出现当前 native 容器显示了上一个 native 容器的页面

这里的解决办法是把(操作 ②)交给 channel 来执行,这时候需要 HybridNavigator 继承下 pop 方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
@override
bool pop<T extends Object>([T result]) {
if (canPop()) {
return super.pop(result);
}
// pop 函数本来是非异步的,但是这里因为是当前 Navigator 最后一个页面了,所以可以放心
// 使用 channel 关闭页面
HybridStackManager.singleton
.closeKeyPage(widget.nativePageId, result, _initialRoute);
return true;
}

难点是在 pop 方法是有返回值的,而且是一个非异步方法,channel 是异步。这里我直接暴力返回了 true,有可能导致 popUntil 死循环,这部分未解决

打开页面的时候也是有可能出现当前页面显示了上一个页面的问题,这部分需要在 native 容器内处理

3、页面数据传递适配

v1.0.0 引擎上,系统的 Navigator.of(context).pushNamed() 是不能带参数的,但是 v1.2.2 后官方给这个 pushNamed 接口扩展了下,可以带上页面参数了。不过 v1.2.2 版本中 android 的 java 代码存在资源释放 bug,无法适配混合栈。最后我选择了 beta 分支上的 v1.3.8 来开发

4、其它

其它还有一些琐碎的小问题(比如页面生命周期 dispose 缺失,android 沉浸式适配问题…等等),这里不给予展开了,各位有兴趣的可以阅读源码