一次弹窗异常引发的思考:iOS present / push 底层机制全解析
这篇文章从一个真实线上问题讲起: 在弹窗VC 里点了一行cell,结果直接跳回了UITabBarController。 借着排查这个 Bug 的过程,我系统梳理了一遍 iOS 中与导航相关的底层机制:present/dismiss、push/pop、“获取顶层 VC(getTopVC)”、以及 UITableView 的选中/取消逻辑。
一、视图控制器层级:Navigation 栈 vs Modal 链
1. 两套完全独立的层级体系
Navigation 栈(push/pop)
- 结构:
UINavigationController.viewControllers = [VC0, VC1, VC2, ...] - 行为:
-
pushViewController::追加到数组尾部 -
popViewControllerAnimated::从数组尾部移除
-
- 只影响 导航栈 中的顺序,不改变谁
present了谁。
Modal 链(present/dismiss)
- 结构:由
presentingViewController/presentedViewController串联成一条链:A.presentedViewController = BB.presentedViewController = C
- 行为:
-
presentViewController::在当前 VC 上方展示一个新 VC -
dismissViewControllerAnimated::从某个 VC 开始,把它和它上面所有通过它present出来的 VC 一起收回
-
记忆方式:
- push/pop 操作的是 “数组”(导航栈)
- present/dismiss 操作的是 “链表”(模态链)
2. 组合层级的典型例子
A(Tab 内业务页)
└─ present → B(弹窗或二级页,带导航)
└─ push → C(B 的导航栈里再 push 出来的 VC)- 导航栈(以 B 的导航控制器为例):[B, C]
- 模态链:
A -(present)-> B
关键结论:
dismiss B ⇒ B 和 B 承载的那棵 VC 树一起消失 ⇒ 导航回到 A(B 的 presentingViewController)。
UIKit 不支持 “只 dismiss B 保留 C” 这种结构。
二、dismissViewControllerAnimated: 的真实含义
[vc dismissViewControllerAnimated:YES completion:nil];核心点:
- 这个调用作用在 “
vc所在的模态链” 上,而不是导航栈。 - 如果
vc是被某个 VC 通过presentViewController:推出来的,那么:- 系统会找到它的
presentingViewController - 把从
vc起到链尾的所有 VC 都 dismiss 掉 - 显示回到
presentingViewController
- 系统会找到它的
1. 谁调用 vs 谁被 dismiss
很多人容易混淆这两种写法:
[self dismissViewControllerAnimated:YES completion:nil];
[[self getTopVC] dismissViewControllerAnimated:YES completion:nil];
只要这两种写法最终作用到的是同一个 VC,它们的行为完全一致。
- 决定回到哪里的,是「被 dismiss 的那个 VC 的
presentingViewController」,而不是“谁来触发这次调用”。 - 这也是为什么单纯把
self改成[self getTopVC]并不能改变 dismiss 之后的落点。
2. presentingViewController 的生命周期
[parentVC presentViewController:childVC animated:YES completion:nil];
- 在这行代码执行完成时:
-
childVC.presentingViewController = parentVC被永久确定
-
- 后续不管从哪里、什么时候触发:
- 只要 dismiss 的对象是
childVC,最终都会回到同一个parentVC
- 只要 dismiss 的对象是
三、“顶层 VC” 工具(如 getTopVC)的时序问题
很多项目中都会有类似如下工具方法:
@implementation UIViewController(Additions)
- (UIViewController*)getTopVC {
if (self.presentedViewController) {
return [self.presentedViewController getTopVC];
}
if ([self isKindOfClass:UITabBarController.class]) {
return [[(UITabBarController*)self selectedViewController] getTopVC];
}
else if ([self isKindOfClass:UINavigationController.class]) {
return [[(UINavigationController*)self visibleViewController] getTopVC];
}
return self;
}
@end
@implementation UIApplication (Additions)
+ (UIViewController *)getCurrentTopVC{
UIViewController *currentVC = [UIApplication sharedApplication].delegate.window.rootViewController;
return [currentVC getTopVC];
}
@end
关键:这类函数对「调用时机」极度敏感。
情况 1:在“弹窗 VC 还在屏幕上”时调用
比如某个present出来的弹窗 VC 还没有被 dismiss,这时调用 getTopVC(),返回的就是这个弹窗 VC。
情况 2:在“弹窗 VC 已经被 dismiss 掉”之后调用
当 弹窗 VC 已经执行过 dismissViewControllerAnimated:,不再显示在屏幕上,这时再调用 getTopVC(),返回的就是它下面那一层控制器(例如列表页、TabBar 下当前选中的子控制器),而不再是 弹窗 VC 本身。
情况3: 一个典型的 Bug 时序
- 子类在 cell 点击时,调了父类的
didSelectRowAtIndexPath: - 父类内部逻辑(伪代码):
[self dismissViewControllerAnimated:YES completion:^{
if (self.didSelectedIndex) {
self.didSelectedIndex(indexPath.row); // 触发外层 block
}
}];
也就是说:先 dismiss 自己,再回调外层 block
- 外层 block 中再执行:
[[UIApplication getCurrentTopVC] dismissViewControllerAnimated:YES completion:nil];
由于这时 弹框VC 已经被 dismiss 掉,getCurrentTopVC() 拿到的是 下层 VC(例如一个筛选页或 TabBar)
于是第二次 dismiss 把下层页面也关掉了
4. 用户看到的效果就是:
点击弹窗里的一个 cell ⇒ 弹窗消失 ⇒ 当前页面也被关闭 ⇒ 直接回到了 TabBar
根本原因:
第二次调用 getTopVC() 的时机太晚,此时“顶层 VC”已经不是弹窗,而是它下面的页面。
四、UITableView 的选中/取消逻辑
1. 系统接口的作用
-
selectRowAtIndexPath:animated:scrollPosition:会:- 更新 tableView 内部的选中状态;
- 调用 cell 的
setSelected:YES; - 触发
tableView:didSelectRowAtIndexPath:回调。
-
deselectRowAtIndexPath:animated:会:- 清除选中状态;
- 调用 cell 的
setSelected:NO; - 触发
tableView:didDeselectRowAtIndexPath:回调。
也就是说,单靠 deselectRowAtIndexPath:,就已经隐含执行了很多事情,不必再额外手工写 cell.selected = NO。
2. 单选列表中的推荐顺序
UITableView 在单选模式下,用户点一个新 row 时,系统内部的默认顺序是:先调用 didDeselectRowAtIndexPath:(旧 row)→ 再调用 didSelectRowAtIndexPath:(新 row)。 所以在自定义 cell 中的选中/取消逻辑时, 推荐顺序调用这2个方法
例如: 你维护了一个 selectedIndex,在代码中手动切换选中行时,可以这样写:
NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:self.selectedIndex inSection:0];
NSIndexPath *newIndexPath = indexPath;
// 1. 先取消旧的
[tableView deselectRowAtIndexPath:oldIndexPath animated:YES];
// 2. 再选中新的
[tableView selectRowAtIndexPath:newIndexPath
animated:YES
scrollPosition:UITableViewScrollPositionNone];这样能确保:
- 旧 cell 的
setSelected:NO/didDeselect逻辑先执行; - 新 cell 的
setSelected:YES/didSelect后执行; - 对自定义 cell(在
setSelected:里更换图标、颜色等)尤为友好; - 不会出现两个 cell 同时高亮的瞬间状态。
3. 何时可以不再调用父类 tableView:didDeselectRowAtIndexPath:
如果父类的 tableView:didDeselectRowAtIndexPath: 实现只是:
-
(void)tableView:(UITableView *)tableView didDeselectRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; [cell setSelected:NO]; }, 而你在子类里已经调用了deselectRowAtIndexPath:animated:,那么: -
系统内部已经帮你执行了
setSelected:NO; -
再手动调用父类
didDeselectRowAtIndexPath:属于重复操作,可以安全省略。
五、“到 C 后不能回 B”:通过修改导航栈实现
用户需求
当前导航栈:A -> B -> C
期望: 在C上点击返回时直接回到 A,不能再回到 B。
代码实现
-- push 到新 VC,并从栈中移除当前 VC
-- 修改 `viewControllers` 数组, 重置导航栈
- (void)deleteCurrentVCAndPush:(UIViewController *)viewController animated:(BOOL)animated {
UIViewController* top = self.topViewController;
[self pushViewController:viewController animated:animated];
NSMutableArray* viewControllers = [self.viewControllers mutableCopy];
[viewControllers removeObject:top];
[self setViewControllers:viewControllers animated:NO];
}
与 dismiss 的区别
- 修改导航栈:
- 仅操作
navigationController.viewControllers数组; - 不改变 modal 链,
presentingViewController关系保持不变;
- 仅操作
- dismiss 某个 VC:
- 只看 modal 链;
- 会回到
presentingViewController; - 无法仅移除 B 而让 C 留在界面上。
六、整体总结
-
理解 Navigation 栈与 Modal 链是所有导航问题的基础:
- push/pop 只改数组
- present/dismiss 只改链表
-
dismissViewControllerAnimated:的返回点由presentingViewController决定:- 谁调用不重要,谁被 dismiss 才重要。
-
“获取顶层 VC” 的工具对调用时机非常敏感:
- 在 VC 被 dismiss 前后调用,返回的完全是不同的对象;
- 在错误的时机用它再发起一次 dismiss,往往会“多退一层”。
-
手动控制 UITableView 的选中状态时,优先使用 select/deselect 接口,并保持“先取消旧选中,再选中新行”的顺序。
-
“到 C 后不能回 B”这类需求,本质是对导航栈的重写,而非 dismiss 某个 VC:
- 正确做法是修改
viewControllers数组,或使用封装好的 “deleteCurrentVCAndPush” 类方法。
- 正确做法是修改
掌握这些底层规则,遇到类似“弹窗关闭顺序错乱”、“页面一点击就跳回根控制器”、“导航上跳过某一层”等问题时,就能更快定位根因,设计出行为可控、易维护的解决方案。