一次弹窗异常引发的思考:iOS present / push 底层机制全解析

这篇文章从一个真实线上问题讲起: 在弹窗VC 里点了一行cell,结果直接跳回了UITabBarController。 借着排查这个 Bug 的过程,我系统梳理了一遍 iOS 中与导航相关的底层机制:present/dismiss、push/pop、"获取顶层 VC(getTopVC)"、以及 UITableView 的选中/取消逻辑。


1. 两套完全独立的层级体系

Navigation 栈(push/pop)

  • 结构:UINavigationController.viewControllers = [VC0, VC1, VC2, ...]
  • 行为:
    • pushViewController::追加到数组尾部
    • popViewControllerAnimated::从数组尾部移除
  • 只影响 导航栈 中的顺序,不改变谁 present 了谁。

Modal 链(present/dismiss)

  • 结构:由 presentingViewController / presentedViewController 串联成一条链:
    • A.presentedViewController = B
    • B.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\];**核心点:** 1. 这个调用作用在 "`vc` 所在的模态链" 上,而不是导航栈。 2. 如果 `vc` 是被某个 VC 通过 `presentViewController:` 推出来的,那么: * 系统会找到它的 `presentingViewController` * 把从 `vc` 起到链尾的所有 VC 都 dismiss 掉 * 显示回到 `presentingViewController` ### 1. 谁调用 vs 谁被 dismiss 很多人容易混淆这两种写法: ```objectivec [self dismissViewControllerAnimated:YES completion:nil]; [[self getTopVC] dismissViewControllerAnimated:YES completion:nil]; ``` **只要这两种写法最终作用到的是同一个 VC,它们的行为完全一致。** * 决定回到哪里的,是「被 dismiss 的那个 VC 的 `presentingViewController`」,而不是"谁来触发这次调用"。 * 这也是为什么单纯把 `self` 改成 `[self getTopVC]`并不能改变 dismiss 之后的落点。 ### 2. `presentingViewController` 的生命周期 ```ini [parentVC presentViewController:childVC animated:YES completion:nil]; ``` * 在这行代码执行完成时: * `childVC.presentingViewController = parentVC` 被永久确定 * 后续不管从哪里、什么时候触发: * 只要 dismiss 的对象是 `childVC`,最终都会回到同一个 `parentVC` *** ** * ** *** ## 三、"顶层 VC" 工具(如 `getTopVC`)的时序问题 很多项目中都会有类似如下工具方法: ```objectivec @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 时序 1. 子类在 cell 点击时,调了父类的 `didSelectRowAtIndexPath:` 2. 父类内部逻辑(伪代码): ```objectivec [self dismissViewControllerAnimated:YES completion:^{ if (self.didSelectedIndex) { self.didSelectedIndex(indexPath.row); // 触发外层 block } }]; ``` 也就是说:**先 dismiss 自己,再回调外层 block** 3. 外层 block 中再执行: ```objectivec [[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`,在代码中手动切换选中行时,可以这样写: ```ini 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。 代码实现 ```ini -- 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 留在界面上。 *** ** * ** *** ## 六、整体总结 1. **理解 Navigation 栈与 Modal 链是所有导航问题的基础**: * push/pop 只改数组 * present/dismiss 只改链表 2. **`dismissViewControllerAnimated:` 的返回点由 `presentingViewController` 决定**: * 谁调用不重要,谁被 dismiss 才重要。 3. **"获取顶层 VC" 的工具对调用时机非常敏感**: * 在 VC 被 dismiss 前后调用,返回的完全是不同的对象; * 在错误的时机用它再发起一次 dismiss,往往会"多退一层"。 4. **手动控制 UITableView 的选中状态时,优先使用 select/deselect 接口,并保持"先取消旧选中,再选中新行"的顺序**。 5. **"到 C 后不能回 B"这类需求,本质是对导航栈的重写,而非 dismiss 某个 VC**: * 正确做法是修改 `viewControllers` 数组,或使用封装好的 "deleteCurrentVCAndPush" 类方法。 掌握这些底层规则,遇到类似"弹窗关闭顺序错乱"、"页面一点击就跳回根控制器"、"导航上跳过某一层"等问题时,就能更快定位根因,设计出行为可控、易维护的解决方案。

相关推荐
Sherry0072 小时前
从零开始理解 JavaScript Promise:彻底搞懂异步编程
前端·javascript·promise
Toomey2 小时前
一次 npm 更新强制2FA导致的发布失败的排查:403、2FA、Recovery Code、Granular Token 的混乱体验
前端
用户4445543654262 小时前
Android模块化管理
前端
小胖霞2 小时前
vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库
前端·vue.js·前端框架
神算大模型APi--天枢6462 小时前
国产硬件架构算力平台:破解大模型本地化部署难题,标准化端口加速企业 AI 落地
大数据·前端·人工智能·架构·硬件架构
AAA阿giao2 小时前
从“拼字符串”到“魔法响应”:一场数据驱动页面的奇幻进化之旅
前端·javascript·vue.js
donecoding2 小时前
解决 npm 发布 403 错误:全局配置 NPM Automation Token 完整指南
前端·javascript
潜水豆2 小时前
浅记录一下专家体系
前端
梨子同志3 小时前
Node.js 事件循环(Event Loop)
前端