App里总会有很多的弹窗,为了美观,大多数弹窗都需要盖住导航栏;这时弹窗会添加到window上以满足需求。但添加到window上的弹窗却不方便管理,也与页面脱离关系,如果有异步的情况,弹窗会更加复杂难以处理,如何才能对window弹窗统一进行管理,解决这些问题?
window弹窗面临的问题:
- 为了统一管理弹框,首先需要对window弹窗可能会出现的问题进行梳理,避免以后在维护弹窗时出现问题,到时候再进行兼容和修改会比较麻烦。这里列举了一些常见的问题,如果有未考虑到的情况,可以在评论里补充。
1、多个弹窗可能会产生重叠:如app启动的时候有2个弹窗,正巧2个弹窗都触发展示,这时候这2个弹窗就会重叠在一起。
2、弹窗无法与页面关联:如登录后有个弹框需要在tab2页显示,但是启动后首屏页为tab1,这时候弹框就不能显示,当tab2出现时才显示。
3、弹窗无法设置优先级:一个比较简单的例子:有多个弹层的新手引导,用户关闭引导页1时,按顺序呈现引导页2、引导页3, 如果中间有其他弹窗出现的逻辑,应该等待引导页结束再展示。
4、弹窗无法留活:一个简单的例子:一个活动弹窗含有2个活动,点击活动A进入详情页,此时window弹窗应该消失,当从详情页返回时,活动弹窗应该继续展示,才能点击进入活动B查看详情。为了避免重新触发弹窗的逻辑,应该对弹窗进行缓存。
5、异步弹窗处理复杂:例如网络请求弹窗的数据,弹窗的展示因此延时,用户在此期间跳转其他页面,或者当前页面已经返回,因为是window弹窗,弹窗则不应该显示出来。
6、弹窗不能自动关闭:例如用户被迫下线,此时app的所有弹窗都应该自动移除,或者弹窗展示情况下app发生页面跳转,避免弹窗忘记关闭的情况,也应该自动移除现有的弹窗。
问题4效果对比-前:
问题4效果对比-后:
问题分析:
一、多个弹框重叠冲突: 这个问题比较好解决,简单的做法是使用信号量来限制当前弹窗的数量,让弹窗一个一个的出现。创建一个弹窗manager,添加show和dismiss方法, show方法lock, dismiss方法 Release Lock。
- (void)show
{
//位于非主线程 不阻塞
dispatch_async(dispatch_queue_create(QUEUE_NAME, DISPATCH_QUEUE_SERIAL), ^{
//Lock
dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
//保证主线程UI操作
dispatch_async(dispatch_get_main_queue(), ^{
[[[UIApplication sharedApplication] keyWindow] addSubview:self];
});
});
}
- (void)dismiss
{
dispatch_async(dispatch_queue_create(QUEUE_NAME, DISPATCH_QUEUE_SERIAL), ^{
//Release Lock
dispatch_semaphore_signal(_globalInstancesLock);
dispatch_async(dispatch_get_main_queue(), ^{
[self removeFromSuperview];
});
});
}
但是使用信号量来处理弹窗展示的数量,这种方式只能满足让弹窗一个个出现,没办法删除或者变更未展示的弹框,是不方便对弹窗进行管理的。
这时候使用队列是一个比较好的选择,在show的时候把弹窗添加进队列中, dismiss的时候从队列里移除,当上一个弹窗dimiss,从队列里选出下一个要展示的,这样也能做到弹窗始终只会有一个正在展示,而未展示的弹窗则在队列中等待展示。
+ (void)showView:(UIView *)view {
if ([self shareInstance].currentView == nil) {
// 当前无弹窗展示直接展示
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
} else {
// 当前有弹窗则加入队列中
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = view;
[[self shareInstance].arrayWaitViews addObject:model];
}
}
+ (void)dismiss:(UIView *)view {
if ([self shareInstance].currentView == view) {
// 删除当前弹窗
[view removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
} else {
// 删除队列中的弹窗
for (int i = 0; i < [self shareInstance].arrayWaitViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayWaitViews[i];
if (model.view == view) {
[[self shareInstance].arrayWaitViews removeObject:model];
}
}
}
// 展示下一个弹窗
if ([self shareInstance].arrayWaitViews.count > 0) {
for (int i = 0; i < [self shareInstance].arrayWaitViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayWaitViews[i];
if (model.view) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
}
}
}
}
二、弹窗无法与页面关联: 弹窗要在指定的页面显示, 因为之前已经有了弹窗队列,此时应该把弹窗添加到队列中去等待展示,但是此时队列里的弹窗并没有页面限制,即使放进队列里也会在其他页面出现。 所以需要对每个弹窗指定一个展示的页面, 当从队列里推出弹窗进行展示时,判断当前页面是否为可展示的页面,如果不是则继续在队列里等待。
+ (void)showView:(UIView *)view
page:(Class)page
{
UIViewController *currentController = [UIViewController currentViewController];
if ([self shareInstance].currentView == nil &&
[currentController isMemberOfClass:page]) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
} else {
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = view;
model.pageClass = page;
[[self shareInstance].arrayWaitViews addObject:model];
}
}
给弹窗指定页面后,这时候需要对当前页面的变化进行监听,当指定页面出现时弹窗应该及时呈现出来。这里的做法是hook UIViewController的viewWillAppear方法,在页面变化时发送通知,告诉manager页面发生变化,检索队列里是否有此页面等待展示的弹窗。
考虑到重写viewWillAppear方法后,每次页面变化都会发送通知,可能会带来一定的性能问题, 所以manager只有在队列里有等待的弹窗时才注册通知,无等待的弹窗则不需要知道页面的变化,这时候可以移除通知。(如果有更好的监听页面变化的方法望告之)
+ (void)viewWillAppearNotification:(NSNotification *)notification {
id identifier = notification.object[LYViewControllerClassIdentifier];
[self viewNeedShowFromQueueWithPage:identifier];
}
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 当前屏幕有弹框,则不显示
if ([self shareInstance].currentView) {
return;
}
// 队列里无等待显示的视图
if (![self shareInstance].arrayWaitViews.count) {
return;
}
// 推出队列中需要展示的视图进行展示
__block LYWindowScreenModel *model = nil;
[[self shareInstance].arrayWaitViews enumerateObjectsUsingBlock:^(LYWindowScreenModel *obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.pageClass && obj.view) {
if ([page isKindOfClass:obj.pageClass]) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:obj.view];
model = obj;
*stop = YES;
}
}
}];
if (model) {
[[self shareInstance].arrayWaitViews removeObject:model];
}
}
三、设置弹窗优先级:因为现在有了弹窗等待队列,弹窗的优先级也就可以很好的解决,在添加进队列时,给弹窗设置一个level值,根据level值排序后从队列里推出展示的弹窗自然是优先级比较高的弹窗。
因为有时候无法确认其他弹框的level值,level的设定建议以场景来设置level,因为同一场景的多个弹窗大部分情况下无需按优先级展示,同等level能按先后顺序展示即可。
如:
typedef enum : NSUInteger {
LYLevelHigh = -100, // 优先级最高, 场景如开屏动画
LYLevelMedium = -1, // 优先级高, 场景如启动完成广告弹窗
LYLevelDefault = 0, // 优先级一般,场景如新手引导
LYLevelLow = 100, // 优先级低,场景如常用弹窗
} LYWindowScreenLevel;
如:开屏动画 > 广告 > 引导 > 业务弹窗,这样可以满足app内绝大多数的弹窗展示顺序,如果有变动可再改动自己修改值。
四、弹窗无法留活:还是之前抛出的问题,点击活动A进入详情页,此时window弹窗应该消失,当从详情页返回时,活动弹窗应该继续展示。为了避免再一次执行弹窗的展示逻辑,所以需要对当前的弹窗进行缓存,等待页面重新回来时展示。 这种情况只是页面暂时离开,页面并未从页面路径栈里消失,如果页面已经不存在,那么缓存里的弹窗也应该移除。
新建一个弹框的缓存数组,这里并没有放入之前等待队列里, 是因为等待队列里的弹窗都是仍未展示的,无论页面是否新建(根据class),当这个页面是弹窗指定的归属类时都可以展示出来。而缓存的弹窗是与具体的页面关联的(根据obj),如果页面返回再重新进入,页面已经重新构造,上次缓存的弹窗是不应该再展示的,因为页面重新构造后可能会重新触发弹窗的逻辑,这时候可能就会2个相同的弹窗,所以这里用了2个队列存储弹框。
具体实现是:
- hook UIViewController的viewWillDisappear方法,当有需要缓存的弹窗时添加监听,当页面离开时移除当前展示的弹框,并且当前弹窗添加进缓存队列里。
+ (void)viewWillDisAppearNotification:(NSNotification *)notification {
NSString *strClass = notification.object[LYViewControllerClassName];
id identifier = notification.object[LYViewControllerClassIdentifier];
if ([self shareInstance].currentView && [strClass isEqualToString:NSStringFromClass([self shareInstance].pageClass)]) {
if ([self shareInstance].keepAlive) {
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = [self shareInstance].currentView;
model.pageClass = [self shareInstance].pageClass;
model.level = LYLevelHigh;
model.keepAlive = [self shareInstance].keepAlive;
model.identifier = identifier;
model.addCompleted = [self shareInstance].addCompleted;
// 添加进缓存数组
[[self shareInstance].arrayAliveViews addObject:model];
[self addWaitShowNotification];
}
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
}
-
如果这个页面已经销毁,那么这个弹框也没有意义再缓存,当页面已经离开时,判断缓存队列里的弹窗所指定的缓存页面是否存在,如果已经销毁则删除弹窗。
如何判断页面已经不存在?如果只是判断Controller是否为空,这显然是不行的,因为即使页面返回,Controller可能仍被其他类持有。所以仍然需要判断页面是否还在某个页面路径栈里, 而OC中带有页面容器的情况有,UINavigationController、presentedViewController、UITabBarController、UISplitViewController、Sub-Controller。tabBar 和 Split 基本上都会包含一次navigation,而subController也会根据父级的路径栈一同消失。 所以页面可以通过Controller是否还有navigationController或者presentingViewController来判断当前页面是否已经从页面栈里移除。
// 如果存活弹框的归属页面已移除,则移除该页面的所有弹框
+ (void)viewDidDisAppearNotification:(NSNotification *)notification {
if ([self shareInstance].arrayAliveViews.count) {
for (int i = 0; i < [self shareInstance].arrayAliveViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayAliveViews[i];
BOOL exist = model.identifier.navigationController || model.identifier.presentingViewController;
if (!exist) {
[[self shareInstance].arrayAliveViews removeObject:model];
[self removeNotification];
}
}
}
}
- 当监听到页面返回到原来页面时弹窗应该重新出现,弹窗从缓存队列里删除,并且添加进等待队列里,等待显示。
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 判断当前页是否有存活的弹框,有则加入队列中。
if ([self shareInstance].arrayAliveViews.count) {
for (int i = 0; i < [self shareInstance].arrayAliveViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayAliveViews[i];
if (page == model.identifier) {
[[self shareInstance].arrayWaitViews addObject:model];
[[self shareInstance].arrayAliveViews removeObject:model];
}
}
}
五、异步弹窗情况:异步弹框的情况稍微复杂,基本上都会跟网络请求扯上联系,如果网络请求未完成的情况下,频繁的“进入-返回”,这可能会出现多个弹窗的网络请求同时在请求,这时候会产生3个问题:
- 将会有多个相同弹窗出现(如果未对弹窗限制次数)
- 弹窗可能不是最后一次请求想要的弹窗。
- 弹窗可能无法响应点击事件。
想要统一支持各种业务场景的弹框,异步的问题就需要解决,为了更清晰的理解各种异步场景弹窗的展示逻辑,这里列出了所有异步场景的情况:
- A—B, B—A ,A—B2, 弹窗延迟加载时在本类, 但是实例发生变换 【不缓存】
- A—B, B—A ,A—B, 弹窗延迟加载时在本类, 实例未发生变换。 【缓存】
- A—B, B—C ,C—B, 弹窗延迟加载时不在本类,B还在页面栈。【缓存】
- A—B, B—C ,C—A, 弹窗延迟加载时不在本类,B不在页面栈。【移除缓存】
- A—B, B—A , 弹窗延迟加载时不在本类。 B未知是否释放 【不确定】
六、自动删除弹窗:有些app需要登录之后才能展示弹窗,如果用户下线或者被踢,这个用户的弹窗都应该移除。 因为有了队列,当用户下线时移除当前展示的弹窗和队列里等待弹窗就可以统一移除manager管理的所有弹窗。
+ (void)removeAllQueueViews {
[[self shareInstance].arrayAliveViews removeAllObjects];
[[self shareInstance].arrayWaitViews removeAllObjects];
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
当页面离开时,为避免忘记手动删除弹窗,window展示在其他地方,此页面的弹窗也应该自动删除,这里在问题四里面已经得到解决,在页面离开时自动移除弹窗。
父控制器问题:
因为这里采用的是 -viewWillAppear 和 -viewWillDisappear 的方式来观察页面的变化(如果有其他方法望告之),如果有弹窗是在父控制器里触发的,那么页面的变化可能是在子控制器里进行页面切换变化的,这时候父控制器里的弹框可能就不会准确。这里父控制器只是一个容器,真正呈现页面元素的是子控制器,所以对于父控制器里的弹框,他的页面归属,应该是呈现页面的子控制器。 对父控制器里的弹框指定多个归属页面,这样就能实现父控制里的弹框可以精准的在部分子控制器显示,或者不显示,让弹框可以指定多个页面。
page = @[class1, class2, class3];
iOS13问题:
iOS13的present默认是非全屏的展示,present之后页面并不会走viewWillDisappear方法,导致弹窗不会自动移除。 这种情况需要手动去移除弹窗或者走iOS13以前的present方式。
动画:
因为最终弹窗添加到window上或者移除都是在manager里处理的,有些情况可能弹窗的出现和移除需要动画进行修饰,而等待队列里的弹窗就无法知道具体的动画。这种情况,可以添加一个block来告诉外界该弹窗刚刚被添加到window上,你可自行处理自己的动画操作
[LYWindowScreenView addWindowScreenView:self.label2 page:self.class level:LYLevelLow keepAlive:YES addCompleted:^{
self.label2.frame = CGRectMake(50, 700, CGRectGetWidth(self.view.frame)-100, CGRectGetHeight(self.view.frame)-270);
[UIView animateWithDuration:0.3 animations:^{
self.label2.frame = CGRectMake(50, 250, CGRectGetWidth(self.view.frame)-100, CGRectGetHeight(self.view.frame)-270);
}];
}];
总结
到此,一开始提出的6个window弹窗问题都已得到解决,实现思路比较简单,主要通过队列和监听页面变化来处理指定页面和顺序的问题。由于keywindow的不确定性,这里的弹框都是统一添加到appdelegate.window上。
大致效果: