flutter getx 路由侧滑返回 PopScope 回调失效的探究与解决

2,058 阅读13分钟

本文主要记录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: 截屏2024-12-10 15.13.30.png

通过测试代码我们可以知道:

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 具体内容如下截图

截屏2024-12-11 08.43.35.png

C、maybePop的实现

OK,现在我们细说拦截回调在哪里完成的,结论前面给了,现在我们来简单查看下 maybePop 的实现:

截屏2024-12-11 08.46.15.png

从方法中我们可以看到当我们顶层路由 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 发生了一些变化。

截屏2024-12-11 09.16.54.png

如图,_observers数组中对比第一节增加了PlatformRouteInformationProviderRootBackButtonDispatcher 这两个类型的参数。

调试断点可以发现我们最终的执行差异的起点是发生在 RootBackButtonDispatcher 这个类的 Future<bool> didPopRoute() 方法中。

话说这个 BackButtonDispatcher 是不是有一点眼熟呀,仿佛在哪里遇见过。在哪里~ 在哪里遇见你~ 有点熟悉但就是想不起~

好吧,这个最后再说,毕竟它是解决问题的关键,现在我们继续执行程序(后续执行只选择关键的地方说说,查看源码的过程比较生涩,标记断点的过程也不是一蹴而就,所以大家有兴趣自己手动逐步调试下)。

a、RootBackButtonDispatcher 的方法栈

类的实现: image.png

该类本质是对返回事件的分发,执行到超类_CallbackHookProviderFuture<bool> invokeCallback(Future<bool> defaultValue),然后响应到我们路由里的一个名为 Future<bool> _handleBackButtonDispatcherNotification() 的方法。

局部方法栈: image.png

超类_CallbackHookProvider在调用T invokeCallback(T defaultValue) 时,会触发方法 _callbacks.single();,该方法的含义是响应方法数组 _callbacks 的第一元素方法。 而这个方法是在路由初始化和刷新的时候添加进 _callbacks 中的。

详情看下面两张方法截图:

image.png image.png

好了,现在方法执行到了 Future<bool> _handleBackButtonDispatcherNotification() ,由方法实现我们可以猜测 popRoute 中可能执行了一些不为人知的操作,使我们当前页面路由状态变成了pop。


  Future<bool> _handleBackButtonDispatcherNotification() {
    _currentRouterTransaction = Object();
    return widget.routerDelegate
      .popRoute()
      .then<bool>(_handleRoutePopped(_currentRouterTransaction));
  }

image.png

注意看 眼前这个女人叫小美,不对,是routerDelegate它的类型为GetDelegate,在这里我们 getx路由 算是和我们系统代码关联上了。

现在继续执行断点。

b、GetDelegate.popRoute 到底干了啥

代码查看: 截屏2024-12-11 10.26.54.png

首先执行了 handlePopupRoutes 判断是不是弹窗路由(看名字猜的,getx路由这块还没去看),获取到最上层的路由,如果存在并其类型不属于PopupRoute则直接返回false

继续向下执行到 await _pop(popMode ?? backButtonPopMode, result),点击继续查看内部调用,最终会执行到GetDelegateFuture<T?> _unsafeHistoryRemoveAt<T>(int index, T result) async { 一个删除对应下标的路由的方法。而入参 int index 为固定的 _activePages.length - 1,即这里是固定执行删除顶层路由。

image.png image.png

这里应该是侧滑始终会返回上级页面的原因

现在方法回到 Future<bool> popRoute({ Object? result, PopMode? popMode})中,在执行删除活跃路由栈的顶层路由后,执行了方法notifyListeners();,对RouterDelegate进行了一次刷新(前面代码查看插图),将最新的活跃路由栈传给Navigatorpages

回到方法Future<bool> _handleBackButtonDispatcherNotification() ,当我们popRoute执行完毕,在异步回调中,我们会刷新整个路由。

image.png

程序执行到这里后,基本和 getx路由没什么关联了,因为它该干的“坏事”的已经做完了。

回顾一下 GetDelegate.popRoute 具体干了什么: 获取了顶层路由,判断了其类型是不是为PopupRoute,如果不是则执行当前活跃路由栈的顶层路由删除操作,最后刷新交给flutter框架继续操作路由。

当然没有详读getx路由,有些方法用意或者更复杂的操作没办法具体来讲,但是对于当前问题的探究也算有初步的了解了。

image.png

下面我们继续讲讲,我们顶层路由状态为什么会变成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

image.png

locationToExitingPageRoute:key为首页路由,value为二级页面路由,内部判断是否标记pop路由状态也是根据这个Map来的

image.png

pageRouteToPagelessRoutes:为空

程序继续执行,到方法末尾,对newPageRouteHistory 进行遍历,此时我们数组中只有一个元素,所以在调用内部方法void handleExitingRoute(RouteTransitionRecord? location, bool isLast) 对路由进行处理,handleExitingRoute是一个递归执行的方法,其注释翻译过来就是:此方法将处理当前路由及其在此位置对应的无页路由。它还将递归地检查它上面是否有其他退出的路由,并相应地处理它们。 简单说就是控制是不是需要退出路由的。

image.png

此时根据我们前面入参可以去确定,执行方法handleExitingRoute(pageRoute, isLastIteration);时,pageRoute为首页的路由,isLastIterationtrue,因为newPageRouteHistory有且仅有首页路由一个元素。

内部方法: image.png

内部方法通过入参,可以判断此时exitingPageRoute 的值为第二个路由 second。并且代码会执行到小红框部分,最终执行方法exitingPageRoute.markForPop(exitingPageRoute.route.currentResult);

而方法markForPop就是对当前路由修改状态为_RouteLifecycle.pop,此时便完成了第二个页面的路由second状态修改。这里直接贴图了,方法实现比较简单。

image.png

完成路由状态修改后,我们会把已经修改状态的路由添加至results中,最后方法 DefaultTransitionDelegate.resolve结果并返回所有处理过的路由(不删除的保留,删除的标记为pop。其实这个方法在pop->push的也会调用,代码中明显有相关处理,但是这里我们只是针对页面返回进行的调试)。

程序到这里已经完成了页面路由栈的更新,现在只需要对以前路由栈进行替换就可以了。

image.png

方法void _flushHistoryUpdates({bool rearrangeOverlay = true}),在状态为pop时会触发_RouteEntry.handlePop,在这里会触发didPop=true的回调: image.png

最后就是路由代码的更新,完成页面的切换,这里就不说了。

C、总结问题

先说前提哈:getx路由使用PopScope时在侧滑返回时,didPop总是为true,并始终会退出当前路由。

经过getx路由的断点调试我们发现在侧滑返回时,GetDelegate会移除当前的顶层活跃路由,然后更新Navgatorpages,然后通过路由相关代码的处理,标记我们已经删除的顶层路由为_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给了我们一个自定义的机会:

image.png

那么我能不能控制自定义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的逻辑。

使用: image.png

测试发现,在文章运行环境下,当canPop: false;时可以触发侧滑返回的拦截。

四、总结

全文算是对路由pop操作执行流程的一个梳理,由于路由模块的代码并没有详细解读,所以提出的解决方案可能具有局限性。不保证都能用,但是期待大家可以尝试看看👀。

有错误可以指出,毕竟源码是真的很干涩,我可不敢说保熟啊~😭。

v2-7261bcf78e46fffd704697f10953d9e0_b.gif

测试效果:

132_1733906830.gif

👇👇👇 测试demo (内含iOS侧滑失效处理)

pub package