Flutter 弹框队列

2,466 阅读4分钟

概述

弹框队列 在客户端是个比较常见的功能组件,用于统一管理 APP 内所有弹框的显隐,主要是解决如下图所示的 “多个弹框同时弹出,其蒙层叠加带来的背景色加重的问题”

image.png

方案效果

将所有场景下的 “即时弹框” 转为相应的 “弹框请求” 按序入列,并逐个弹出,当下展示的弹框在消失后,唤起队列下一个弹框进行展示,如此反复直至队列为空。

                   co0aq-4ahs2.gif

具体来讲:

  • 支持按序逐一弹框
  • 支持按优先级弹框
  • 支持弹框入列排重
  • 保留 Flutter Navigator 弹框调用方式

goutou.png 对方案实现过程无感的同学,可直接使用 pub.dev : dialog_queue

方案实现

Step 1 - 队列元素 DialogElement

  1. APP 各式弹框的抽象基类,是弹框队列的唯一管理元素,也代表了一个「弹框请求」
  2. 具体弹框样式及行为借成员变量 onShow 委托扩展子类实现
  3. 允许扩展子类在必要的时候重载 update(DialogQueueElement? dialog) 实现自己的数据更新
  4. uniqueKey 作为判断 DialogElement 相等性的唯一属性,在对象创建时如果未指定,则会使用一个随机 uuid 作为默认值
typedef onShow = Future Function();

abstract class DialogQueueElement extends Equatable {
  onShow show; // 外部传入的展示业务对话框的回调方法
  late int? _priority;
  late String? _uniqueKey;
  late String? _tag;
  late String _uuid;

  DialogQueueElement(
    this.show, {
    int? priority = defaultPriority,
    String? uniqueKey,
    String? tag,
  }) {
    _uuid = const Uuid().v1();
    _priority = priority;
    _uniqueKey = uniqueKey ?? _uuid;
    _tag = tag;
  }

  int get priority => _priority ?? defaultPriority;


  update(DialogQueueElement? dialog) {
    if (dialog == null) {
      return;
    }
    _show = dialog._show;
    _priority = dialog._priority ?? _priority;
    _uniqueKey = dialog._uniqueKey ?? _uniqueKey;
    _tag = dialog._tag ?? _tag;
  }

  showDialog() {
    return show.call();
  }

  @override
  String toString() {
    return 'DialogQueueElement { tag : $_tag, priority : $_priority, uniqueKey : $_uniqueKey }';
  }

  @override
  List<Object?> get props => [_uniqueKey];
}

Step 2: 添加 DialogElement 入列

谈具体实现前,我们必须知道「弹框入列 - DialogQueue.addDialog() 」这个动作是用来替换之前各个场景下的另一个动作:「即时弹框 - showDialog」,调用方使用 showDialog 的方式方法在切换成 addDialog 之后,原则上应该保持一致,不去打破原本的使用机制

我们拿 Flutter 官方的 showModalBottomSheet 底部弹框为例进行说明。一般来讲,业务调用方直接按下面的方式就能弹出一个官方底部对话框:

// XXXBusiness.dart 业务模块弹框
showModalBottomSheet(
  context: context,
  backgroundColor: Colors.white,
  builder: (BuildContext context) {
    return SomeWidget();
  },
);

而从 showModalBottomSheet 的源码来看,此方法其实是个 Navigator 的 push 操作,既然是 push page 操作,那表示它作为一个异步操作,会返回给调用方一个 Future 对象:

Future<T?> showModalBottomSheet<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  ...
}) {
  ...
  final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator);
  // 返回 Future 对象
  return navigator.push(_ModalBottomSheetRoute<T>(
    builder: builder,
    ...
  ));
}

调用方拿到这个 Future 对象可能会做两个事情:

  1. 常规的异步转同步调用
  2. 等待对话框 pop 消失的时候,通过 then 方法回调做自己的业务逻辑。

在 Navigator push 的场景下,页面 pop 的时候会触发 then 回调

所以综上所述,在 showDialog 切换成 addDialog 之后,对于调用方而言,其原本的使用习惯应该维持不变。即 addDialog 方法体结构应该是这样:

Future<T?> addDialog<T>(DialogQueueElement<T> dialog) {
  ...
  ...
  return xxxFuture; // 如何定义此 Future ?
}

