1、Flutter路由
几乎所有 UI 框架开发出来的大型应用都是由数十甚至数百个页面组成。在 Android 项目中,每个页面都被承载在一个 Activity 中,因此一个 Activity 可以被认为是 Android 应用中的一个页面。在 Flutter 中,每个页面都对应一个 Route 对象,需要注意的是,弹窗也是 Route 的一种表现形式。Flutter 通过 Navigator 以栈结构来管理所有打开页面对应的 Route 对象。当一个页面打开时,对应的 Route 对象就被压入栈中;当一个页面关闭时,对应的 Route 对象就会从栈中弹出。
虽然 Flutter 中页面和弹窗都是用 Route 表示,但是两者之间的交互和表现形式存在明显的区别。为了保证清晰的代码结构和良好的可维护性,Flutter 按照表现形式通过类继承的方式对 Route 相关类进行了划分,具体如下:
在 Flutter 应用中,Overlay 扮演了至关重要的角色,它负责在视图上正确地显示页面和弹窗。要实现这一目的,我们需要将 Route 加载到 Overlay 中,而 OverlayRoute 就是用于实现这一目的的重要类之一。在 Flutter 应用中,通常会使用 MaterialApp 作为根节点,而 MaterialApp 中会内嵌一个 Navigator 对象,用于管理页面的显示与隐藏。同时,Navigator 内部还嵌套了 Overlay Widget,用于显示 OverlayRoute 对象。
当Overlay中的Route进行切换时,TransitionRoute是一个提供Route切换动画效果的抽象类,通过配合使用SlideTransition、FadeTransition等Widget,控制页面打开或关闭时的动画。
保证所有的手势事件都被当前的ModalRoute处理,其底层的Route无法感知任何手势事件。
对应Flutter中的页面,适配各平台的页面交互特性,如iOS系统页面可侧滑退出。
对应Flutter中的弹窗,支持点击弹窗外部区域退出等特性。
在Flutter开发中,可以通过以下三种方式打开页面,使用示例如下:
import 'package:flutter/material.dart';
void main() {
runApp(Nav2App());
}
class Nav2App extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: FlatButton(
child: Text('open Details'),
onPressed: () {
Navigator.push( //1、打开详情页
context,
MaterialPageRoute(builder: (context) {
return DetailScreen();
}),
);
},
),
),
);
}
}
class DetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: FlatButton(
child: Text('Back'),
onPressed: () {
Navigator.pop(context); //2、关闭详情页
},
),
),
);
}
}
当 push() 被调用时,DetailScreen 页面被放置在 HomeScreen 页面的前面,此时与用户交互的页面是最顶层的DetailScreen页面,效果如下:
import 'package:flutter/material.dart';
void main() {
runApp(Nav2App());
}
class Nav2App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp( //1、注册路由表
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailScreen(),
},
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: FlatButton(
child: Text('open Details'),
onPressed: () {
Navigator.pushNamed(
context,
'/details', //2、通过路由表中路由名称,打开相应页面
);
},
),
),
);
}
}
在使用命名路由前,需要提前以name- Page键值对的形式将路由表注册到Navigator中。在进行路由跳转的时候,通过name即可打开路由表中对应的页面。相比组件路由的方式,使用命名路由打开页面代码简洁了不少。
import 'package:flutter/material.dart';
void main() {
runApp(Nav2App());
}
class Nav2App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateRoute: (settings) { //2、通过路由名称,生成对应路由对象
if (settings.name == '/') {
return MaterialPageRoute(builder: (context) => HomeScreen());
}
var uri = Uri.parse(settings.name);
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'details') {
var id = uri.pathSegments[1];
return MaterialPageRoute(builder: (context) => DetailScreen(id: id));
}
return MaterialPageRoute(builder: (context) => UnknownScreen());
},
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: FlatButton(
child: Text('open Details'),
onPressed: () {
Navigator.pushNamed(
context,
'/details/1', //1、传入路由名称,打开页面
);
},
),
),
);
}
}
虽看上去生成路由与命名路由打开页面的方式一样,都是通过路由标识字符串的形式打开对应的页面。但命名路由需要提前将路由表提前注册到Navigator中,而生成路由在页面进行跳转时,临时解析路由标识字符串,并确定需要打开的页面。
在使用上,生成路由要比命名路由灵活,但是后期代码维护成本,代码结构清晰度却远不如命名路由。
通过对以上三种路由跳转方式的说明,命名路由在使用的简洁度以及代码结构清晰度上,更愿意被大部分项目所接受使用。
项目大多采用命名路由的方式对路由进行统一管理,但是由于命名路由需要提前将路由表注册到Navigator中,所以每当新增一个页面,就需要往路由表中添加一个路由配置,例如:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
...
initialRoute: '/home',
routes: {
'/home': (context) => HomePage(), //路由注册
'/a': (context) => APage(),
'/b': (context) => BPage(),
},
);
}
}
通过分析上面路由注册配置代码,如果页面想通过命名路由的方式打开,则页面需要提前注册到Navigator中。随着项目规模变大,页面逐步增加,在MyApp中注册路由,问题也愈发明显:
耦合度:随着页面不断新增,路由注册代码也随之追加,MyApp类变得越来越臃肿,类中充斥着大量对其他类的引用。
模块化:由于路由注册逻辑统一在MyApp类中,不同模块的路由不能单独管理控制,完全的扁平化:
针对项目路由管理高耦合、无法模块化的问题,假设我们项目路由结构做出如下调整:
1、将项目中的所有页面按照模块进行划分,每个模块内部完成页面注册,使得不同模块的路由管理相互独立,方便开发人员进行单独的模块开发和维护。
2、通过App下的Navigator完成模块注册,方便地实现不同模块之间的页面跳转,使得Flutter应用程序的结构更加清晰,易于扩展和维护。
Flutter中的mixin机制是一种代码重用的技术,可以帮助开发者在不使用继承的情况下将代码的功能注入到其他类中。mixin可以看作是一种将一组函数、属性和其他代码注入到类中的方式,以实现代码复用。
下面是一个简单的例子,其中我们创建了一个名为 Runner 的 mixin,它包含了一个run()
函数:
mixin Runner {
void run() {
print("I'm Running!");
}
}
然后我们定义了一个类Person
,它使用了Runner
mixin:
class Person with Runner {
String name;
Person(this.name);
}
现在,我们可以使用Person类的实例并调用其run()
函数,因为Person
类已经将 Runner
mixin 中的函数注入到了自身中:
void main() {
var Person = Person("xiaoying");
person.run(); // Output: "I'm Running!"
}
需要注意的是,mixin机制并不是继承,而是一种注入代码的方式。因此,它可以避免一些继承带来的问题,比如多重继承的复杂性。同时,mixin机制也使得代码更加灵活,可以组合不同的功能,以满足不同的需求。
源码WidgetsFlutterBinding通过mixin机制来管理和协调不同模块工作,使得Flutter框架在不同平台下表现更为稳定和高效。同样,我们也可以利用这一机制,将不同的路由管理模块进行组合,并注册到App的Navigator中,实现不同模块之间的路由管理相互独立,具体代码如下:
class MixinRouterContainer {
Map<String, WidgetBuilder> installRouters() => {};
Future<T?>? openPage<T>(BuildContext context, String pageName ...}) {
...
return Navigator.pushNamed(context, pageName, arguments: args);
...
}
}
在基类中仅定义了两个方法:
installRouters: 注册该模块下的所有页面。
openPage:打开该模块下注册的页面
//设置模块
mixin SettingRouteContainer on MixinRouterContainer {
@override
Map<String, WidgetBuilder> installRouters() {
Map<String, WidgetBuilder> originRoutes = super.installRouters();
Map<String, WidgetBuilder> newRoutes = {};
newRoutes['/setting_a'] = (context) => APage(); //注册A页面
newRoutes['/setting_b'] = (context) => BPage(); //注册B页面
newRoutes.addAll(originRoutes);
return newRoutes;
}
}
//大厅模块
mixin HomeRouteContainer on MixinRouterContainer {
@override
Map<String, WidgetBuilder> installRouters() {
Map<String, WidgetBuilder> originRoutes = super.installRouters();
Map<String, WidgetBuilder> newRoutes = {};
newRoutes['/home'] = (context) => HomePage(); //注册大厅页面
newRoutes.addAll(originRoutes);
return newRoutes;
}
}
不同模块的路由管理都通过mixin RouterContainer,并将模块内部的页面注册到其中。例如,HomeRouteContainer 将 HomePage 添加到自己的路由表中,SettingRouteContainer 管理 SettingA 和 SettingB 两个页面。不同的路由模块都能够独立管理自己模块内的页面,从而实现了路由模块的高度解耦。
//1、创建总路由管理类,并通过mixin机制将各个路由模块进行粘合,即:
// AppRouteContainer 将 HomeRouteContainer 和 SettingRouteContainer... 组装
class AppRouteContainer extends MixinRouterContainer with HomeRouteContainer, SettingRouteContainer {
AppRouteContainer._();
static AppRouteContainer _instance = AppRouteContainer._();
static AppRouteContainer get share => _instance;
}
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
...
initialRoute: '/home',
// 2、将总路由注册到App中
routes: AppRouteContainer.share.installRouters(),
);
}
}
通过创建一个总路由管理类,利用mixin机制将不同的路由模块组合起来,然后将其注册到App的Navigator中。在代码中,我们可以看到AppRouteContainer通过组合大厅路由模块(HomeRouteContainer)和设置路由模块(SettingRouteContainer),实现了路由模块的高度解耦。为了方便在项目中使用总路由管理类,我们将其改写为单例模式。在使用总路由管理类时,只需要了解以下两个方法:
路由表注册:AppRouteContainer.share.installRouters()
页面跳转:AppRouteContainer.share.openPage(context, '/setting_a')
在项目开发中,路由拦截是一个常见的需求。例如,当用户尚未登录时,如果想打开个人主页,需要拦截这一过程并将用户重定向到登录页面。
为了解决这个问题,可以在现有的路由管理模块的基类(MixinRouterContainer)上再封装,添加拦截路由表并重写路由跳转过程来实现。
//定义路由拦截回调函数
typedef MixinRouteInterceptor = bool Function(BuildContext context, String pageName, ...);
//对MixinRouterContainer进行封装
class MixinRouterInterceptContainer extends MixinRouterContainer {
//添加拦截路由表逻辑
final Map<String, MixinRouteInterceptor> _routeInterceptorTable = {};
void registerRouteInterceptor(String pageName, MixinRouteInterceptor interceptor) {
_routeInterceptorTable[pageName] = interceptor;
}
void unRegisterRouteInterceptor(String pageName) {
_routeInterceptorTable.remove(pageName);
}
//重写路由跳转过程
@override
Future<T?>? openPage<T>(BuildContext context, String pageName,...) {
//拦截跳转
if (!_routeInterceptorTable.containsKey(pageName)) {
return super.openPage(context,pageName,...);
}
MixinRouteInterceptor interceptor = _routeInterceptorTable[pageName]!;
bool needIntercept = interceptor.call(context,pageName,...);
if (needIntercept) {
return Future.value(null);
} else {
return super.openPage(context,pageName,...);
}
}
}
该类增加了拦截路由配置的注册和反注册逻辑,在进行页面跳转时,判断当前路由是否能被拦截,如果是,则会拦截后续的页面跳转逻辑,并执行拦截相关的处理工作,否则将会继续进行页面跳转。
下面我们来改造大厅路由管理模块,使之能处理登录拦截,代码如下:
mixin HomeRouteContainer on MixinRouterInterceptContainer {
@override
Map<String, WidgetBuilder> installRouters() {
//注册拦截路由表
registerRouteInterceptor('/home', (...) => if(!isLogin) openLoginPage());
Map<String, WidgetBuilder> originRoutes = super.installRouters();
Map<String, WidgetBuilder> newRoutes = {};
newRoutes['/home'] = (context) => HomePage();
newRoutes.addAll(originRoutes);
return newRoutes;
}
}
对于很多项目来说,为了能够通过外链打开对应的页面,常常采用URL统一跳转。为了实现这个功能,只需要对现有的总路由管理类AppRouteContainer进行扩展,代理默认的页面打开行为,实现URL的解析。
class AppRouteContainer extends MixinRouterContainerwith HomeRouteContainer, SettingRouteContainer {
...
Future<T?>? urlToPage<T>(BuildContext context, String urlStr, ...) {
//1、解析URL
Uri? url = Uri.tryParse(urlStr);
if (url == null) return Future.error('parse url fail');
Map<String, String> args = {};
args.addAll(url.queryParameters);
args['_url'] = urlStr;
String pageName = url.host;
//2.通过HOST作为路由名称,打开对应页面
super.openPage(context,'/' + pageName ...);
}
}
在项目中,可以通过添加 urlToPage(...) 方法来对 openPage(...) 方法进行封装。通过调用 AppRouteContainer.share.urlToPage(...) 并传入 URL 字符串,该方法会对传入的URL进行解析,并提取出 HOST 和参数。HOST 作为路由名称,并将参数传递给 openPage 方法,从而打开对应的页面。这样就可以实现通过 URL 统一跳转到对应的页面。
经过以上的路由改造,是否就可以说路由的问题解决了呢?然而,仔细回顾之前的改造过程,我们会发现还存在一些问题:
页面注册:仍需要手动注册新的页面到对应的模块类中。
路由模块管理:还需要手动创建并维护不同的路由管理模块,如 HomeRouteContainer、SettingRouteContainer
客户端原生项目中,ARouter通过注解生成路由管理文件可以省去手动创建和维护不同的路由管理模块的麻烦。Flutter可以借鉴这种方式,使用注解来自动生成对应的路由模块管理文件。这样做的好处是可以提高开发效率,降低出错率,同时也可以避免代码冗余和重复的工作。另外,注解方式可以让开发者更加专注于业务逻辑的开发,而不用花费太多精力在路由管理的维护上。示例代码如下:
const String HOME_ROUTE_TABLE = 'HomeRouteTable';
const String SETTING_ROUTE_TABLE = 'SettingsRouteTable';
//tDescription: 仅仅作为生成类的注释
(
tableList: [
RouterTable(tName: HOME_ROUTE_TABLE, tDescription: '大厅路由模块'),
RouterTable(tName: SETTING_ROUTE_TABLE, tDescription: '设置路由模块'),
],
)
class AppRouteContainer extends MixinRouterInterceptContainer
with HomeRouteTable, SettingsRouteTable {
AppRouteContainer._();
static AppRouteContainer _instance = AppRouteContainer._();
static AppRouteContainer get share => _instance;
}
//将页面注册到 SettingsRouteTable 模块中,并指定页面的路由名称
'/setting_a') (tName: SETTING_ROUTE_TABLE, path:
class APage extends StatelessWidget {
const APage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Text('APage'),
);
}
}
'/setting_b') (tName: SETTING_ROUTE_TABLE, path:
class BPage extends StatelessWidget {
const BPage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Text('BPage'),
);
}
}
//注解拦截路由函数
'/setting_b') (tName: SETTING_ROUTE_TABLE, path:
bool interceptorMinePage(context, pageName, pushType, {arguments, predicate}) {
print('toLogin');
return true;
}
编写一个顶层函数,并通过 MixinInterceptRoute 进行注解,该函数签名具体如下:
bool Function(BuildContext context, String pageName, String pushType, {Map<String, dynamic>? arguments, bool Function()? predicate});
BuildContext context
:BuildContext对象,表示当前BuildContext。
String pageName
:字符串类型,表示需要拦截的页面名称。
String pushType
:字符串类型,表示跳转类型,如push
、pushNamed
、pushReplacement
等。
Map<String, dynamic>? arguments
:可选的Map类型,表示传递给目标页面的参数。
bool Function()? predicate
:可选的bool类型回调函数,控制页面打开策略。
函数返回结果代表本次拦截是否消费原本的页面跳转,如果返回true,则继续执行后续页面打开操作,否则终止后续跳转逻辑。
在项目的pubspec.yaml中添加依赖,即可开启注解路由之旅
dependencies:
flutter:
sdk: flutter
flutter_mixin_router: ^1.0.0 # 添加路由模块管理基类
flutter_mixin_router_ann: 1.0.0 # 添加注解类
dev_dependencies:
build_runner: 2.1.8 # 添加依赖
flutter_mixin_router_gen: 1.0.1 # 添加代码生成工具库
在项目页面上添加对应的注解后,执行以下命令生成对应的路由代码
# 清除增量编译缓存
flutter packages pub run build_runner clean
# 重新生成代码
flutter packages pub run build_runner build --delete-conflicting-outputs
路由注册相关的代码可以被分模块管理,这使得项目中的App入口类代码行数从1500行缩减到200行以内,项目的代码结构更加清晰,降低了代码的维护难度,提高代码的可读性和可维护性。
不同的开发人员负责开发不同的模块,如果路由相关的代码没有被分模块管理,那么就容易出现代码冲突的问题,在使用 flutter_mixin_router
后,路由相关的代码合并冲突几乎不再发生,提高代码的稳定性,从而降低了项目出错的概率。
使用注解可以省去编写路由注册相关的代码,提高了代码的简洁性。由于开发人员可以更加专注于业务逻辑的实现,从而提高了开发效率。
使用 flutter_mixin_router
可以让开发人员更专注于业务逻辑的实现,快速地迭代开发,提高项目的上线速度;模块化的路由管理,能够有效地应对项目规模的增长,并保持代码的一致性和可维护性。
希望该框架的持续维护和更新也能够为团队提供更多的功能,满足不断增长的业务需求。