iOS 聊聊present和dismiss

7,768 阅读8分钟

今天遇到一个崩溃,最后发现是因为present弹了一个模态视图导致的。今天就总结一下关于present和dismiss相关的问题。

先列几个问题,你能答上来吗

假设有3个UIViewController,分别是A、B、C。下文中的“A弹B”是指
[A presentViewController:B animated:NO completion:nil];

  1. 如果A已经弹了B,这个时候你想在弹一个C,是应该A弹C,还是B弹C,A弹C可不可行?
  2. 关于UIViewController的两个属性,presentingViewController和presentedViewController。
    如果A弹B,A.presentingViewController = ?,A.presentedViewController = ?,B.presentingViewController = ?,B.presentedViewController = ? 如果A弹B,B弹C呢?
  3. 如果A弹B,B弹C。A调用dismiss会有什么结果?B调dismiss会有什么结果?

下文将逐个解答。

问题2:presentingViewController和presentedViewController属性

我们先看看问题2。UIViewController有两个属性,presentedViewController和presentingViewController。大家看注释应该大致理解了它的意思。

//UIKit.UIViewController.h
// The view controller that was presented by this view controller or its nearest ancestor.
@property(nullable, nonatomic,readonly) UIViewController *presentedViewController  NS_AVAILABLE_IOS(5_0);

// The view controller that presented this view controller (or its farthest ancestor.)
@property(nullable, nonatomic,readonly) UIViewController *presentingViewController NS_AVAILABLE_IOS(5_0);

presentingViewController:负责承载被弹出视图的控制器,就是被覆盖的那个。
presentedViewController:被弹出的那个控制器,即上面那个。

值得注意的是,注释中提到“or its nearest/farthest ancestor”,意思是这两个属性的值不一定是和弹操作直接关联的视图控制器。

下面我们来看三个示例,来理解这两个属性。

示例1:常规情况

我们创建A、B、C三个试图控制器,A弹B,B弹C(如下图),分别打印各自的presentedViewController和presentingViewController属性。

结果如下:

---------------------AB后---------------------
 A.presentingViewController (null)     
 A.presentedViewController B          
 B.presentingViewController A
 B.presentedViewController (null)
---------------------B弹C后---------------------
 A.presentingViewController (null)
 A.presentedViewController B
 B.presentingViewController A
 B.presentedViewController C
 C.presentingViewController B
 C.presentedViewController (null)

从上面的结果可以得出,presentingViewController属性返回相邻“父节点”,presentedViewController属性返回相邻“子节点”,如果没有父节点或子节点,返回nil。注意,这两个属性返回的是当前节点直接相邻父子节点,并不是返回最底层或者最顶层的节点(这点和文档注释有出入) (后来我发现文档并没有问题,只是我把ancestor这个概念理解错误,ancestor在这里应该理解为parentViewController,详细内容请看下面childViewControllers部分)。下面对照例子解释下这个结论。

---------------------AB后---------------------
A.presentingViewController (null)      //因为A是最底层,没有父节点,所以A的父节点返回nil
 A.presentedViewController B           //BA的上层,BA的子节点,所以A的子节点返回B
 B.presentingViewController A          //B的父节点是A,所以B的父节点返回A
 B.presentedViewController (null)      //B没有子节点,所以B的子节点返回nil
---------------------B弹C后---------------------
 A.presentingViewController (null)     //A是最底层,没有父节点
 A.presentedViewController B           //A的直接子节点是B
 B.presentingViewController A          //B的父节点是A
 B.presentedViewController C           //B的子节点是C
 C.presentingViewController B          //C的直接父节点是B
 C.presentedViewController (null)      //C是顶层,没有子节点

示例2:子控制器childViewControllers

苹果官方的文档《Presenting a View Controller》中有这么一句话:

The view controller that calls the presentViewController:animated:completion: method may not be the one that actually performs the modal presentation. The presentation style determines how that view controller is to be presented, including the characteristics required of the presenting view controller. For example, a full-screen presentation must be initiated by a full-screen view controller. If the current presenting view controller is not suitable, UIKit walks the view controller hierarchy until it finds one that is. Upon completion of a modal presentation, UIKit updates the presentingViewController and presentedViewController properties of the affected view controllers.

文档上说,调用presentViewController:animated:completion:方法的那个viewController并不一定是真正承载被弹窗口的控制器。modalPresentationStyle属性决定了由谁来弹窗和弹窗的样式。比如,如果UIModalPresentationFullScreen风格要求当前控制器必须是全屏的,如果当前窗口不满足,UIKit会遍历控制器的继承树直到找到全屏的,当弹窗动画完成后,UIKit会更新相关控制器的的presentingViewController和presentedViewController属性

我总结为:默认情况(UIModalPresentationFullScreen、iOS13 UIModalPresentationAutomatic),present会在根视图控制器(最顶层的parentViewController)上弹。
我们看下图,我举了一个复杂的情况来分析。

图1

例如 A present Navi(B),那么

  1. B.presentingViewController = Navi.presentingViewController = A
  2. B.presentedViewController = Navi.presentedViewController = nil
  3. A.presentingViewController = nil
  4. A.presentedViewController = Navi