那么这个 xxxFuture 从何而来?回顾之前 DialogElement 的定义,我们很自然会想到将委托给扩展子类的 "typedef onShow = Future Function()" 方法的执行结果作为 xxxFuture 进行返回,即:

Future<T?> addDialog<T>(DialogQueueElement<T> dialog) {
  ...
  ...
  return dialog.show.call();
}

但很遗憾这并非是正确的方式,这样做只会导致当调用方调用 addDialog 的时候就立马触发其对话框的展示(show 方法会被立即执行),然后调用方 await addDialog() 等待的其实是 “已展示的对话框” 消失(pop)时的结果。

所以 addDialog 返回的 Future 对象,不应该指向调用方的 show 方法执行结果,那有没有办法既能在当下返回一个 Future,并在恰当的时机控制 Future 的结果返回呢?有的,那就是 Flutter Completer

我们应该借助 Flutter 的 Completer 做调用桥接。Future 是一个异步计算的结果,而 Completer 是一个用来产生 Future 并控制计算过程结束时机的工具,用一个小例子展示下 Completer 的使用:

Future openImagePicker () {
    Complete completer = new Completer();
    ImagePicker.singlePicker(context, 
       singleCallback: (data) {
         // complete() 表示成功收尾
         completer.complete(data);
       },
       failCallback:(err) {
         // catchError() 表示出错收尾
         completer.catchError(err); 
       }
    );
    // 返回 Completer 的 Future
    return completer.future;
}

在上述例子中,我们可以任意时刻调用 complete 或 catchError 方法来结束 openImagePicker 的调用;甚至可以用来组装多个异步操作,并做最终的结束控制,可谓相当灵活。

所以在调用方执行 addDialog 的时候创建一个 Completer,并返回 completer.future,等待该对话框消失(pop)时让其对应的 Completer 执行 complete() 即可。可见一个 DialogElement 在队列中会有一个 Completer 与之对应。

Flutter 弹窗队列 async 示意图.jpg

特别注意: 如果 addDialog 的时候 DialogElement 已存在,DialogQueue 不会重复添加,而会更新已在队列中 DialogElement 的属性数据,并将已入列的 DialogElement 的 Completer.future 作为此次 addDialog 方法调用的返回值。也就是一个 Completer.future 可能会被多个调用方 await;这样便能确保当 Dialog pop(Completer.future.complete())的时候,多个调用方都能收到 then 的回调。

至此,对于「添加 DialogElement 入列」我们梳理一下:

  1. DialogElement 入列前需要查重(依据 uniqueKey 属性),做属性更新 & Completer 复用
  2. DialogElement 入列后需根据优先级重新排序
  3. 尝试展示下一个弹框
class DialogQueue {
  // 标记当前是否有 Dialog 在展示
  bool _isShowing = false;
  // 使用 Map 的方式存储 DialogElement & Completer 的映射关系
  final Map<DialogQueueElement, Completer> _dialogQueue = {};

  Future<T?> addDialog<T>(DialogQueueElement dialog) {
    // 1. 入列前查重
    List<DialogQueueElement> keyList = _dialogQueue.keys.toList();
    int existIndex = keyList.indexOf(dialog);
    if (existIndex >= 0) {
      DialogQueueElement currentDialog = keyList.elementAt(existIndex);
      Completer<T?> existCompleter = _dialogQueue[currentDialog] as Completer<T?>;
      // 1.1 更新对话框数据
      currentDialog.update(dialog);
      // 1.2 更新排序
      _sortQueue();
      // 1.3 复用 Completer
      return existCompleter.future;
    }
    Completer<T?> dialogCompleter = Completer();
    _dialogQueue[dialog] = dialogCompleter;
    // 2. 按优先级排序
    _sortQueue();
    // 3. 取下一个对话框进行展示
    _showNext();
    return dialogCompleter.future;
  }
}

Step 3: DialogQueue 排序

按优先级排序:即 priority 越大则越优先弹出。由于 DialogElement 与 Completer 的映射关系存储于哈希表中,为了实现排序,另外定义了决定 DialogElement List 顺序的数组 _sortedKeys ,具体实现如下:

class DialogQueue {
  bool _isShowing = false;
  final Map<DialogQueueElement, Completer> _dialogQueue = {};
  
  // 存储对话框顺序
  List<DialogQueueElement> _sortedKeys = [];

  // 排序
  _sortQueue() {
    _sortedKeys = _dialogQueue.keys.toList();
    _sortedKeys.sort((a, b) {
      if (a.priority > b.priority) {
        return -1;
      } else if (a.priority < b.priority) {
        return 1;
      }
      return 0;
    });
  }
}

