本文主要记录Pop路由的探索过程,内含大量源码截图,讲解有可能有点云山雾罩的感觉,有兴趣可以看看,想直接看结果可以跳转至文末。
一、前提概要
在基于Getx
自定义初始项目模版(template_batch)的使用中,发现针对路由模块在侧滑返回时 PopScope.onPopInvokedWithResult(didPop, result)
的返回参数 didPop
始终为 true
,并且执行退出路由操作,所以无法实现路由拦截的效果。
针对这个问题第一反应就是自我怀疑,经过一番尝试发现这个问题不是个例。好嘛,和我代码无关(嘻嘻一下😊),但是嘻嘻没法解决问题(乐极生悲😭)。
这玩意已经影响到了用户体验(按钮返回和手势返回效果不一致)所以必须要解决啊。
在 github issues 中搜索 PopScope
的相关问题,查找了几个发现没啥确切的解决方案,之后进行如下尝试:
- 使用上个版本的Api
WillPopScope
,未果; - 对路由设置
popGesture: false
,未果; - 修改
PopScope
的嵌套位置(注:PopScope
在页面的任意位置理论都能正常触发),未果;
运行环境和插件版本:
Flutter (Channel stable, 3.24.5)
Dart SDK version: 3.5.4 (stable)
get: ^5.0.0-release-candidate-9.2.1
使用PopScope
,iOS的侧滑返回会被禁用,后续抽时间iOS单独处理下(文章链接)。
PopScope
对比测试结果:
导航按钮 | 侧滑返回 | |
---|---|---|
基础路由 | ✅ Android | ✅ Android |
go_router | ✅ Android | ✅ Android |
getx | ✅ Android | ❌ Android |
多次尝试并没有找到对应的解决方案,所以只能自己手动开盒了。
二、问题解决思路
排查问题的依据是 getx
和 基础路由
横向对比,内部方法执行的差异。
对于PopScope
的失效,最直观的感受就是 onPopInvokedWithResult
方法的 didPop
入参没有变成 false
,并且当前页面会被退出。
1、基础命名路由分析
测试代码结构:
---> /lib/normal_main/main.dart
// MyApp
...
@override
Widget build(BuildContext context) {
return MaterialApp(
...
routes: {
'/': (context) => const HomePage(),
'/second': (context) => const SecondPage(),
},
);
}
// SecondPage
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PopScopeAppBar(
title: 'SecondPage',
onPopInvokedWithResult: (didPop, result) {
print(' SecondPage `onPopInvokedWithResult` didPop: $didPop');
if (didPop) {
return;
}
...
},
),
body: Column(
...
),
);
}
...
A、路由API猜想
首先让我们来设定一个业务场景,当我们在页面上进行交互并需要返回至上级页面时该如何操作(非导航返回按钮/非手势返回):
- 没有
PopScope
拦截的情况,我们处理完业务逻辑后可以直接执行Navigator.pop(context);
退出当前路由,so easy! - 有
PopScope
拦截的情况,我们如何通过Navigator
去控制代码响应到我们对应的 拦截逻辑 中,即回调到方法PopScope.onPopInvokedWithResult(_,__)
。
先查看下API:
通过测试代码我们可以知道:
Navigator.pop(context);
:直接退出当前路由(不管是否有PopScope);
Navigator.canPop(context);
:总是返回true(仅对比测试是否含有PopScope的情况);
Navigator.maybePop(context);
:当我们页面中包含PopScope
时,该方法会触发拦截回调,事实上这个方法是有迹可循的,在maybePop方法中有执行lastEntry.route.onPopInvokedWithResult(false, result);
拦截回调,注意只有 false
时能做逻辑判断。
结论先记住后续会用到。
B、PopScope拦截方法栈的执行
现在我们来向上查找方法栈(这里是反序):
sequenceDiagram
PopScope->PopScope: package:flutter/src/widgets/pop_scope.dart
PopScope-->>PopScope: void _callPopInvoked(bool didPop, T? result)
PopScope-->>_PopScopeState: void onPopInvokedWithResult(bool didPop, T? result)
ModalRoute->ModalRoute: package:flutter/src/widgets/routes.dart
_PopScopeState-->>ModalRoute: void onPopInvokedWithResult(bool didPop, T? result)
NavigatorState->NavigatorState: package:flutter/src/widgets/navigator.dart
ModalRoute-->>NavigatorState: Future<bool> maybePop<T extends Object?>([T? result])
_WidgetsAppState->_WidgetsAppState: package:flutter/src/widgets/app.dart
NavigatorState-->>_WidgetsAppState: Future<bool> didPopRoute()
WidgetsBinding->WidgetsBinding: package:flutter/src/widgets/binding.dart
_WidgetsAppState-->>WidgetsBinding: Future<bool> handlePopRoute()
例图1
通过层层查找我们找到了方法源头 handlePopRoute
,在这里我们可以看到最终执行流程是属于类型 _WidgetsAppState
的调用。
方法handlePopRoute
在这里勉强算登顶了,再往上就是一些顶层事件触发。
@protected
@visibleForTesting
Future<bool> handlePopRoute() async {
for (final WidgetsBindingObserver observer in List<WidgetsBindingObserver>.of(_observers)) {
if (await observer.didPopRoute()) {
return true;
}
}
SystemNavigator.pop();
return false;
}
该方法处理了我们的返回事件逻辑,让我们断点到该方法处,可以查看到 _observers
具体内容如下截图
C、maybePop的实现
OK,现在我们细说拦截回调在哪里完成的,结论前面给了,现在我们来简单查看下 maybePop
的实现:
从方法中我们可以看到当我们顶层路由 lastEntry.route.popDisposition
的值为RoutePopDisposition.doNotPop
时,程序调用了我们路由拦截的回调方法 lastEntry.route.onPopInvokedWithResult(false, result);
。
其实到这里我们可以进行下一步getx
路由的调试了,因为结论maybePop
可以触发拦截回调,我们已经拿到了。但还是可以继续看下为什么popDisposition
的值是doNotPop
,不喜欢的可以跳过看下一节了。
D、为什么是doNotPop
查看lastEntry.route.popDisposition
我们可以找到获取 popDisposition
是在类文件 ModalRoute
中获取的返回值。在具体实现中我们可以看到要想返回值为RoutePopDisposition.doNotPop
我们必须保证popEntry.canPopNotifier.value == false
。
<--- package:flutter/src/widgets/routes.dart
@override
RoutePopDisposition get popDisposition {
for (final PopEntry<Object?> popEntry in _popEntries) {
if (!popEntry.canPopNotifier.value) {
return RoutePopDisposition.doNotPop;
}
}
return super.popDisposition;
}
现在思路清晰了,canPopNotifier
的赋值决定了我们路由是否需要拦截,查看canPopNotifier
的赋值位置,可以看到赋值方法则是在 PopScope
的初始化和组件刷新时完成的。
<--- package:flutter/src/widgets/pop_scope.dart
@override
void initState() {
super.initState();
canPopNotifier = ValueNotifier<bool>(widget.canPop);
}
...
@override
void didUpdateWidget(PopScope<T> oldWidget) {
super.didUpdateWidget(oldWidget);
canPopNotifier.value = widget.canPop;
}
E、小结
具体来说,当我们在页面中使用PopScope
时,当前模态路由会设置 canPopNotifier
的值,来控制路由在 maybePop
时拦截的是否完成调用。
!!!注意全程源码的调用,并没有针对路由进行返回,而 getx
路由则在某一个节点完成了路由的退出(当前显示路由状态会被设置为pop), 这点是 getx
路由拦截失效的根本原因。(目前情况看是这样,更深层次还没有去探究,有错误可以指出😢)
2、getx路由分析
页面路由定义:
class AppPages {
AppPages._();
// ignore: constant_identifier_names
static const INITIAL = kRouteHome;
static final routes = [
GetPage(
name: kRouteHome,
page: () => const HomeView(),
binding: HomeBinding(),
),
GetPage(
name: kRouteSecond,
page: () => const SecondView(),
binding: SecondBinding(),
children: [
GetPage(
name: kRouteThirdName,
page: () => const ThirdView(),
binding: ThirdBinding(),
),
]),
// GetPage(
// name: kRouteThird,
// page: () => const ThirdView(),
// binding: ThirdBinding(),
// ),
];
}
测试路径为:kRouteSecond ---back---> kRouteHome。
A、点击BackButton
方法调用逻辑最终还是执行到了 maybePop
。
void _onPressedCallback(BuildContext context) => Navigator.maybePop(context);
。
class BackButton extends _ActionButton {
/// Creates an [IconButton] with the appropriate "back" icon for the current
/// target platform.
const BackButton({
super.key,
super.color,
super.style,
super.onPressed,
}) : super(icon: const BackButtonIcon());
@override
void _onPressedCallback(BuildContext context) => Navigator.maybePop(context);
@override
String _getTooltip(BuildContext context) {
return MaterialLocalizations.of(context).backButtonTooltip;
}
}
情况和第一节类似,这里就不多做复述了。
B、侧滑返回
沿用第一节
的断点,在测试侧滑返回时断点最开始还是会停在 顶层方法 Future<bool> handlePopRoute()
处,唯一不同的就是我们 _observers
发生了一些变化。
如图,_observers
数组中对比第一节
增加了PlatformRouteInformationProvider
和 RootBackButtonDispatcher
这两个类型的参数。
调试断点可以发现我们最终的执行差异的起点是发生在 RootBackButtonDispatcher
这个类的 Future<bool> didPopRoute()
方法中。
话说这个 BackButtonDispatcher
是不是有一点眼熟呀,仿佛在哪里遇见过。在哪里~ 在哪里遇见你~ 有点熟悉但就是想不起~
好吧,这个最后再说,毕竟它是解决问题的关键,现在我们继续执行程序(后续执行只选择关键的地方说说,查看源码的过程比较生涩,标记断点的过程也不是一蹴而就,所以大家有兴趣自己手动逐步调试下)。
a、RootBackButtonDispatcher
的方法栈
类的实现:
该类本质是对返回事件的分发,执行到超类_CallbackHookProvider
的Future<bool> invokeCallback(Future<bool> defaultValue)
,然后响应到我们路由里的一个名为 Future<bool> _handleBackButtonDispatcherNotification()
的方法。
局部方法栈:
超类_CallbackHookProvider
在调用T invokeCallback(T defaultValue)
时,会触发方法 _callbacks.single();
,该方法的含义是响应方法数组 _callbacks
的第一元素方法。 而这个方法是在路由初始化和刷新的时候添加进 _callbacks
中的。
详情看下面两张方法截图:
好了,现在方法执行到了 Future<bool> _handleBackButtonDispatcherNotification()
,由方法实现我们可以猜测 popRoute
中可能执行了一些不为人知的操作,使我们当前页面路由状态变成了pop。
Future<bool> _handleBackButtonDispatcherNotification() {
_currentRouterTransaction = Object();
return widget.routerDelegate
.popRoute()
.then<bool>(_handleRoutePopped(_currentRouterTransaction));
}
注意看 眼前这个女人叫小美,不对,是routerDelegate
它的类型为GetDelegate
,在这里我们 getx
路由 算是和我们系统代码关联上了。
现在继续执行断点。
b、GetDelegate.popRoute
到底干了啥
代码查看:
首先执行了 handlePopupRoutes
判断是不是弹窗路由(看名字猜的,getx路由这块还没去看),获取到最上层的路由,如果存在并其类型不属于PopupRoute
则直接返回false
。
继续向下执行到 await _pop(popMode ?? backButtonPopMode, result)
,点击继续查看内部调用,最终会执行到GetDelegate
的 Future<T?> _unsafeHistoryRemoveAt<T>(int index, T result) async {
一个删除对应下标的路由的方法。而入参 int index
为固定的 _activePages.length - 1
,即这里是固定执行删除顶层路由。
这里应该是侧滑始终会返回上级页面的原因
现在方法回到 Future<bool> popRoute({ Object? result, PopMode? popMode})
中,在执行删除活跃路由栈的顶层路由后,执行了方法notifyListeners();
,对RouterDelegate
进行了一次刷新(前面代码查看插图),将最新的活跃路由栈传给Navigator
的pages
。
回到方法Future<bool> _handleBackButtonDispatcherNotification()
,当我们popRoute
执行完毕,在异步回调中,我们会刷新整个路由。
程序执行到这里后,基本和 getx
路由没什么关联了,因为它该干的“坏事”的已经做完了。
回顾一下 GetDelegate.popRoute
具体干了什么: 获取了顶层路由,判断了其类型是不是为PopupRoute
,如果不是则执行当前活跃路由栈的顶层路由删除操作,最后刷新交给flutter框架继续操作路由。
当然没有详读getx路由,有些方法用意或者更复杂的操作没办法具体来讲,但是对于当前问题的探究也算有初步的了解了。
下面我们继续讲讲,我们顶层路由状态为什么会变成pop。
c、被Getx删掉的顶层活跃路由状态在哪里变成了pop
程序继续执行。
我们都知道页面刷新的时候程序会执行void didUpdateWidget(_)
方法,针对导航也不会例外,所以程序会调用我们更新页面的方法 void _updatePages()
。整个过程发生在 NavigatorState
类中,这里属于路由更新的相关方法,有兴趣的可以去看看。
进入 void _updatePages()
后,代码会执行 TransitionDelegate._transition
-> DefaultTransitionDelegate.resolve
(继承自TransitionDelegate),代码在这里完成了对前面已删除的活跃路由的状态修改。
Iterable<RouteTransitionRecord> resolve({
required List<RouteTransitionRecord> newPageRouteHistory,
required Map<RouteTransitionRecord?, RouteTransitionRecord> locationToExitingPageRoute,
required Map<RouteTransitionRecord?, List<RouteTransitionRecord>> pageRouteToPagelessRoutes,
}) {
...
}
具体入参如下图:
newPageRouteHistory
:仅包含我们首页的路由的List
locationToExitingPageRoute
:key为首页路由,value为二级页面路由,内部判断是否标记pop路由状态也是根据这个Map来的
pageRouteToPagelessRoutes
:为空
程序继续执行,到方法末尾,对newPageRouteHistory
进行遍历,此时我们数组中只有一个元素,所以在调用内部方法void handleExitingRoute(RouteTransitionRecord? location, bool isLast)
对路由进行处理,handleExitingRoute
是一个递归执行的方法,其注释翻译过来就是:此方法将处理当前路由及其在此位置对应的无页路由。它还将递归地检查它上面是否有其他退出的路由,并相应地处理它们。 简单说就是控制是不是需要退出路由的。
此时根据我们前面入参可以去确定,执行方法handleExitingRoute(pageRoute, isLastIteration);
时,pageRoute
为首页的路由,isLastIteration
为true
,因为newPageRouteHistory
有且仅有首页路由一个元素。
内部方法:
内部方法通过入参,可以判断此时exitingPageRoute
的值为第二个路由 second
。并且代码会执行到小红框部分,最终执行方法exitingPageRoute.markForPop(exitingPageRoute.route.currentResult);
。
而方法markForPop
就是对当前路由修改状态为_RouteLifecycle.pop
,此时便完成了第二个页面的路由second
状态修改。这里直接贴图了,方法实现比较简单。
完成路由状态修改后,我们会把已经修改状态的路由添加至results
中,最后方法 DefaultTransitionDelegate.resolve
结果并返回所有处理过的路由(不删除的保留,删除的标记为pop。其实这个方法在pop->push的也会调用,代码中明显有相关处理,但是这里我们只是针对页面返回进行的调试)。
程序到这里已经完成了页面路由栈的更新,现在只需要对以前路由栈进行替换就可以了。
方法void _flushHistoryUpdates({bool rearrangeOverlay = true})
,在状态为pop
时会触发_RouteEntry.handlePop
,在这里会触发didPop=true
的回调:
最后就是路由代码的更新,完成页面的切换,这里就不说了。
C、总结问题
先说前提哈:getx路由
使用PopScope
时在侧滑返回时,didPop
总是为true
,并始终会退出当前路由。
经过getx路由
的断点调试我们发现在侧滑返回时,GetDelegate
会移除当前的顶层活跃路由,然后更新Navgator
的pages
,然后通过路由相关代码的处理,标记我们已经删除的顶层路由为_RouteLifecycle.pop
,从而导致页面的退出。
话说到这里相信大家已经明白了吧,如何控制GetDelegate
不移除当前顶层路由成了关键的。回顾代码:
graph TD
入口`WidgetsBinding.handlePopRoute`
-->`RootBackButtonDispatcher.didPopRoute`触发`super.invokeCallback`
--> `_RouteSetter._handleBackButtonDispatcherNotification`
--> `GetDelegate.popRoute`完成了顶层路由的删除
--> 完成路由状态的修改
首先修改源代码GetDelegate.popRoute
是不可靠的,而且我们也不知道修改了会导致什么其他的问题,毕竟get源码体量也不小。排除(个人猜测可以通过)。maybePop
来控制,取消PopupRoute
的判断,但是这里没有去详细了解,不知道会有什么问题
换个思路,那我们能不能控制代码,使我们侧滑返回的时候不用触发GetDelegate.popRoute
呢?
根据流程我们可以确定方法栈的最顶点是RootBackButtonDispatcher.didPopRoute
,那我们能不能直接自定义顶点函数呢。
RootBackButtonDispatcher
最开始就说到了似曾相识,经过查找我们发现返回事件分发其实在Navigator2.0
给了我们一个自定义的机会:
那么我能不能控制自定义backButtonDispatcher
来重写BackButtonDispatcher.didPopRoute
的实现,具体怎么来实现路由拦截,开篇的时候已经提到 Navigator.maybePop(context);
可以用于触发拦截,那么问题就比较简单了。
下面开整!
三、解决问题的尝试
根据RootBackButtonDispatcher
的实现,我们来自定义一个分发器,系统源码代码就不贴了,这里直接贴出我们自定义的代码。
class MRootBackButtonDispatcher extends BackButtonDispatcher
with WidgetsBindingObserver {
/// Create a root back button dispatcher.
MRootBackButtonDispatcher();
@override
void addCallback(ValueGetter<Future<bool>> callback) {
if (!hasCallbacks) {
WidgetsBinding.instance.addObserver(this);
}
super.addCallback(callback);
}
@override
void removeCallback(ValueGetter<Future<bool>> callback) {
super.removeCallback(callback);
if (!hasCallbacks) {
WidgetsBinding.instance.removeObserver(this);
}
}
@override
Future<bool> didPopRoute() async {
final maybePop = await Get.rawRoute?.navigator?.maybePop();
return maybePop ?? invokeCallback(Future.value(false));
}
}
通过maybePop
的处理,我们来避免触发invokeCallback
,避免调用后续GetDelegate
顶层路由的删除操作,从而保留我们PopScope
的逻辑。
使用:
测试发现,在文章运行环境下,当canPop: false;
时可以触发侧滑返回的拦截。
四、总结
全文算是对路由pop操作执行流程的一个梳理,由于路由模块的代码并没有详细解读,所以提出的解决方案可能具有局限性。不保证都能用,但是期待大家可以尝试看看👀。
有错误可以指出,毕竟源码是真的很干涩,我可不敢说保熟啊~😭。
测试效果:
👇👇👇 测试demo (内含iOS侧滑失效处理)