准备
关于Flutter路由的一些原理,可以阅读我们之前的文章《Flutter 路由源码剖析》,本文我们主要来学习一下Navigator2.0的用法。
为了演示Navigator2.0的用法,这里准备了一个简单案例,项目下载 访问这里。
nav_demo目录是一个使用Navigator1.0的示例,总共4个页面,分别是:splash
、login
、home
、details
代码结构如下:
核心代码:
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Navigator 1.0',
routes: <String, WidgetBuilder> {
'/login': (_) => const Login(),
'/home': (_) => const Home(),
},
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const Splash(),
);
}
}
Navigator1.0 存在的几个问题:
-
静态路由不方便给构造函数传参,不如动态路由灵活。如不考虑使用第三方路由框架时,常常会存在静态路由和动态路由一起使用的情况。如下代码,点击网格项,拉起详情页
GestureDetector( onTap: () { Navigator.push(context, MaterialPageRoute( builder: (ctx) => Details( _movieList![i].name, _movieList![i].imgUrl))); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ Flexible(child: Image.network(_movieList![i].imgUrl)), Text(_movieList![i].name), ], ), );
-
难以处理Web版上,地址栏URL做路由导航的需求。如下图,切换路由,地址栏没有变化
-
对路由的控制极不灵活,譬如对路由嵌套的场景需求;处理Android返回键需求等
-
编程风格不统一。Navigator 1.0是一种命令式编程范式,而Flutter本身是一种声明式的编程范式。Navigator 2.0回归声明式的范式,更具Flutter的味道。
使用Navigator 2.0重构
这里,我先以一种最少的修改,最简单的使用方式来重构,看看Navigator 2.0是如果使用的
修改代码结构:
仅添加router文件夹,新增delegate.dart
文件。在其中自定义类MyRouterDelegate
继承自RouterDelegate
,并混入ChangeNotifier
和PopNavigatorRouterDelegateMixin
。
这里有三个方法必须实现:
class MyRouterDelegate extends RouterDelegate<List<RouteSettings>> with ChangeNotifier, PopNavigatorRouterDelegateMixin<List<RouteSettings>> {
final List<Page> _pages = [];
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: List.of(_pages),
onPopPage: _onPopPage,
);
}
@override
Future<void> setNewRoutePath(List<RouteSettings> configuration) async {}
/// ………………省略部分代码………………
}
setNewRoutePath
方法可以先留空,主要看一下build
方法。我们创建了Navigator
作为路由的管理者,并设置了两个主要参数pages
和onPopPage
,其中pages
是一个存放Page
对象的列表;当路由被pop
时,onPopPage
会被回调,开发者可在此处理路由退栈的逻辑。
这里,我们真正需要关心的是,Page
对象列表是什么东西?
我们知道,在Flutter中,使用路由这个词来表示App中的页面,路由栈也就是页面栈。2.0提出的这个Page
类,实际上就相当于是一个路由的描述文件。这个思想就类似于我在《Flutter 框架实现原理》一文提到的Flutter的四颗树。Flutter中的所谓Widget
就是一种配置描述,而Element
类就根据这个描述生成的。同理,Page
也是一种描述,用于生成真正的路由对象。
理解了这一点,你就会明白,Navigator2.0不仅没有让路由管理更复杂,反而更简单了。我们只要操作这个Page
列表,相应的路由栈就会感知到,自动发生变化。我们想要哪个页面显示,只需要把它放置到List
的最后一个元素位置即可。Navigator2.0就是把原来对形同黑盒子的路由栈操作变成了一个对列表List
的操作。我们想要改变路由栈中页面的先后顺序,只需要修改List<Page>
中的元素位置。
接下来就需要看一下,如何使用Page
类创建对象。Page
类本身继承自RouteSettings
类,这说明它确实就是一个路由配置文件。它本身是一个抽象类,不能实例化,我们找到了它的两个直接实现类:
这里我们一看就明白了,Flutter已经给我们提供好了实现类,一个是Android的Material
风格,一个是iOS的Cupertino
风格。
直接使用MaterialPage
包装我们写的页面,增加一个方法封装这些逻辑,帮助创建Page
:
MaterialPage _createPage(RouteSettings routeSettings) {
Widget child;
switch (routeSettings.name) {
case '/home':
child = const Home();
break;
case '/splash':
child = const Splash();
break;
case '/login':
child = const Login();
break;
case '/details':
child = Details(routeSettings.arguments! as Map<String, String>);
break;
default:
child = const Scaffold();
}
return MaterialPage(
child: child,
key: Key(routeSettings.name!) as LocalKey,
name: routeSettings.name,
arguments: routeSettings.arguments,
);
}
此处的处理,有些类似于静态路由配置表,但是注意到,我们可以通过RouteSettings
参数,给页面的构造方法传参了,比1.0的静态路由灵活许多。
好了,到这里就只需要写几个方法来操作Page
列表:
/// 压入新页面显示
void push({required String name, dynamic arguments}) {
_pages.add(_createPage(RouteSettings(name: name, arguments: arguments)));
// 通知路由栈,我们的Page列表已经修改了
notifyListeners();
}
/// 替换当前正在显示的页面
void replace({required String name, dynamic arguments}) {
if (_pages.isNotEmpty) {
_pages.removeLast();
}
push(name: name,arguments: arguments);
}
最后,为了能使用Navigator2.0的接口,还要对main.dart
中进行修改。这里,我们为了演示简单,在app.dart
中实例化了一个MyRouterDelegate
的全局变量:
import 'package:nav2_demo/router/delegate.dart';
MyRouterDelegate delegate = MyRouterDelegate();
修改main.dart
,直接引用了这个全局变量:
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key) {
// 初始化时添加第一个页面
delegate.push(name: '/splash');
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Navigator 2.0',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Router(
routerDelegate: delegate,
backButtonDispatcher: RootBackButtonDispatcher(),
),
);
}
}
主要就是声明式的创建了一个Router
,并设置了routerDelegate
属性。这里backButtonDispatcher
不是必须的,但我们这个案例为了演示对返回键的处理,创建了一个默认实现RootBackButtonDispatcher()
。
好了,基本完成。把以前使用Navigator1.0操作路由栈的地方,全部替换成我们自己提供的接口。
// 显示home页
delegate.replace(name: '/home');
包括之前利用动态路由给详情页的构造方法传参的地方:
/// home.dart
GestureDetector(
onTap: (){
delegate.push(name: '/details',arguments: {'name':_movieList![i].name,'imgUrl':_movieList![i].imgUrl});
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Image.network(_movieList![i].imgUrl)),
Text(_movieList![i].name),
],
),
);
详情页接收数据:
class Details extends StatelessWidget {
final String name;
final String imgSrc;
Details(Map<String, String> arguments)
: name = arguments['name']!,
imgSrc = arguments['imgUrl']!;
/// 省略部分代码
}
看出来了吗?我们以类似1.0的静态路由的操作方式,可以轻松的给页面构造传参了。
这就是Navigator2.0的基本使用,大家还觉得难吗?
以下是完整代码,我们还重写了popRoute
方法,用于处理页面退出的逻辑。很多时候,我们不希望用户点返回键时,直接退出应用,以下做了处理,当已经退到根路由页面了,那么我们就弹出一个对话框询问用户是否确定要退出App,如果选择确定再退出,防止误触带来不好的体验。在1.0时的处理,非常不优雅,需要用到WillPopScope
去包装,现在不需要了,直接在popRoute
中即可处理。
class MyRouterDelegate extends RouterDelegate<List<RouteSettings>> with ChangeNotifier, PopNavigatorRouterDelegateMixin<List<RouteSettings>> {
final List<Page> _pages = [];
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: List.of(_pages),
onPopPage: _onPopPage,
);
}
@override
Future<void> setNewRoutePath(List<RouteSettings> configuration) async {}
@override
Future<bool> popRoute() {
if (canPop()) {
_pages.removeLast();
notifyListeners();
return Future.value(true);
}
return _confirmExit();
}
bool canPop() {
return _pages.length > 1;
}
bool _onPopPage(Route route, dynamic result) {
if (!route.didPop(result)) return false;
if (canPop()) {
_pages.removeLast();
return true;
} else {
return false;
}
}
void push({required String name, dynamic arguments}) {
_pages.add(_createPage(RouteSettings(name: name, arguments: arguments)));
notifyListeners();
}
void replace({required String name, dynamic arguments}) {
if (_pages.isNotEmpty) {
_pages.removeLast();
}
push(name: name,arguments: arguments);
}
MaterialPage _createPage(RouteSettings routeSettings) {
Widget child;
switch (routeSettings.name) {
case '/home':
child = const Home();
break;
case '/splash':
child = const Splash();
break;
case '/login':
child = const Login();
break;
case '/details':
child = Details(routeSettings.arguments! as Map<String, String>);
break;
default:
child = const Scaffold();
}
return MaterialPage(
child: child,
key: Key(routeSettings.name!) as LocalKey,
name: routeSettings.name,
arguments: routeSettings.arguments,
);
}
Future<bool> _confirmExit() async {
final result = await showDialog<bool>(
context: navigatorKey.currentContext!,
builder: (context) {
return AlertDialog(
content: const Text('确定要退出App吗?'),
actions: [
TextButton(
child: const Text('取消'),
onPressed: () => Navigator.pop(context, true),
),
TextButton(
child: const Text('确定'),
onPressed: () => Navigator.pop(context, false),
),
],
);
});
return result ?? true;
}
}
用法深入
上述的案例中,我们还是没有解决Web版的问题。当我们在浏览器地址栏输入URL,无法定位到具体的路由页面;当我们切换到具体的路由页面,地址栏的URL也不会同步发生变化。如果你的应用将来要考虑兼容Web版,那么就很有必要继续深入学习Navigator2.0。
要想处理该问题,我们需要自定义一个路由信息解析器:
/// parser.dart
class MyRouteInformationParser extends RouteInformationParser<List<RouteSettings>> {
const MyRouteInformationParser() : super();
@override
Future<List<RouteSettings>> parseRouteInformation(RouteInformation routeInformation) {
final uri = Uri.parse(routeInformation.location!);
if (uri.pathSegments.isEmpty) {
return Future.value([const RouteSettings(name: '/home')]);
}
final routeSettings = uri.pathSegments
.map((pathSegment) => RouteSettings(
name: '/$pathSegment',
arguments: pathSegment == uri.pathSegments.last
? uri.queryParameters
: null,
))
.toList();
return Future.value(routeSettings);
}
@override
RouteInformation restoreRouteInformation(List<RouteSettings> configuration) {
final location = configuration.last.name;
final arguments = _restoreArguments(configuration.last);
return RouteInformation(location: '$location$arguments');
}
String _restoreArguments(RouteSettings routeSettings) {
if (routeSettings.name != '/details') return '';
var args = routeSettings.arguments as Map;
return '?name=${args['name']}&imgUrl=${args['imgUrl']}';
}
}
这里有两个方法需要实现,分别是
-
parseRouteInformation
:帮助我们将一个URL地址转换成路由的状态(即配置信息) -
restoreRouteInformation
:帮助我们将路由的状态(配置信息)转换为一个URL地址
可见,这两个方法的功能正好相反,并且刚好对应我们的两个需求:输入URL切换相应路由页面;操作路由页面,URL同步变化。
具体而言,parseRouteInformation
方法接收一个RouteInformation
类型参数,它描述了一个URL的信息,它包含的两个属性分别是字符串location
和动态类型state
。location
就是URL的path
部分,state
是用来保存页面中的状态的,例如页面中有一个输入框,并且输入框中输入了内容,保存到state
中,下次恢复页面时,数据也可以得到恢复。弄清楚了这个方法的参数,上面的代码实现就很好理解了,我们将URL的path
解析成Uri
类型,这比直接操作字符串path
要更方便,然后根据这些path
信息,生成对应的路由配置RouteSettings
并返回。
restoreRouteInformation
方法的逻辑更加简单,它接收一组路由配置信息做参数,我需要根据当前的这些路由配置信息,组合生成一条URL,并封装成RouteInformation
对象返回。这里返回的URL正是用于更新浏览器的地址栏的URL。
到这里,我们的路由信息解析器就写好了,现在需要在MyRouterDelegate
中添加代码:
@override
List<Page> get currentConfiguration => List.of(_pages);
@override
Future<void> setNewRoutePath(List<RouteSettings> configuration) {
debugPrint('setNewRoutePath ${configuration.last.name}');
_setPath(configuration
.map((routeSettings) => _createPage(routeSettings))
.toList());
return Future.value(null);
}
void _setPath(List<Page> pages) {
_pages.clear();
_pages.addAll(pages);
if (_pages.first.name != '/') {
// _pages.insert(0, _createPage(const RouteSettings(name: '/')));
}
notifyListeners();
}
首先需要重写一个get
方法 currentConfiguration
,其实现就是返回我们的Page
列表,接着实现我们之前留空的setNewRoutePath
方法。
前面在路由信息解析器中实现的parseRouteInformation
被调用后,就会接着回调这里的setNewRoutePath
方法,很明显,parseRouteInformation
方法的返回值正是被转发到setNewRoutePath
方法中的参数。我们在parseRouteInformation
方法中完成了对URL的解析并生成了一组路由配置信息,现在这组配置信息被转发到了setNewRoutePath
中,这意味着我们需要在setNewRoutePath
中,将这组路由配置信息生成对应的Page
对象,并插入到当前的Page
列表,最终实现路由栈更新。整个流程概括成一句话,就是外部输入的一条URL,最终导致App内路由页面的生成和更新。
最后,修改main.dart
,设置我们的路由信息解析器:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key) {
delegate.push(name: '/splash');
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Navigator 2.0',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
routerDelegate: delegate,
routeInformationParser: const MyRouteInformationParser(),
);
}
}
这里,我们直接替换MaterialApp
提供的新构造方法router
。使用该构造方法可以省略我们之前设置的RootBackButtonDispatcher
。
切换App路由,地址栏也同步更新:
输入URL,App内导航到相应路由:
完美! 完整源码访问 nav2_demo
使用小结
- 实现
RouterDelegate
:它是路由的代理,我们要使用Navigator 2.0的接口来管理路由,必须实现该类。事实上,它也是一个被观察者,它内部管理了Page
列表,仅修改Page
列表并不会导致底层路由栈的真正更新,因此我们需要混入ChangeNotifier
,操作完Page
列表后,还要调用notifyListeners()
通知观察者数据改变,触发底层路由栈的真正更新。我们的案例代码中,还混入了PopNavigatorRouterDelegateMixin
,主要是为了重写它的popRoute
方法,自定义退栈逻辑。 - 实现
RouteInformationParser
:它是一个路由信息解析器。实现它的主要目的是为了处理URL。
注意,Navigator 2.0的接口与状态管理框架结合起来效果更好,本文为了简单,并未与状态管理框架结合。
总的来说,Navigator 2.0并不复杂,它使得Flutter的路由管理更加方便灵活,第三方开发者可以基于此机制,建立一套业务高度封装的框架,实现远程的动态的路由导航。
关注公众号:编程之路从0到1
或关注博主的视频课程