Step 4: DialogQueue 弹窗时机

两个时机:

  1. 每当一个 DialogElement 入列时
  2. 每当一个 DialogElement 消失时
_showNext() {
  if (!_isShowing && _dialogQueue.isNotEmpty) {
    _isShowing = true;
    DialogQueueElement nextDialog = _sortedKeys.first;
    Completer? nextCompleter = _dialogQueue[nextDialog];
    _dialogQueue.remove(nextDialog);
    _sortedKeys.remove(nextDialog);
    // 使用 then 监听对话框的消失
    nextDialog.showDialog().then((value) {
      nextCompleter?.complete();
      // 继续下一个弹框
      _isShowing = false;
      _showNext();
    });
  }
}

至此,一个简易的 DialogQueue for flutter 就算完整了,下面我们进入踩坑环节。


踩坑环节

踩坑 1 :在使用过程中发现了一种情况弹窗队列会直接报废,导致队列中余下的对话框都无法弹出。

还原下事发现场:

  1. APP 内从 PageA 跳 PageB 再跳 PageC
  2. 在 PageC 此时发起若干个弹框请求入列
  3. 首个对话框弹出,点击对话框按钮执行 Navigator.of(context).pushNamedAndRemoveUntil(PageA)

此时问题复现。咋回事?

先说结论: Flutter navigator 执行 pushNameAndRemoveUntil 的时候把页面栈的历史元素直接 remove,但未结束 Route 元素对应的 future,导致正在展示的对话框的 then 回调得不到执行,继而 DialogQueue 中的 _isShowing 标识一直为 true 且无法调度下一个对话框的显示

nextDialog.showDialog().then((value) {
  // 😮 导致下方的逻辑都无法执行
  nextCompleter?.complete();
  _isShowing = false;
  _showNext();
});

看看源码:flutter/lib/src/widgets/navigator.dart

void _pushEntryAndRemoveUntil(_RouteEntry entry, RoutePredicate predicate) {
  assert(!_debugLocked);
  assert(() {
    _debugLocked = true;
    return true;
  }());
  ...
  ...
  int index = _history.length - 1;
  _history.add(entry);
  // 遍历页面历史栈,直接移除 ❌ 不符合业务定义的保留条件且在合法的生命周期内的页面
  while (index >= 0 && !predicate(_history[index].route)) {
    if (_history[index].isPresent)
      // 对 List 中的元素直接 remove 未做任何其他处理
      _history[index].remove();
    index -= 1;
  }
  _flushHistoryUpdates();
  ...
  ...
  _afterNavigation(entry.route);
}

而正常 pop 一个页面又是什么样的呢?

void pop<T>(T? result) {
  assert(isPresent);
  doingPop = true;
  // route.didPop
  if (route.didPop(result) && doingPop) {
    currentState = _RouteLifecycle.pop;
  }
  doingPop = false;
}

bool didPop(T? result) {
  didComplete(result);
  return true;
}

// 在 push 一个页面得到的 Future 就是 _popCompleter.future
final Completer<T?> _popCompleter = Completer<T?>();
void didComplete(T? result) {
  _popCompleter.complete(result ?? currentResult);
}

所以可见,同样是将页面移除,remove 和 pop 有着本质的不同,pop 会执行 Completer.future.complete() 继而触发 then 的回调,而 remove 是不会的。

方案一 : 监听 Navigator 路由动态变化

原理:当 DialogQueue 正在展示弹框时,将发生的 didRemove 行为及其目标 pushRoute 透传给业务方,由业务方来决定队列的下一步操作(清空队列或择机重弹)。也就是当 DialogQueue 当前正在展示的对话框被无情 remove 掉的时候,队列的按序弹框被强制中断,我们便允许业务方在合适的时候进行修复处理。

通过给我们业务的根 Widget MateralApp 的 navigatorObservers 注入我们自定义的 RouteObserver 就能监听到页面被 remove 的情况。


class DialogQueueRouteObserver extends RouteObserver {

  Route<dynamic>? _pushRoute;

  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    super.didPush(route, previousRoute);
    print('didPush route = $route, and code = ${route.hashCode} and preRoute = $previousRoute and preCode = ${previousRoute.hashCode}');
    _pushRoute = route;
  }
  
  @override
  void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
    super.didRemove(route, previousRoute);
    print('didRemove route = $route, and preRoute = $previousRoute');
    if (DialogQueue.instance.isShowing) {
      // 委托 DialogQueue 交由业务方自行处理
      DialogQueue.instance.handlePushRemoveEvent(_pushRoute);
    }
  }
}

