三分钟让Flutter路由实现SingleTask启动模式
Flutter 的 路由启动模式实现思路
前言
如果你是 Android 开发者,请放心食用,模仿 Android 的页面启动模式封装的,如果是其他端的开发者呢,可以略过分析与思路直接文章末尾拿走代码即可。
事情是怎么一回事呢?有时候我们想跳转到一个页面,不知道是用 to ,还是用 until ,如果要跳转到指定页面并关闭之前的页面,也不知道用 off 还是用 until 。
因为我们只能写前进的跳转还是后退的跳转,他不支持像 Android 那样的 SingleTop SingleTask 的启动方式。
这里举例一个场景大家就明白了。
场景:假如我现在收到推送了,点击通知栏需要跳转到 Flutter 的页面,我们要根据业务逻辑选择性的跳转到通知页面或者首页。
那么怎么跳转?如果已经在通知页面或者已经在首页了,又如何跳转?如果在首页或者通知页面的二级页面又该如何跳转?
因为我们不确定首页在不在,不确定通知页面在不在,如果是写 Android 应用,那简单了,直接设置启动模式 SingleTask ,它就会自动把通知页面之前的栈全部清掉。
那么 GetX 或者说原生的 Navigator 能不能实现类似的功能呢?
一、GetX的路由跳转
我们都知道,其实 GetX 的路由跳转也是基于 Navigator 的封装,本身并没有持有路由栈对象,本质上还是 NavigatorState 内部持有路由栈。
关键的关键是 _RouteEntry 与 _history 路由栈都是私有的,我们无法通过重写或扩展方法来拿到路由栈从而实现自定义功能。
但是我们可以通过了解 NavigatorState 中几种原生跳转方法的原理,可以曲线救国实现类似 SingleTask 的功能。
这里先从 GetX 的几种路由跳转方法介绍:
1. 直接跳转
//导航到新页面
Get.to(NextScreen()); //一个是自己new对象
Get.toNamed(RouterPath.NEXT); //一个是通过Name标识
这两个方法不用说,最基本的方法,我们实际开发用 Get.toNamed(RouterPath.NEXT)
的方式更多一些。
本质上其实调用的是
Navigator.of(context).pushNamed(...)
效果就是不管3721开启一个新页面,不管这个页面是否已经存在。
以我们推送跳转的例子来说,假如我们现在就在消息通知页面,点击推送通知栏跳转到消息通知页面。那么此时的效果就是创建了一个新的消息通知页面。
此时返回上一级页面结果发现还是消息通知页面,并且两个页面的内容效果是一致的,因为使用的是同一个Controller,同一个State。(如果想开同一个页面不同的效果需要使用tag,这又是另一个故事了,不多介绍)
2. 直接跳转并关闭当前
//进入下一个页面并取消之前的所有路由
Get.offAll(NextScreen()); //一个是自己new对象
Get.offAllNamed(RouterPath.NEXT); //一个是通过Name标识
关于 offAll 的这一组,其实就是关闭全部的页面再跳转到指定的页面。
本质上和原生的这样写法没什么区别
Navigator.of(context) .pushNamedAndRemoveUntil(RouterPath.NEXT, (Route route) => false, arguments: arguments)
效果就是管你3721,先把你全部页面关闭了再说,然后再帮你开启一个新的页面。
以我们推送跳转的例子来说,假如我们现在从首页跳转到了消息通知页面,点击推送通知栏跳转到消息通知页面。那么此时的效果就是把首页和消息通知页面全部关闭了,然后又重新开启了一个新的消息通知页面,然后需要重新 Loading 加载数据。
然后返回消息通知页面之后直接退出应用
了,你敢信?这神仙操作!
3. 直接跳转并关闭多个指定条件路由
另外就是 offUntil 这一组:
Get.offUntil(NextScreen(), (route) => false);
Get.offNamedUntil(RouterPath.NEXT, (route) => route.settings.name == RouterPath.NEXT);
它其实是类似 offAll 那一组的,只是 offAll 是关闭全部的页面,而 offUntil 这一组就是可以自定义表达式,内部可以写一些表达式,当条件返回 true 时,停止移除路由。
但是还是会先添加再移除,比如我们从首页跳转到登录页面, 当登录成功之后需要从登录页面返回首页。
Get.offNamedUntil(RouterPath.MAIN, (route) => route.settings.name == RouterPath.MAIN);
我们如果用这一种方法,那么就是把 RouterPath.MAIN 理由之前的路由全部清除,理论上是可以达到 SingleTask 的效果,但是它确又添加了一个 MainPage,导致目前页面上有两个 MainPage 了。不符合我们的预期。
那我们用 offAll 呢?
Get.offAllNamed(RouterPath.MAIN);
确实是能跳转到新的首页了,但是它的逻辑是先清除全部的路由栈,然后再添加一个新的 MainPage ,这样就导致我的 MainPage 需要重新加载了,之前保存的状态都没了,无法接受!
至于直接用 toNamed 那肯定也是不行,因为也会又创建一个 MainPage 页面。
怎么办,还剩下几个Get路由看看再说。
4. 跳转页面并关闭当前页面
off 这一组和我们 Android 的一些页面跳转类似 startWithPop 的逻辑。
Get.off(NextScreen()); //一个是自己new对象
Get.offNamed(RouterPath.NEXT); //一个是通过Name标识
本质上它是调用了原生 Navigator 的 pushReplacementNamed 。也就是把之前的页面替换掉,从而实现跳转并关闭页面的效果。
5. 返回
Get.back();
//相当于SetResult给上一个页面传递数据
Get.back(result: 'success');
Get.until((route) => route.settings.name == RouterPath.MAIN)
back本质是调用了 Navigator 的 pop 方法,这个就不多介绍了,返回页面可以选择携带参数返回。
until本质是调用了 Navigator 的 popUntil 方法,就可以返回到指定的页面。
那么我们回到我们之前的首页与登录页面,登录成功之后从登录页面返回到首页该怎么写?
我们可以使用
Get.back();
Get.until((route) => route.settings.name == RouterPath.MAIN)
都可以实现返回的功能,反正我们知道 MainPage 在吗,那确实是可以直接返回,但是如果 MainPage 不在呢?比如退出登录之后我们把全部的路由清掉了跳转到登录页面。
那么此时你登录成功之后逻辑如果还是 back 和 until 那岂不是登录成功直接退出应用?太傻了吧。
这其实是和文章开头推送跳转的逻辑是一样的了,我不知道我要跳转的页面在不在,所以我不知道要前进还是返回。
那么怎么解决这个问题呢?难道只能用最傻的方法,全部关闭再开启页面?
二、GetX的自定义路由启动模式
2.1 不靠谱方案一
那么网上有没有什么好的解决方案了,我看了下也是推荐使用 Navigator 的 pushNamedAndRemoveUntil 来实现的。
其实我们看 pushNamedAndRemoveUntil 最终的实现源码就可以得知,它是先把我们的目标路由存入,然后在执行表达式关闭到指定的条件的页面。
比如我们要从登录跳转到首页,那么就是开启一个首页并且关闭到首页。
Get.offNamedUntil(RouterPath.MAIN, (route) => route.settings.name == RouterPath.MAIN);
此时的结果就是同时存在两个首页。那么我们这里先 add 的路由是在栈顶的,我们可以不可以直接 navigator.pop 直接把刚添加的路由出栈不就行了?
这骚操作也行?你别急,这还真行!
在首页存在的情况下确实可以比较完美的实现 SingleTask 的逻辑。当然这是在首页存在的情况下才能实现的。
我心想我要是知道首页存在了,我直接 Get.until((route) => route.settings.name == RouterPath.MAIN)
不香吗?
最后这种方案,它还是不支持自动的判断前进和后退,如果是后退的勉强能用,但是如果是前进的你加上back之后就无法正常使用了。
2.2 不靠谱方案二
突发奇想,我们能不能让首页保持单例,这样不就可以了?像 Activity 在清单文件中指定 SingleTop 一样。
修改代码如下:
class MainPage extends StatefulWidget {
static const MainPage _instance = MainPage._internal();
factory MainPage() {
return _instance;
}
const MainPage._internal();
static MainPage get instance => _instance;
@override
State<MainPage> createState() => _MainPageState();
}
在路由中,我们统一提供我们单例对象
...
GetPage(
name: RouterPath.MAIN,
page: () => MainPage.instance,
binding: MainBinding(),
),
...
我们在登录页面返回到首页,看似没有初始化 MainPage,这只是因为他是单例了,但是还是会两个MainPage。
天真!
这种方案本质上并没有修改什么,只是修改了单例页面,展示了同样的一个目标页面,还是会有目标页面是否存在的判断问题。
2.3 不靠谱方案三
既然最后还是要判断目标页面是否已经存在,我们直接自定义一个方法暴露不就行了吗,为了方便我们可以使用扩展方法扩展 Get 与 NavigatorState 直接暴露方法。
通过源码我们发现,路由的跳转与之对应的入栈,出栈是由 Navigator 中的 NavigatorState 持有的。
history 对象,持有当前栈对象 RouteEntry 也就是我们的路由实体。
那么我们的判断方法就可以这么写:
extension GetRouterNavigation on GetInterface {
bool hasRouterByName({int? id, required String routerName}) {
return global(id).currentState?.hasRouterByName(routerName) ?? false;
}
}
extension RouterNavigator on NavigatorState {
bool hasRouterByName(String routerName) {
final Iterator<_RouteEntry> iterator = _history.where(_RouteEntry.isPresentPredicate).iterator;
if (!iterator.moveNext()) {
return false;
}
if (iterator.current.route.settings.name == routerName) {
return true;
}
if (!iterator.moveNext()) {
return false;
}
return false;
}
}
但是 history 和 RouteEntry 都是私有的,我们就算用扩展方法也无法访问。查看其他的核心执行方法都是私有方法,无法修改。
额,虽然实现不了,但是这个思路是对的,我们查询当前的路由栈,查询目标路由是否已经存在,如果存在就使用后退的跳转方法,如果不存在就使用前进的跳转方法。
2.4 靠谱方案四
既然 Navigator 不暴露路由栈给我们访问与查询,那么我们能不能自己实现一个路由栈?
基于 Get 实现的话有什么方案?
- 拦截 Get 的全部路由方案,启动的时候添加到自己的路由栈中,关闭的时候移除?
不靠谱!因为如果是返回的话,例如 until 是可以使用表达式返回多个页面的,就无法准确的记录路由表数据。
- 重写 GetController ?创建的时候添加到路由栈?销毁的时候移除路由栈?
不靠谱,只说一点,有些 Controller 是几个页面或者不同的页面一起持有的,那么 Controller 就无法准确的记录路由表数据。
那怎么办?咦?我们 Android 的页面栈,我们不是使用的一个 ActivityManager 来管理页面栈的吗?
我们 Activity 是在 Application 的 ActivityLifecycleCallbacks 监听中获取到 Activity 的创建与销毁中进行添加栈与移除栈的操作吗?
那 Flutter 有没有类似的监听呢?咦?我们在前文中不是有原生 Navigator 的监听中处理 GetX 的路由的兼容设置吗?
我们在里面进行页面的添加栈与移除栈操作行不行呢?试试!
...
navigatorObservers: [GetXRouterObserver()],
...
具体的监听器实现:
class GetXRouterObserver extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
RouterReportManager.reportCurrentRoute(route);
MyRouterHistoryManager().putRouterByName(route.settings.name);
Log.d('添加之后-当前的My路由表:${MyRouterHistoryManager().routeNames.toString()}');
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) async {
RouterReportManager.reportRouteDispose(route);
MyRouterHistoryManager().removeRouterByName(route.settings.name);
Log.d('Pop之后-当前的My路由表:${MyRouterHistoryManager().routeNames.toString()}');
}
@override
void didRemove(Route route, Route? previousRoute) {
MyRouterHistoryManager().removeRouterByName(route.settings.name);
Log.d('Remove之后-当前的My路由表:${MyRouterHistoryManager().routeNames.toString()}');
}
@override
void didReplace({Route? newRoute, Route? oldRoute}) {
MyRouterHistoryManager().putRouterByName(newRoute?.settings.name);
MyRouterHistoryManager().removeRouterByName(oldRoute?.settings.name);
Log.d('Replace之后-当前的My路由表:${MyRouterHistoryManager().routeNames.toString()}');
}
}
我们需要监听各种的条件,因为 Navigator 有这么几种操作 push pop popUntil pushReplacementNamed 等操作,对应的就是上面的几种回调。
而 GetX 本质是调用的这几种 Navigator 方法,所以完全是可用的。
剩下的路由栈管理的单例类如下:
class MyRouterHistoryManager {
static final MyRouterHistoryManager _instance = MyRouterHistoryManager._internal();
factory MyRouterHistoryManager() {
return _instance;
}
MyRouterHistoryManager._internal();
final List<String?> _routeNames = [];
void putRouterByName(String? routeName) {
if (routeName != null) {
_routeNames.add(routeName);
}
}
@override
void removeRouterByName(String? routeName) {
if (routeName != null && _routeNames.contains(routeName)) {
_routeNames.remove(routeName);
}
}
//获取到全部的RouterName数组
List<String?> get routeNames => _routeNames;
//查询当前栈中是否存在指定的路由名称
bool isRouteExist(String routeName) {
return _routeNames.contains(routeName);
}
}
我们可以试验一下各种情况
Get.offNamed(RouterPath.MAIN);
效果:
Get.offNamed(RouterPath.AUTH_SIGNUP)
Get.back()
多次页面返回 Get.until((route) => route.settings.name == RouterPath.MAIN);
貌似没什么问题?那我们就可以拿到目标页面是否存在栈中,就能解决是前进跳转还是后退跳转,也就能实现 SingleTask 的逻辑啦。
剩下的就简单啦,我们模仿 GetX 的路由跳转规则写一个 SingleTask 启动模式:
extension GetRouterNavigation on GetInterface {
/// 查询指定的RouterName是否存在自己的路由栈中
bool isRouteExist(String routerName) {
return MyRouterHistoryManager().isRouteExist(routerName);
}
/// 跳转页面SingleTask模式
void toNamedSingleTask(
String routerName, {
dynamic arguments,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
if (isRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.offNamed(routerName, arguments: arguments, parameters: parameters)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
}
这样不管是从首页跳转到登录页面(ToNamed),还是从登录页面跳转到注册页面(toNamedSingleTask),还是从注册页面跳转到首页(toNamedSingleTask),基本上囊括了前进跳转也后退跳转的场景:
后记
本文是从发现问题,到解决问题,期间踩过的坑与最终实现的思路记录。
最终的实现思路还是参考 Android 开发的思路,自己实现路由栈的管理,由于我的功能比较简单,并没有在栈中做跳转,清除栈等操作。
这般实现还有一个好处就是并不局限与 GetX 框架,支持原生的路由的。并且后续还能继续扩展,比如 SingleTop 的启动模式。例如目前在首页,按下 Home 键之后点击推送可以回到 Home 键,此时需要 SingleTop 的启动模式。
如何实现?我心里大概有思路了,但是我们没有做到这一步,目前没这个需求,后期有时间的话我会讲一下如何实现 SingleTop 的启动模式。
由于我不知道知道其他人是怎么实现的,可能我资质愚钝并没有在网上找到什么好的方案,所以自己硬着头皮简单的实现了一下。
诚惶诚恐!如果已经有好的实现方式,还请告知大家一起交流,如本文讲的错漏的地方,希望同学们可以评论区指出。
本文最终代码在文章末尾,有兴趣的可以复制代码进行试验,都是比较简单的工具代码,就没有封装库了,关于后续我也会持续分享一些实际开发中 Flutter 的踩坑与其他实现方案思路,有兴趣可以关注一下。
如果感觉本文对你有一点点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力啦。
Ok,这一期就此完结。
转载自:https://juejin.cn/post/7281488026832158781