Flutter 弹框队列
概述
弹框队列 在客户端是个比较常见的功能组件,用于统一管理 APP 内所有弹框的显隐,主要是解决如下图所示的 “多个弹框同时弹出,其蒙层叠加带来的背景色加重的问题”
方案效果
将所有场景下的 “即时弹框” 转为相应的 “弹框请求” 按序入列,并逐个弹出,当下展示的弹框在消失后,唤起队列下一个弹框进行展示,如此反复直至队列为空。
具体来讲:
- 支持按序逐一弹框
- 支持按优先级弹框
- 支持弹框入列排重
- 保留 Flutter Navigator 弹框调用方式
对方案实现过程无感的同学,可直接使用 pub.dev : dialog_queue
方案实现
Step 1 - 队列元素 DialogElement
- APP 各式弹框的抽象基类,是弹框队列的唯一管理元素,也代表了一个「弹框请求」
- 具体弹框样式及行为借成员变量 onShow 委托扩展子类实现
- 允许扩展子类在必要的时候重载 update(DialogQueueElement? dialog) 实现自己的数据更新
- 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 对象可能会做两个事情:
- 常规的异步转同步调用
- 等待对话框 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 与之对应。
特别注意: 如果 addDialog 的时候 DialogElement 已存在,DialogQueue 不会重复添加,而会更新已在队列中 DialogElement 的属性数据,并将已入列的 DialogElement 的 Completer.future 作为此次 addDialog 方法调用的返回值。也就是一个 Completer.future 可能会被多个调用方 await;这样便能确保当 Dialog pop(Completer.future.complete())的时候,多个调用方都能收到 then 的回调。
至此,对于「添加 DialogElement 入列」我们梳理一下:
- DialogElement 入列前需要查重(依据 uniqueKey 属性),做属性更新 & Completer 复用
- DialogElement 入列后需根据优先级重新排序
- 尝试展示下一个弹框
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 弹窗时机
两个时机:
- 每当一个 DialogElement 入列时
- 每当一个 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 :在使用过程中发现了一种情况弹窗队列会直接报废,导致队列中余下的对话框都无法弹出。
还原下事发现场:
- APP 内从 PageA 跳 PageB 再跳 PageC
- 在 PageC 此时发起若干个弹框请求入列
- 首个对话框弹出,点击对话框按钮执行 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) 为空
结合模型场景,我们看看问题出在哪:
- 初始页面栈:PageA > PageB > PageC(PageC 在栈顶)
- 此时 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 的业务流。
合理的做法是当用户跳转到 PageB 的时候,弹框队列停止工作,并在处理完业务回到 PageA 的时候,NO.2 弹框再出现:
基于此,DialogQueue 应该提供 pause() & resume() 方便用户随时暂停或恢复队列的执行;更进一步,应该封装一个这样的方法:允许在页面发生跳转时,暂停 DialogQueue 的执行,并在回到跳转前的 Page 时恢复 DialogQueue 的执行。 具体怎么做,请大伙点击下方链接看源码即可。
至此,结合前面弹框队列设计的基本模型 + 上述踩坑的边界处理,便有了 :
pub.dev : dialog_queue github : flutter_dialog_queue
End
转载自:https://juejin.cn/post/7099834211418243103