/// 项目入口 - runApp Widget 
class App extends StatelessWidget {
    ...
    @override
    Widget build(BuildContext context) {
        return MaterialApp(
          ...
          // 注入 DialogQueueRouteObserver
          navigatorObservers: [DialogQueueRouteObserver()],
        );
    }
}

方案二:代理 NavigatorState 的 pushNameAndRemoveUntil 方法

通过定义全局的 NavigatorState 替代 Navigator.of(context) 来执行页面导航的动作。如下:

/// 自定义全局的 Navigator
class NavigateService {
  final GlobalKey<NavigatorState> key = GlobalKey(debugLabel: 'navigate_key');
  NavigatorState? get navigator => key.currentState;

  pushNamedAndRemoveUntil(String newRouteName, {RoutePredicate? predicate, Object? arguments}) async {
    // 当业务执行 pushNamedAndRemoveUntil 的时候,在此处做弹框队列的处理
    serviceLocator<DialogQueue>().clear();
    return navigator?.pushNamedAndRemoveUntil(newRouteName, predicate ?? (route) => false, arguments: arguments);
  }
}

/// 项目入口 - runApp Widget 
class App extends StatelessWidget {
    ...
    @override
    Widget build(BuildContext context) {
        return MaterialApp(
          ...
          // 注入 GlobalKey<NavigatorState>
          navigatorKey: serviceLocator<NavigateService>().key,
        );
    }
}

两种方法都可行,只不过方案二的侵入性略高,但它的好处也是明显的,请看「踩坑 2」。

踩坑 2 :弹框队列中的弹框元素持有过期的 BuildContext,导致 Navigator.of(context) 为空

结合模型场景,我们看看问题出在哪:

  1. 初始页面栈:PageA > PageB > PageC(PageC 在栈顶)
  2. 此时 PageC 收到多个弹框请求,构建多个 DialogElement 入列

相应的,在构建业务对话框的时候,我们往往会给对话框的取消按钮添加这么一句让对话框消失的代码:

Navigator.of(context).pop();

注意! 此时 Navigator.of(context) 的 BuildContext context 实例来源于 Page3。

那么当 Page3 被 pop 或者 remove 的时候,context 实例就算还存在于内存,但我们通过 Navigator.of(context) 试图获取的 NavigatorState 是为空的。也就意味着之前所构建好入列的业务对话框,其 Navigator.of(context).pop() 是无法执行的;对话框无法弹出消失也就意味着「弹框队列无法正常运转」。

// Navigator.of(context) source code
static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
}) {
  NavigatorState? navigator;
  // “context.state is NavigtorState” will throw exception !
  if (context is StatefulElement && context.state is NavigatorState) {
    navigator = context.state as NavigatorState;
  }
}

所以,解决这个问题的方法,就是入列的对话框,使用 NavigatorState 的时候,不要依赖于当前 Widget build 传入的 context,而应该使用如「踩坑 1」方案二所提及的全局 NavigatorState

serviceLocator<NavigateService>().pop();

踩坑 3 :弹框队列的按序弹出无法暂停,会打断用户的业务流程

如以下场景:用户在 PageA 的时候收到了多个弹框请求,处理 NO.1 对话框的时候,会触发 PageB 的跳转;NO.1 对话框消失会自动触发 NO.2 对话框的弹出,继而遮挡住了 PageB,打断了用户在 PageB 的业务流。

sa6x4-8wckw.gif

合理的做法是当用户跳转到 PageB 的时候,弹框队列停止工作,并在处理完业务回到 PageA 的时候,NO.2 弹框再出现:

q7wxi-0vc9r.gif

基于此,DialogQueue 应该提供 pause() & resume() 方便用户随时暂停或恢复队列的执行;更进一步,应该封装一个这样的方法:允许在页面发生跳转时,暂停 DialogQueue 的执行,并在回到跳转前的 Page 时恢复 DialogQueue 的执行。 具体怎么做,请大伙点击下方链接看源码即可。

至此,结合前面弹框队列设计的基本模型 + 上述踩坑的边界处理,便有了 :

pub.dev : dialog_queue  github : flutter_dialog_queue

End