极客时间已完结课程限时免费阅读

29 | 混合开发,该用何种方案管理导航栈?

29 | 混合开发,该用何种方案管理导航栈?-极客时间

29 | 混合开发,该用何种方案管理导航栈?

讲述:陈航

时长11:12大小10.26M

你好,我是陈航。
为了把 Flutter 引入到原生工程,我们需要把 Flutter 工程改造为原生工程的一个组件依赖,并以组件化的方式管理不同平台的 Flutter 构建产物,即 Android 平台使用 aar、iOS 平台使用 pod 进行依赖管理。这样,我们就可以在 Android 工程中通过 FlutterView,iOS 工程中通过 FlutterViewController,为 Flutter 搭建应用入口,实现 Flutter 与原生的混合开发方式。
我在第 26 篇文章中提到,FlutterView 与 FlutterViewController 是初始化 Flutter 的地方,也是应用的入口。可以看到,以混合开发方式接入 Flutter,与开发一个纯 Flutter 应用在运行机制上并无任何区别,只需要原生工程为它提供一个画板容器(Android 为 FlutterView,iOS 为 FlutterViewController),Flutter 就可以自己管理页面导航栈,从而实现多个复杂页面的渲染和切换。
关于纯 Flutter 应用的页面路由与导航,我已经在第 21 篇文章中与你介绍过了。今天这篇文章,我会为你讲述在混合开发中,应该如何管理混合导航栈。
对于混合开发的应用而言,通常我们只会将应用的部分模块修改成 Flutter 开发,其他模块继续保留原生开发,因此应用内除了 Flutter 的页面之外,还会有原生 Android、iOS 的页面。在这种情况下,Flutter 页面有可能会需要跳转到原生页面,而原生页面也可能会需要跳转到 Flutter 页面。这就涉及到了一个新的问题:如何统一管理原生页面和 Flutter 页面跳转交互的混合导航栈。
接下来,我们就从这个问题入手,开始今天的学习吧。

混合导航栈

混合导航栈,指的是原生页面和 Flutter 页面相互掺杂,存在于用户视角的页面导航栈视图中。
以下图为例,Flutter 与原生 Android、iOS 各自实现了一套互不相同的页面映射机制,即原生采用单容器单页面(一个 ViewController/Activity 对应一个原生页面)、Flutter 采用单容器多页面(一个 ViewController/Activity 对应多个 Flutter 页面)的机制。Flutter 在原生的导航栈之上又自建了一套 Flutter 导航栈,这使得 Flutter 页面与原生页面之间涉及页面切换时,我们需要处理跨引擎的页面切换。
图 1 混合导航栈示意图
接下来,我们就分别看看从原生页面跳转至 Flutter 页面,以及从 Flutter 页面跳转至原生页面,应该如何处理吧。

从原生页面跳转至 Flutter 页面

从原生页面跳转至 Flutter 页面,实现起来比较简单。
因为 Flutter 本身依托于原生提供的容器(iOS 为 FlutterViewController,Android 为 Activity 中的 FlutterView),所以我们通过初始化 Flutter 容器,为其设置初始路由页面之后,就可以以原生的方式跳转至 Flutter 页面了。
如下代码所示。对于 iOS,我们初始化一个 FlutterViewController 的实例,为其设置初始化页面路由后,将其加入原生的视图导航栈中完成跳转。
对于 Android 而言,则需要多加一步。因为 Flutter 页面的入口并不是原生视图导航栈的最小单位 Activity,而是一个 View(即 FlutterView),所以我们还需要把这个 View 包装到 Activity 的 contentView 中。在 Activity 内部设置页面初始化路由之后,在外部就可以采用打开一个普通的原生视图的方式,打开 Flutter 页面了。
//iOS 跳转至Flutter页面
FlutterViewController *vc = [[FlutterViewController alloc] init];
[vc setInitialRoute:@"defaultPage"];//设置Flutter初始化路由页面
[self.navigationController pushViewController:vc animated:YES];//完成页面跳转
//Android 跳转至Flutter页面
//创建一个作为Flutter页面容器的Activity
public class FlutterHomeActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//设置Flutter初始化路由页面
View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符
setContentView(FlutterView);//用FlutterView替代Activity的ContentView
}
}
//用FlutterPageActivity完成页面跳转
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
startActivity(intent);

