阅读视图

发现新文章,点击刷新页面。

一次弹窗异常引发的思考: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 = 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

很多人容易混淆这两种写法:

[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

三、“顶层 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 时序

  1. 子类在 cell 点击时,调了父类的 didSelectRowAtIndexPath:
  2. 父类内部逻辑(伪代码):
 [self dismissViewControllerAnimated:YES completion:^{
     if (self.didSelectedIndex) {
         self.didSelectedIndex(indexPath.row);  // 触发外层 block
     }
 }];

也就是说:先 dismiss 自己,再回调外层 block

  1. 外层 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 留在界面上。

六、整体总结

  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” 类方法。

掌握这些底层规则,遇到类似“弹窗关闭顺序错乱”、“页面一点击就跳回根控制器”、“导航上跳过某一层”等问题时,就能更快定位根因,设计出行为可控、易维护的解决方案。

❌