目录
- Navigator1.0
- Navigator2.0
- APP
- RouteInformationParser
- RouterDelegate
- 问题
- The Navigator.pages must not be empty to use the Navigator.pages API
- 浏览器的回退按钮
- 总结
Navigator1.0
我们学习flutter一开始接触的路由管理就是Navigator1.0,它非常方便,使用简单,如下:
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
onGenerateRoute: (RouteSettings settings){
return PageRouteBuilder(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
if(settings.name == "pageB"){
return PageB();
}
else if(settings.name == "pageC"){
return PageC();
}
else{
return Container();
}
}
);
},
// routes: {
// "pageB" : (BuildContext context) => PageB(),
// "pageC" : (BuildContext context) => PageC()
// },
home: PageA(),
);
}
}
通过onGenerateRoute
或routes
来注册路由,使用时通过Navigator.of(context).pushNamed()
或者其他函数即可。
Navigator1.0使用简单,但是问题也一样,只有push、pop等几个简单操作,对于复杂场景就无能为力了,比如web开发时地址栏或后退键的处理。
所以google后来又推出了Navigator2.0
Navigator2.0
Navigator1.0是通过Navigator来管理处理路由,而Navigator2.0则是通过Router来处理的,但是也需要Navigator,实际上是用Router对Navigator包裹起来。Router相对来说功能就强大很多了,同时使用起来也复杂很多。
关于Navigator2.0的原理,网上已经有很多文章了,但是我发现这些文章在使用实例上都不是很清楚,或者说示例过于复杂。应该是大部分参考google官方文档简单翻译的,但是其实我们正常场景使用并不是那么复杂,而且大部分都没有讲清楚。所以本篇文章不讨论原理,只用最简单的示例来展示如果使用Navigator2.0,或者说如何快速的从Navigator1.0转成Navigator2.0。
APP
首先创建MaterialApp方式有了改变,通过MaterialApp.router()
来创建,如下:
class MyApp extends StatelessWidget {
final delegate = MyRouteDelegate();
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
routerDelegate: delegate,
routeInformationParser: MyRouteParser(),
);
}
}
通过这种方式我们需要设置routerDelegate
和routeInformationParser
,这样就需要实现这两个类。
RouteInformationParser
创建一个类继承RouteInformationParser,主要的作用是包装解析路由信息,这里有一个最简单的方式,如下:
class MyRouteParser extends RouteInformationParser<String> {
Future<String> parseRouteInformation(RouteInformation routeInformation) {
return SynchronousFuture(routeInformation.location);
}
RouteInformation restoreRouteInformation(String configuration) {
return RouteInformation(location: configuration);
}
}
我们的路由信息都由一个字符串承载,可以用url的形式,这样方便处理。
RouterDelegate
RouterDelegate是最重要的部分,这里实现路由切换的逻辑,继承RouterDelegate的类需要实现下面的函数:
void addListener(listener)
void removeListener(listener)
Widget build(BuildContext context)
Future<bool> popRoute()
Future<void> setNewRoutePath(T configuration)
其中addListener
和removeListener
是来自RouterDelegate的继承Listenable。
build一般返回的是一个Navigator。
popRoute实现后退逻辑
setNewRoutePath实现新页面的逻辑
单单这么说肯定一头雾水,我们用一个示例来实现它,具体代码如下:
class MyRouteDelegate extends RouterDelegate<String> with PopNavigatorRouterDelegateMixin<String>, ChangeNotifier{
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
String get currentConfiguration => _stack.isNotEmpty ? _stack.last : null;
final _stack = <String>[];
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
for (final url in _stack)
getPage(url)
],
onPopPage: (route, result){
if (_stack.isNotEmpty) {
_stack.removeLast();
notifyListeners();
}
return route.didPop(result);
},
);
}
Page getPage(String url){
return MaterialPage(
name: url,
arguments: null,
child: getWidget(url)
);
}
Widget getWidget(String name){
switch(name){
case "pageB":
return PageB();
case "pageC":
return PageC();
default:
return PageA();
}
}
Future<void> setNewRoutePath(String config) {
if(config == "/"){
_stack.clear();
}
if(_stack.isEmpty || config != _stack.last) {
_stack.add(config);
notifyListeners();
}
return SynchronousFuture<void>(null);
}
}
首先我们不仅继承RouterDelegate,同时还继承ChangeNotifier,这样就不必实现addListener
和removeListener
了。
注意:如果这里手动实现了addListener
和removeListener
但是并没有实现代码,这样会导致页面无法切换,因为路由变化没有通知。现象就是点击切换页面的按钮无反应,build不执行。
然后又继承了PopNavigatorRouterDelegateMixin,它实现了popRoute
函数,所以这个函数也可以不用实现。但是继承它后需要实现navigatorKey
,如上第一行。
通过上面两个继承,我们只需要实现setNewRoutePath
和build
两个函数即可。先看setNewRoutePath
的代码:
<void> setNewRoutePath(String config) {
if(config == "/"){
_stack.clear();
}
if(_stack.isEmpty || config != _stack.last) {
_stack.add(config);
notifyListeners();
}
return SynchronousFuture<void>(null);
}
Future
_stack
是一个列表,用来存储所有路由信息,因为前面我们的路由信息用String承载,所以_stack
是一个字符串列表。
在这个函数里将新路由添加进_stack
,然后调用notifyListeners()
通知路由变化。
注意这里的两个逻辑,如果是首页则先清空;如果新页面与上一页一摸一样,则忽略,因为发现在web上setNewRoutePath会被重复调用。
然后是build函数,如下:
build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
for (final url in _stack)
getPage(url)
],
onPopPage: (route, result){
if (_stack.isNotEmpty) {
_stack.removeLast();
notifyListeners();
}
return route.didPop(result);
},
);
}
Widget
返回一个Navigator,设置pages
和onPopPage
。
在onPopPage
中实现回退逻辑,可以看到将列表中最后一个remove掉,然后notifyListeners()
同时路由变化。上面我们提到PopNavigatorRouterDelegateMixin实现了popRoute
函数,它的实现代码最终就会调用到onPopPage
这里。
pages
则是一个Page列表,是当前已经打开的所有页面,所以用一个for循环来创建,我自己定义了一个getPage函数:
Page getPage(String url){
return MaterialPage(
name: url,
arguments: null,
child: getWidget(url)
);
}
Widget getWidget(String name){
switch(name){
case "pageB":
return PageB();
case "pageC":
return PageC();
default:
return PageA();
}
}
注意:因为我们的示例中路由没有参数,只有路由名称,所以上面对url没有进行处理。但是实际使用的时候,在getPage函数一开始就应该对url进行处理,提取出name和参数,并将参数整理成Object设置给arguments,这样页面中就可以用之前的方式ModalRoute.of(context).settings.arguments
获取,不用改变太多。
这里我定义了三个页面,其中PageA是默认页面。三个页面都很简单,每个页面有两个按钮,一个打开新页面,一个回退。
打开新页面用
Router.of(context).routerDelegate.setNewRoutePath("pageB");
代替了之前Navigator1.0中的
Navigator.of(context).pushNamed("pageB");
回退则使用
Router.of(context).routerDelegate.popRoute();
代替了之前Navigator1.0中的
Navigator.of(context).pop();
这样页面内的改动很小,可以很快的转到Navigator2.0。
到这里还差最后一步,实现RouterDelegate中字段currentConfiguration
的get方法,如下:
get currentConfiguration => _stack.isNotEmpty ? _stack.last : null;
String
如果不实现这里,虽然页面可以切换,但是路由信息并没有更新,比如flutter web的应用在浏览器中,页面正常切换,但是地址栏并没有变化。只有实现了这个get函数,当路由发生变化的时候,其他类才能通过这个函数获取到最新路由。
上面就是Navigator2.0的简单使用,相对于官方的示例更简单一些,也更容易理解核心部分,尤其方便从Navigator1.0升级到Navigator2.0。
问题
这个过程还是出现不少问题的,记录一下:
The Navigator.pages must not be empty to use the Navigator.pages API
报错如下:
════════ Exception caught by widget library ════════════════════════════════════════════════════════
The following assertion was thrown:
The Navigator.pages must not be empty to use the Navigator.pages APIWhen the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/patch/core_patch.dart 906:28 get current
packages/flutter/src/widgets/navigator.dart 3345:33
packages/flutter/src/widgets/navigator.dart 3361:14 initState
packages/flutter/src/widgets/framework.dart 4632:57 [_firstBuild]
packages/flutter/src/widgets/framework.dart 4469:5 mount
…
════════════════════════════════════════════════════════════════════════════════════════════════════════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
Navigator.onGenerateRoute was null, but the route named “/” was referenced.
The relevant error-causing widget was:
MaterialApp file:///Users/bennu/fluttertest/lib/main.dart:62:24
════════════════════════════════════════════════════════════════════════════════════════════════════════════ Exception caught by widget library ════════════════════════════════════════════════════════
The following assertion was thrown:
The Navigator.pages must not be empty to use the Navigator.pages APIWhen the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/patch/core_patch.dart 906:28 get current
packages/flutter/src/widgets/navigator.dart 3345:33
packages/flutter/src/widgets/navigator.dart 3361:14 initState
packages/flutter/src/widgets/framework.dart 4632:57 [_firstBuild]
packages/flutter/src/widgets/framework.dart 4469:5 mount
…
════════════════════════════════════════════════════════════════════════════════════════════════════════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
Navigator.onGenerateRoute was null, but the route named “/” was referenced.
The relevant error-causing widget was:
MaterialApp file:///Users/bennu/fluttertest/lib/main.dart:62:24
════════════════════════════════════════════════════════════════════════════════════════════════════════════ Exception caught by widget library ════════════════════════════════════════════════════════
The following assertion was thrown:
A HeroController can not be shared by multiple Navigators. The Navigators that share the same HeroController are:
- NavigatorState#1f365(lifecycle state: initialized)
- NavigatorState#9f699(lifecycle state: initialized)
Please create a HeroControllerScope for each Navigator or use a HeroControllerScope.none to prevent subtree from receiving a HeroController.
When the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/patch/core_patch.dart 906:28 get current
packages/flutter/src/widgets/navigator.dart 3501:41
packages/flutter/src/scheduler/binding.dart 1144:15 [_invokeFrameCallback]
packages/flutter/src/scheduler/binding.dart 1090:9 handleDrawFrame
packages/flutter/src/scheduler/binding.dart 865:7
…
════════════════════════════════════════════════════════════════════════════════════════════════════
这里涉及到一开始App的创建,回顾一下代码:
class MyApp extends StatelessWidget {
final delegate = MyRouteDelegate();
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
routerDelegate: delegate,
routeInformationParser: MyRouteParser(),
);
}
}
注意MyRouteDelegate并不是在build中创建的,而是在初始化时就创建了。如果在build中才创建就会出现上面的问题,如果像上面代码一样在初始化创建就没有这个问题了。
浏览器的回退按钮
经过测试发现,浏览器的后退按钮点击后并不执行pop操作,而是执行setNewRoutePath
,这样就会导致回退的时候实际上_stack
并没有移除当前页面,反而将上一个页面重新添加进来了,这样_stack
路径就乱了。
这个问题有个官方issues:https://github.com/flutter/flutter/issues/71122
其中官方提到:
the browser backward button no longer tie to the didpopRoute in navigator 2.0. it is now acting as deeplinking. Whenever backward or forward button is pressed, the web engine will get the new url and send that to the framework through didpushRoute.
BackButtonDispatcher is for android back button, it will only be triggered in android.
这里涉及的BackButtonDispatcher也是Navigator2.0的功能,可以拦截处理返回键,但是通过上面可以看出这个功能只对android的返回键有效。而在web上,无论是前进还是后退键,都是当初新的url处理,会执行didpushRoute
,所以就执行到了setNewRoutePath
,而不是pop。
issues中也提到了,目前官方没有解决这个问题,不过已经列入todo列表了,目前想要解决这个问题需要我们自己手动开发一个plugin,可能需要在native层处理,即在html中通过history处理并暴露api给flutter,比较复杂,所以目前这个问题并没有很好的解决方法。
有关浏览器后退的问题,可以看我另外一篇博客《Flutter Web中刷新与后退问题》
总结
通过上面可以看出,Navigator2.0相对来说复杂很多,开发和学习成本大大提高,这也是很多人诟病的原因,所以有人认为Navigator2.0是一个失败的改造,这也导致目前大家很少使用它。