从 Flutter 页面跳转至原生页面

从 Flutter 页面跳转至原生页面,则会相对麻烦些,我们需要考虑以下两种场景:
从 Flutter 页面打开新的原生页面;
从 Flutter 页面回退到旧的原生页面。
首先,我们来看看 Flutter 如何打开原生页面。
Flutter 并没有提供对原生页面操作的方法,所以不可以直接调用。我们需要通过方法通道(你可以再回顾下第 26 篇文章的相关内容),在 Flutter 和原生两端各自初始化时,提供 Flutter 操作原生页面的方法,并注册方法通道,在原生端收到 Flutter 的方法调用时,打开新的原生页面。
接下来,我们再看看如何从 Flutter 页面回退到原生页面。
因为 Flutter 容器本身属于原生导航栈的一部分,所以当 Flutter 容器内的根页面(即初始化路由页面)需要返回时,我们需要关闭 Flutter 容器,从而实现 Flutter 根页面的关闭。同样,Flutter 并没有提供操作 Flutter 容器的方法,因此我们依然需要通过方法通道,在原生代码宿主为 Flutter 提供操作 Flutter 容器的方法,在页面返回时,关闭 Flutter 页面。
Flutter 跳转至原生页面的两种场景,如下图所示:
图 2 Flutter 页面跳转至原生页面示意图
接下来,我们一起看看这两个需要通过方法通道实现的方法,即打开原生页面 openNativePage,与关闭 Flutter 页面 closeFlutterPage,在 Android 和 iOS 平台上分别如何实现。
注册方法通道最合适的地方,是 Flutter 应用的入口,即在 FlutterViewController(iOS 端)和 Activity 中的 FlutterView(Android 端)这两个容器内部初始化 Flutter 页面前。为了将 Flutter 相关的行为封装到容器内部,我们需要分别继承 FlutterViewController 和 Activity,在其 viewDidLoad 和 onCreate 初始化容器时,注册 openNativePage 和 closeFlutterPage 这两个方法。
iOS 端的实现代码如下所示:
@interface FlutterHomeViewController : FlutterViewController
@end
@implementation FlutterHomeViewController
- (void)viewDidLoad {
[super viewDidLoad];
//声明方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self];
//注册方法回调
[channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
//如果方法名为打开新页面
if([call.method isEqualToString:@"openNativePage"]) {
//初始化原生页面并打开
SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
result(@0);
}
//如果方法名为关闭Flutter页面
else if([call.method isEqualToString:@"closeFlutterPage"]) {
//关闭自身(FlutterHomeViewController)
[self.navigationController popViewControllerAnimated:YES];
result(@0);
}
else {
result(FlutterMethodNotImplemented);//其他方法未实现
}
}];
}
@end
Android 端的实现代码如下所示:
//继承AppCompatActivity来作为Flutter的容器
public class FlutterHomeActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//初始化Flutter容器
FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); //传入路由标识符
//注册方法通道
new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
//如果方法名为打开新页面
if(call.method.equals("openNativePage")) {
//新建Intent,打开原生页面
Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
startActivity(intent);
result.success(0);
}
//如果方法名为关闭Flutter页面
else if(call.method.equals("closeFlutterPage")) {
//销毁自身(Flutter容器)
finish();
result.success(0);
}
else {
//方法未实现
result.notImplemented();
}
}
});
//将flutterView替换成Activity的contentView
setContentView(flutterView);
}
}
经过上面的方法注册,我们就可以在 Flutter 层分别通过 openNativePage 和 closeFlutterPage 方法,来实现 Flutter 页面与原生页面之间的切换了。
在下面的例子中,Flutter 容器的根视图 DefaultPage 包含有两个按钮:
点击左上角的按钮后,可以通过 closeFlutterPage 返回原生页面;
点击中间的按钮后,会打开一个新的 Flutter 页面 PageA。PageA 中也有一个按钮,点击这个按钮之后会调用 openNativePage 来打开一个新的原生页面。
void main() => runApp(_widgetForRoute(window.defaultRouteName));
//获取方法通道
const platform = MethodChannel('samples.chenhang/navigation');
//根据路由标识符返回应用入口视图
Widget _widgetForRoute(String route) {
switch (route) {
default://返回默认视图
return MaterialApp(home:DefaultPage());
}
}
class PageA extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return Scaffold(
body: RaisedButton(
child: Text("Go PageB"),
onPressed: ()=>platform.invokeMethod('openNativePage')//打开原生页面
));
}
}
class DefaultPage extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("DefaultPage Page"),
leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//关闭Flutter页面
)),
body: RaisedButton(
child: Text("Go PageA"),
onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//打开Flutter页面 PageA
));
}
}
整个混合导航栈示例的代码流程,如下图所示。通过这张图,你就可以把这个示例的整个代码流程串起来了。
图 3 混合导航栈示例
在我们的混合应用中,RootViewController 与 MainActivity 分别是 iOS 和 Android 应用的原生页面入口,可以初始化为 Flutter 容器的 FlutterHomeViewController(iOS 端)与 FlutterHomeActivity(Android 端)。
在为其设置初始路由页面 DefaultPage 之后,就可以以原生的方式跳转至 Flutter 页面。但是,Flutter 并未提供接口,来支持从 Flutter 的 DefaultPage 页面返回到原生页面,因此我们需要利用方法通道来注册关闭 Flutter 容器的方法,即 closeFlutterPage,让 Flutter 容器接收到这个方法调用时关闭自身。
在 Flutter 容器内部,我们可以使用 Flutter 内部的页面路由机制,通过 Navigator.push 方法,完成从 DefaultPage 到 PageA 的页面跳转;而当我们想从 Flutter 的 PageA 页面跳转到原生页面时,因为涉及到跨引擎的页面路由,所以我们仍然需要利用方法通道来注册打开原生页面的方法,即 openNativePage,让 Flutter 容器接收到这个方法调用时,在原生代码宿主完成原生页面 SomeOtherNativeViewController(iOS 端)与 SomeNativePageActivity(Android 端)的初始化,并最终完成页面跳转。

