发布网友 发布时间:2024-09-30 13:17
共1个回答
热心网友 时间:2024-10-04 09:36
概述弹框队列在客户端是个比较常见的功能组件,用于统一管理APP内所有弹框的显隐,主要是解决如下图所示的“多个弹框同时弹出,其蒙层叠加带来的背景色加重的问题”
方案效果将所有场景下的“即时弹框”转为相应的“弹框请求”按序入列,并逐个弹出,当下展示的弹框在消失后,唤起队列下一个弹框进行展示,如此反复直至队列为空。
              ?
具体来讲:
支持按序逐一弹框
支持按优先级弹框
支持弹框入列排重
保留FlutterNavigator弹框调用方式
对方案实现过程无感的同学,可直接使用pub.dev:dialog_queue
方案实现Step1-队列元素DialogElement
APP各式弹框的抽象基类,是弹框队列的唯一管理元素,也代表了一个「弹框请求」
具体弹框样式及行为借成员变量onShow委托扩展子类实现
允许扩展子类在必要的时候重载update(DialogQueueElement?dialog)实现自己的数据更新
uniqueKey作为判断DialogElement相等性的唯一属性,在对象创建时如果未指定,则会使用一个随机uuid作为默认值
typedefonShow=FutureFunction();abstractclassDialogQueueElementextendsEquatable{onShowshow;//外部传入的展示业务对话框的回调方法lateint?_priority;lateString?_uniqueKey;lateString?_tag;lateString_uuid;DialogQueueElement(this.show,{int?priority=defaultPriority,String?uniqueKey,String?tag,}){_uuid=constUuid().v1();_priority=priority;_uniqueKey=uniqueKey??_uuid;_tag=tag;}intgetpriority=>_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(){returnshow.call();}@overrideStringtoString(){return'DialogQueueElement{tag:$_tag,priority:$_priority,uniqueKey:$_uniqueKey}';}@overrideList<Object?>getprops=>[_uniqueKey];}Step2:添加DialogElement入列
谈具体实现前,我们必须知道「弹框入列-DialogQueue.addDialog()」这个动作是用来替换之前各个场景下的另一个动作:「即时弹框-showDialog」,调用方使用showDialog的方式方法在切换成addDialog之后,原则上应该保持一致,不去打破原本的使用机制。
我们拿Flutter官方的showModalBottomSheet底部弹框为例进行说明。一般来讲,业务调用方直接按下面的方式就能弹出一个官方底部对话框:
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);而从showModalBottomSheet的源码来看,此方法其实是个Navigator的push操作,既然是pushpage操作,那表示它作为一个异步操作,会返回给调用方一个Future对象:
Future<T?>showModalBottomSheet<T>({requiredBuildContextcontext,requiredWidgetBuilderbuilder,...}){...finalNavigatorStatenavigator=Navigator.of(context,rootNavigator:useRootNavigator);//返回Future对象returnnavigator.push(_ModalBottomSheetRoute<T>(builder:builder,...));}调用方拿到这个Future对象可能会做两个事情:
常规的异步转同步调用
等待对话框pop消失的时候,通过then方法回调做自己的业务逻辑。
在Navigatorpush的场景下,页面pop的时候会触发then回调
所以综上所述,在showDialog切换成addDialog之后,对于调用方而言,其原本的使用习惯应该维持不变。即addDialog方法体结构应该是这样:
Future<T?>addDialog<T>(DialogQueueElement<T>dialog){......returnxxxFuture;//如何定义此Future?}那么这个xxxFuture从何而来?回顾之前DialogElement的定义,我们很自然会想到将委托给扩展子类的"typedefonShow=FutureFunction()"方法的执行结果作为xxxFuture进行返回,即:
Future<T?>addDialog<T>(DialogQueueElement<T>dialog){......returndialog.show.call();}但很遗憾这并非是正确的方式,这样做只会导致当调用方调用addDialog的时候就立马触发其对话框的展示(show方法会被立即执行),然后调用方awaitaddDialog()等待的其实是“已展示的对话框”消失(pop)时的结果。
所以addDialog返回的Future对象,不应该指向调用方的show方法执行结果,那有没有办法既能在当下返回一个Future,并在恰当的时机控制Future的结果返回呢?有的,那就是FlutterCompleter。
我们应该借助Flutter的Completer做调用桥接。Future是一个异步计算的结果,而Completer是一个用来产生Future并控制计算过程结束时机的工具,用一个小例子展示下Completer的使用:
FutureopenImagePicker(){Completecompleter=newCompleter();ImagePicker.singlePicker(context,singleCallback:(data){//complete()表示成功收尾completer.complete(data);},failCallback:(err){//catchError()表示出错收尾completer.catchError(err);});//返回Completer的Futurereturncompleter.future;}在上述例子中,我们可以任意时刻调用complete或catchError方法来结束openImagePicker的调用;甚至可以用来组装多个异步操作,并做最终的结束控制,可谓相当灵活。
所以在调用方执行addDialog的时候创建一个Completer,并返回completer.future,等待该对话框消失(pop)时让其对应的Completer执行complete()即可。可见一个DialogElement在队列中会有一个Completer与之对应。
特别注意:如果addDialog的时候DialogElement已存在,DialogQueue不会重复添加,而会更新已在队列中DialogElement的属性数据,并将已入列的DialogElement的Completer.future作为此次addDialog方法调用的返回值。也就是一个Completer.future可能会被多个调用方await;这样便能确保当Dialogpop(Completer.future.complete())的时候,多个调用方都能收到then的回调。
至此,对于「添加DialogElement入列」我们梳理一下:
DialogElement入列前需要查重(依据uniqueKey属性),做属性更新&Completer复用
DialogElement入列后需根据优先级重新排序
尝试展示下一个弹框
classDialogQueue{//标记当前是否有Dialog在展示bool_isShowing=false;//使用Map的方式存储DialogElement&Completer的映射关系finalMap<DialogQueueElement,Completer>_dialogQueue={};Future<T?>addDialog<T>(DialogQueueElementdialog){//1.入列前查重List<DialogQueueElement>keyList=_dialogQueue.keys.toList();intexistIndex=keyList.indexOf(dialog);if(existIndex>=0){DialogQueueElementcurrentDialog=keyList.elementAt(existIndex);Completer<T?>existCompleter=_dialogQueue[currentDialog]asCompleter<T?>;//1.1更新对话框数据currentDialog.update(dialog);//1.2更新排序_sortQueue();//1.3复用CompleterreturnexistCompleter.future;}Completer<T?>dialogCompleter=Completer();_dialogQueue[dialog]=dialogCompleter;//2.按优先级排序_sortQueue();//3.取下一个对话框进行展示_showNext();returndialogCompleter.future;}}Step3:DialogQueue排序
按优先级排序:即priority越大则越优先弹出。由于DialogElement与Completer的映射关系存储于哈希表中,为了实现排序,另外定义了决定DialogElementList顺序的数组_sortedKeys,具体实现如下:
classDialogQueue{bool_isShowing=false;finalMap<DialogQueueElement,Completer>_dialogQueue={};//存储对话框顺序List<DialogQueueElement>_sortedKeys=[];//排序_sortQueue(){_sortedKeys=_dialogQueue.keys.toList();_sortedKeys.sort((a,b){if(a.priority>b.priority){return-1;}elseif(a.priority<b.priority){return1;}return0;});}}Step4:DialogQueue弹窗时机
两个时机:
每当一个DialogElement入列时
每当一个DialogElement消失时
_showNext(){if(!_isShowing&&_dialogQueue.isNotEmpty){_isShowing=true;DialogQueueElementnextDialog=_sortedKeys.first;Completer?nextCompleter=_dialogQueue[nextDialog];_dialogQueue.remove(nextDialog);_sortedKeys.remove(nextDialog);//使用then监听对话框的消失nextDialog.showDialog().then((value){nextCompleter?.complete();//继续下一个弹框_isShowing=false;_showNext();});}}至此,一个简易的DialogQueueforFlutter就算完整了,下面我们进入踩坑环节。
踩坑环节踩坑1:在使用过程中发现了一种情况弹窗队列会直接报废,导致队列中余下的对话框都无法弹出。
还原下事发现场:
APP内从PageA跳PageB再跳PageC
在PageC此时发起若干个弹框请求入列
首个对话框弹出,点击对话框按钮执行Navigator.of(context).pushNamedAndRemoveUntil(PageA)
此时问题复现。咋回事?
先说结论:Flutternavigator执行pushNameAndRemoveUntil的时候把页面栈的历史元素直接remove,但未结束Route元素对应的future,导致正在展示的对话框的then回调得不到执行,继而DialogQueue中的_isShowing标识一直为true且无法调度下一个对话框的显示
nextDialog.showDialog().then((value){//?导致下方的逻辑都无法执行nextCompleter?.complete();_isShowing=false;_showNext();});看看源码:flutter/lib/src/widgets/navigator.dart
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);0而正常pop一个页面又是什么样的呢?
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);1所以可见,同样是将页面移除,remove和pop有着本质的不同,pop会执行Completer.future.complete()继而触发then的回调,而remove是不会的。
方案一:监听Navigator路由动态变化
原理:当DialogQueue正在展示弹框时,将发生的didRemove行为及其目标pushRoute透传给业务方,由业务方来决定队列的下一步操作(清空队列或择机重弹)。也就是当DialogQueue当前正在展示的对话框被无情remove掉的时候,队列的按序弹框被强制中断,我们便允许业务方在合适的时候进行修复处理。
通过给我们业务的根WidgetMateralApp的navigatorObservers注入我们自定义的RouteObserver就能监听到页面被remove的情况。
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);2方案二:代理NavigatorState的pushNameAndRemoveUntil方法
通过定义全局的NavigatorState替代Navigator.of(context)来执行页面导航的动作。如下:
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);3两种方法都可行,只不过方案二的侵入性略高,但它的好处也是明显的,请看「踩坑2」。
踩坑2:弹框队列中的弹框元素持有过期的BuildContext,导致Navigator.of(context)为空
结合模型场景,我们看看问题出在哪:
初始页面栈:PageA>PageB>PageC(PageC在栈顶)
此时PageC收到多个弹框请求,构建多个DialogElement入列
相应的,在构建业务对话框的时候,我们往往会给对话框的取消按钮添加这么一句让对话框消失的代码:
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);4注意!此时Navigator.of(context)的BuildContextcontext实例来源于Page3。
那么当Page3被pop或者remove的时候,context实例就算还存在于内存,但我们通过Navigator.of(context)试图获取的NavigatorState是为空的。也就意味着之前所构建好入列的业务对话框,其Navigator.of(context).pop()是无法执行的;对话框无法弹出消失也就意味着「弹框队列无法正常运转」。
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);5所以,解决这个问题的方法,就是入列的对话框,使用NavigatorState的时候,不要依赖于当前Widgetbuild传入的context,而应该使用如「踩坑1」方案二所提及的全局NavigatorState
//XXXBusiness.dart业务模块弹框showModalBottomSheet(context:context,backgroundColor:Colors.white,builder:(BuildContextcontext){returnSomeWidget();},);6踩坑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