解释一下上面的例子
假如,A和B是UIViewController,Navi是UINavigationController,B是Navi的rootViewController。

  1. 因为B是Navi的子控制器,根据刚才的结论,子控制的presentingViewController和presentedViewController由父控制器决定,B的父控制器是Navi,Navi的presentingViewController是谁呢,根据之前的结论,presentingViewController属性返回相邻的父节点,即A。所以,B.presentingViewController = Navi.presentingViewController = A。
  2. 同理1.
  3. A没有父控制器,A.presentingViewController等于A相邻父节点,由于A没有父节点,所以A.presentingViewController = nil。
  4. A没有父控制器,A.presentedViewController等于A相邻子节点,A的子节点是Navi,所以A.presentedViewController = Navi。

问题1:present的层级问题,多次弹窗由谁去弹

如果A已经弹了B,这个时候想要在弹一个C,正确的做法是,B弹C。

如果你尝试用A弹C,系统会抛出警告,并且界面不会有变化,即C不会被弹出,警告如下:

Warning: Attempt to present <UIViewController: 0x7fbcecc04e80> on <ViewController: 0x7fbcecd09850> which is already presenting <UIViewController: 0x7fbcef2024c0>

把警告内容翻译一下,
"尝试在A上弹C,但是A已经弹了B"

这下就很清楚了,使用present去弹模态视图的时候,只能用最顶层的的控制器去弹,用底层的控制器去弹会失败,并抛出警告。

我简单地写了个方法来获取传入viewController的最顶层子节点,大家可以参考下。

//获取最顶层的弹出视图,没有子节点则返回本身
+ (UIViewController *)topestPresentedViewControllerForVC:(UIViewController *)viewController
{
    UIViewController *topestVC = viewController;
    while (topestVC.presentedViewController) {
        topestVC = topestVC.presentedViewController;
    }
    return topestVC;
}

一个崩溃问题

文章开头我提到过一个崩溃问题,下面是崩溃时Xcode的日志:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Application tried to present modally an active controller <ViewController: 0x7feddce0c9e0>.'

经过排查我发现,如果present一个已经被presented的视图控制器就会崩溃,一般是不会出现这种情形的,如果出现了可能是因为同一行present的代码被多次执行导致的,注意检查你的代码逻辑,修复bug

问题3:dismiss方法

dismiss方法大家都很熟悉吧
- (void)dismissViewControllerAnimated: (BOOL)flag completion: (void (^ __nullable)(void))completion
一般,大家都是这么用的,A弹B,B中调用dismiss消失弹框。没问题。
那,A弹B,我在A中调用dismiss可以吗?——也没问题,B会消失。
那,A弹B,B弹C。A调用dismiss,会有什么样的结果?是C消失,还是B、C都消失,还是会报错? ——正确答案是B、C都消失。 如果B掉dismiss呢?B会消失吗?——答案是:只有C会消失,B不会消失。

我们来看下官方文档对这个方法的说明。

The presenting view controller is responsible for dismissing the view controller it presented. If you call this method on the presented view controller itself, UIKit asks the presenting view controller to handle the dismissal. If you present several view controllers in succession, thus building a stack of presented view controllers, calling this method on a view controller lower in the stack dismisses its immediate child view controller and all view controllers above that child on the stack. When this happens, only the top-most view is dismissed in an animated fashion; any intermediate view controllers are simply removed from the stack. The top-most view is dismissed using its modal transition style, which may differ from the styles used by other view controllers lower in the stack.

文档指出
1. 父节点负责调用dismiss来关闭他弹出来的子节点,你也可以直接在子节点中调用dismiss方法,UIKit会通知父节点去处理。
2. 如果你连续弹出多个节点,应当由最底层的父节点调用dismiss来一次性关闭所有子节点;如果中间节点调dismiss,中间节点上面的节点都会消失,但中间几点本身并不会消失,这里要注意。
3. 关闭多个子节点时,只有最顶层的子节点会有动画效果,下层的子节点会直接被移除,不会有动画效果。

经过我的测试,确实如此。

一个常见的错误

下面这个错误很容易遇到吧。

Warning: Attempt to present <UIViewController: 0x7fa43ac0bdb0> on <ViewController: 0x7fa43ae15de0> whose view is not in the window hierarchy!

你的代码可能是这样的

- (void)viewDidLoad {
    [super viewDidLoad];
 
    _BViewController = [[UIViewController alloc] init];
    [self presentViewController:_BViewController animated:NO completion:nil];
}

上述代码都会失败,B并不会弹出,并会抛出上面的警告。警告说得很明确,self.view还没有被添加到视图树(父视图),不允许弹出视图。
也就是说,如果一个viewController的view还没被添加到视图树(父视图)上,那么用这个viewController去present会失败,并抛出警告。

如果你非要这么写的话,可以把present的部分放到-viewDidAppear方法中,因为-viewDidAppear被调用时self.view已经被添加到视图树中了(强烈不推荐)。

正确的做法应该是使用childViewController,你可以用添加子视图、子控制器的方式来实现类似效果(推荐)。**

- (void)viewDidLoad {
    [super viewDidLoad];
 
    _BViewController = [[UIViewController alloc] init];
    _BViewController.view.frame = self.view.bounds;
    [self.view addSubview:_BViewController.view];
    [self addChildViewController:_BViewController];  //这句话一定要加,否则视图上的按钮事件可能不响应
}

关于UIView的生命周期,viewDidLoad系列方法的调用顺序,可以参考这篇博文,写得非常好。UIView生命周期详解

如果觉得这篇文章对你有帮助,请点个赞吧。
转载请注明出处,谢谢!

参考链接
你真的了解iOS中控制器的present和dismiss吗?