总结

好了,今天的分享就到这里。我们一起总结下今天的主要内容吧。
对于原生 Android、iOS 工程混编 Flutter 开发,由于应用中会同时存在 Android、iOS 和 Flutter 页面,所以我们需要妥善处理跨渲染引擎的页面跳转,解决原生页面如何切换 Flutter 页面,以及 Flutter 页面如何切换到原生页面的问题。
在原生页面切换到 Flutter 页面时,我们通常会将 Flutter 容器封装成一个独立的 ViewController(iOS 端)或 Activity(Android 端),在为其设置好 Flutter 容器的页面初始化路由(即根视图)后,原生的代码就可以按照打开一个普通的原生页面的方式,来打开 Flutter 页面了。
而如果我们想在 Flutter 页面跳转到原生页面,则需要同时处理好打开新的原生页面,以及关闭自身回退到老的原生页面两种场景。在这两种场景下,我们都需要利用方法通道来注册相应的处理方法,从而在原生代码宿主实现新页面的打开和 Flutter 容器的关闭。
需要注意的是,与纯 Flutter 应用不同,原生应用混编 Flutter 由于涉及到原生页面与 Flutter 页面之间切换,因此导航栈内可能会出现多个 Flutter 容器的情况,即多个 Flutter 实例。
Flutter 实例的初始化成本非常高昂,每启动一个 Flutter 实例,就会创建一套新的渲染机制,即 Flutter Engine,以及底层的 Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。
因此我们在实际业务开发中,应该尽量用 Flutter 去开发闭环的业务模块,原生只需要能够跳转到 Flutter 模块,剩下的业务都应该在 Flutter 内部完成,而尽量避免 Flutter 页面又跳回到原生页面,原生页面又启动新的 Flutter 实例的情况
为了解决混编工程中 Flutter 多实例的问题,业界有两种解决方案:
以今日头条为代表的修改 Flutter Engine 源码,使多 FlutterView 实例对应的多 Flutter Engine 能够在底层共享 Isolate;
以闲鱼为代表的共享 FlutterView,即由原生层驱动 Flutter 层渲染内容的方案。
坦白说,这两种方案各有不足:
前者涉及到修改 Flutter 源码,不仅开发维护成本高,而且增加了线程模型和内存回收出现异常的概率,稳定性不可控。
后者涉及到跨渲染引擎的 hack,包括 Flutter 页面的新建、缓存和内存回收等机制,因此在一些低端机或是处理页面切换动画时,容易出现渲染 Bug。
除此之外,这两种方式均与 Flutter 的内部实现绑定较紧,因此在处理 Flutter SDK 版本升级时往往需要耗费较大的适配成本。
综合来说,目前这两种解决方案都不够完美。所以,在 Flutter 官方支持多实例单引擎之前,我们还是尽量在产品模块层面,保证应用内不要出现多个 Flutter 容器实例吧。
我把今天分享所涉及到的知识点打包到了 GitHub(flutter_module_pageandroid_demoiOS_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。

思考题

最后,我给你留两道思考题吧。
请在 openNativePage 方法的基础上,增加页面 id 的功能,可以支持在 Flutter 页面打开任意的原生页面。
混编工程中会出现两种页面过渡动画:原生页面之间的切换动画、Flutter 页面之间的切换动画。请你思考下,如何能够确保这两种页面过渡动画在应用整体的效果是一致的。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 4

提建议

上一篇
28 | 如何在原生应用中混编Flutter工程?
下一篇
30 | 为什么需要做状态管理,怎么做?
 写留言

精选留言(18)

  • Geek_joestar
    2019-09-10
    原生页面打开Flutter页面时会黑屏一两秒,然后出现Flutter页面内容,这个体验很不好

    作者回复: Debug环境吧?Release环境加载会快很多,另外可以考虑提前把引擎初始化

    共 6 条评论
    6
  • Carlo
    2019-10-27
    从 Flutter 页面跳转至原生页面 或者 从原生页面跳转至Flutter页面 如何传参呢?

    作者回复: OpenNativePage,closeFlutterPage这两个方法可以增加参数

    3
  • 阿水
    2019-10-31
    老师我想请教一下,原生界面和flutter界面互相跳转的时候出现屏幕一闪的现象,有解决方案吗

    作者回复: 把flutter容器做成单例,提前初始化好

    共 3 条评论
    4
  • 菜头
    2019-11-20
    请教一下 如何能够确保这两种页面过渡动画在应用整体的效果是一致的?

    作者回复: 保证整体效果一致,有两种方案: 一是,分别定制原生工程(主要是Android)的切换动画,及Flutter的切换动画; 二是,使用类似闲鱼的共享FlutterView的机制,将页面切换统一交由原生处理,FlutterView只负责刷新界面。

    共 2 条评论
    3
  • 陆大胖
    2019-09-18
    Push FlutterViewController的方案不可取。起码现在stable分支上没有解决内存泄漏的问题。另外需知道每个FlutterViewController的创建都应了整个Flutter App的启动(xcode上暂停应用,会看到多了一套完整的Flutter线程,UI、io那些),无论你是否setInitialRoute,都是从Flutter代码的main函数开始运行,感觉上就是在你的应用内,启动了2个同样的Flutter App/模块, 这还意味着2个FlutterViewController之间不做特殊处理的情况下不共享任何信息,以上,大多数情况下都会带来些水土不服。另外作者在回复中提到的“或者纯FlutterApp全部都是FlutterViewController,不释放其实也没什么问题”,这里的“全部都是FlutterViewController”可能我理解有偏差,先打个问号。
    展开

    作者回复: flutteView确实有内存泄漏问题,不过问题不大,技术上可以通过全局共享VC解决,实际落地尽量让业务闭环在Flutter内部即可。 对于纯flutter应用,整个页面都是flutter渲染的,不涉及跨技术栈的渲染切换,自然也就不需要重建和释放了。

    2
  • mirrors
    2021-06-03
    因此我们在实际业务开发中,应该尽量用 Flutter 去开发闭环的业务模块,原生只需要能够跳转到 Flutter 模块,剩下的业务都应该在 Flutter 内部完成,而尽量避免 Flutter 页面又跳回到原生页面,原生页面又启动新的 Flutter 实例的情况。 这个感觉没法避免,单个业务线可以做到flutter闭环,但如果我有多个业务线,那从另一个业务线退回到原生,然后又打开另一条业务线。这种情况应该比较常见,有什么好的方案吗?除非很极端原生只是个壳子,其他全用flutter实现,但这样混编还有什么意义呢?
    展开
  • mirrors
    2021-06-03
    Flutter 实例的初始化成本非常高昂,每启动一个 Flutter 实例,就会创建一套新的渲染机制,即 Flutter Engine,以及底层的 Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。 现在有一种写法: iOS在AppDelegate中 //flutter engine启动 self.flutterEngine = [[FlutterEngine alloc] initWithName:@"WMFlutterEngine"]; // Runs the default Dart entrypoint with a default Flutter route. [self.flutterEngine run]; 这样的话,我是不是之后创建的FlutterViewController实例都是共用的这个flutter engine。效率上会提升吗?还是说,只是提前启动了引擎,每次创建的FlutterViewController实例还是不共用flutter engine。
    展开
  • Geek_efan
    2020-06-23
    请教一下,Flutter自1.12,不再支持用Android端使用Flutter.createView的方式了,而是用新的io.flutter.embedding.android包下的FlutterActivity和FlutterFragment,初始化路由也改到了FlutterEngine里,我想请教,是不是这样就一个引擎只能有一个初始化路由了,也就没办法做到灵活的原生端启动Flutter页面的时候传参了?
    1
  • Geek_fef1f7
    2020-06-12
    您好!我按照您的代码编译试了一次,一直提示 Unhandled Exception: MissingPluginException(No implementation found for method openNativePage on channel samples.chenhang/navigation) ,请问可用从哪些方面定位问题呢
    共 1 条评论
    1
  • 2020-04-30
    有个问题想请问下:通过OpenNativePage跳转到原生Activity(SomeNativePageActivity)后,我们返回,应该是退到FlutterHomeActivity吧?是PageA页面还是DefaultPage页?
  • 王文亮
    2020-04-09
    为了更加灵活的打开原生页面 可以考虑使用deeplink跳转,传参也方便
  • zzz
    2019-10-30
    看文中提到的头条的文章,FlutterView -> Engine —> vm中的isolate,然后使用isolate共享,FlutterView -> Engine还是一一对应的就解决了内存的问题,那请问下在iOS中,初始化FlutterViewController的方法中initWithEngine中传入同一个engine,是否是多个FlutterViewController对应同一个engine然后对应一个isolate呢?

    作者回复: 是,但是你需要自己去清理和恢复engine的运行上下文,可能会出现为定义的异常或者bug

  • N1eR
    2019-10-30
    跳转到的原生页面继承AppCompatActivity会崩溃 继承Activity就没事 这是为啥

    作者回复: 一般是直接用acrivity,否则你需要单独隐藏工具栏。crash倒从来没遇到过,你可以查一下具体的报错信息

  • 舒大飞
    2019-10-11
    如果混合开发中,多个不连续的flutter页面那个就会创建多个Flutter引擎吗

    作者回复: 如果不做特殊定制(头条或闲鱼的方案),是的

  • 辉哥
    2019-09-26
    老师,请教一下,对于android来说,Flutter实例是不是对应的FlutterView对象?同时开启多个Flutter实例会占用过多的内存,那关闭当前Flutter容器所在的Activity是否能回收Flutter实例所占的内存?

    作者回复: 对;理论上是,但考虑到Flutter引擎有内存泄漏问题,如果Flutter页面需要反复打开,不建议采用这种方式回收内存,可以考虑将Flutter容器做成一个单例。

  • Tony
    2019-09-24
    我想问下老师,用什么画图软件画的图

    作者回复: draw.io

  • 矮个子先生😝
    2019-09-04
    老师,不知您是否有出现,push FlutterViewController之后,然后closeFlutterPage,但是内存依然居高不下.

    作者回复: FlutterEngine内部实现存在循环引用的情况,所以会有内存泄漏的问题。不过一般的混合应用只会创建一个FlutterViewController,或者纯FlutterApp全部都是FlutterViewController,不释放其实也没什么问题。如果真的需要关闭flutter容器,可以把FlutterViewController缓存起来(作为单例使用)。

  • 许童童
    2019-09-03
    跟着老师一起精进,感谢老师的分享。