iOS状态栏问题探究

4,659 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

碰到的问题

最近在适配UI的时候,发现在iOS13及以后的版本进入一个视频播放页面后,调用更改状态栏的API无效了。后面发现是由于iOS13以后对Scene的适配以及一个隐藏的UIWindow导致的,iOS13加入Scene后,系统状态栏的实现发现了很大的变化。这里对iOS状态栏进行一个系统的总结备后续参考。

如何控制状态栏

使用UIApplication控制(iOS7之前)

iOS7之前,一般都是通过直接使用UIApplication来变更状态栏的样式和隐藏状态。

//UIApplication
@available(iOS, introduced: 2.0, deprecated: 9.0, message: "Use -[UIViewController preferredStatusBarStyle]")
    open func setStatusBarStyle(_ statusBarStyle: UIStatusBarStyle, animated: Bool)

@available(iOS, introduced: 3.2, deprecated: 9.0, message: "Use -[UIViewController prefersStatusBarHidden]")
open func setStatusBarHidden(_ hidden: Bool, with animation: UIStatusBarAnimation)

我们可以直接通过调用:UIApplication.shared.setStatusBarStyle(.lightContent, animated: true)来修改状态栏,非常的简单粗暴。但是在复杂逻辑中,这样全局的设置容易造成混乱和不好维护。所以在iOS7后提供了新的控制体系,并把上述方法在iOS9标记为deprecated

View controller-based status bar appearance方式(iOS7之后)

AppleiOS7中提供了新的状态栏控制方法,由VC去负责自己生命周期中的状态栏控制。使用新方法首先要在Info.plist中将View controller-based status bar appearance 设为YES

...
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
...

然后在相应的VC中重写两个控制方法:

func changeStatusBar() {
    statusBarStyle = .lightContent
    isStatusBarHidden = true
    // 调用后状态栏才会刷新
    setNeedsStatusBarAppearanceUpdate()
}

// 控制状态栏样式
override var preferredStatusBarStyle: UIStatusBarStyle {
    get {
        return statusBarStyle
    }
}

// 控制状态栏隐藏状态
public override var prefersStatusBarHidden: Bool {
    get {
        return isStatusBarHidden
    }
}

通过上述代码,我们就可以通过VC来控制当前它所在视图的状态栏的样式。但是在实际使用中并没有这样简单,因为VC可能是嵌套在UINavigationController中,或者我需要VCchildViewControllers来控制状态栏的样式等。这就涉及到控制权限问题了。

子控制器的控制权限问题

这种类型非常简单,如果你想要使用子控制器来更改状态栏的状态,重写UIViewControllerchildForStatusBarStyle方法即可:

override var childForStatusBarStyle: UIViewController? {
    let childVC = children[0]
    // 将控制权限交给childVC
    return childVC
}

override var childForStatusBarHidden: UIViewController? {
    let childVC = children[0]
    // 将控制权限交给childVC
    return childVC
}

这样状态栏的控制权限就交给了子控制器childVC

UINavigationController的控制权限问题

这种情况涉及到导航栏,会复杂一些,分为状态栏是否可见两种场景,它的规则如下:

  1. NavigationBar不可见时,由它栈顶的topViewController控制状态栏。
  2. NavigationBar可见时,由它自身控制状态栏。这种情况下,由它的barStyle属性决定状态栏的样式:
    • barStyle = .default 时,NavigationBar显示为白色,此时StatusBar显示为黑色
    • barStyle = .black 时,NavigationBar 显示为白色,此时StatusBar显示为白色

如果你既不想隐藏状态栏,又想让topViewController来控制状态栏,那么可以像上面子控制器的控制权限问题的方法一样,重写childForStatusBarStylechildForStatusBarHidden即可。

Modal Presentation的控制权限问题

当我们presnet一个VC时,它的状态栏由谁控制,取决于它是否是全屏展示。现在VC默认的modalPresentationStyleUIModalPresentationStyle.automatic,并不是全屏展示,所以默认情况下它自身并不控制状态栏。但是当我们将modalPresentationStyle改为UIModalPresentationStyle.fullScreen后,它变成了全屏展示,就获得了状态栏的控制权。

如果既不想让presentVC全屏显示,又想让它控制状态栏,那么将它的modalPresentationCapturesStatusBarAppearance属性设置为true即可。

iOS13之前状态栏的底层实现及状态栏无法控制的场景

iOS13之前,状态栏是通过UIWindow来实现的,可以很方便的通过UIApplication拿到状态栏对应的视图。

以前还有通过状态栏获取网络状态等骚操作。

iOS13之前,我们可以通过如下代码来分析状态栏:

// 获取到状态栏
id bar = [[UIApplication sharedApplication] valueForKey:@"_statusBar"];
NSLog(@"class:%@, superClass:%@", [bar class], [bar superclass]);
NSLog(@"superview:%@", [bar superview]);
/*
Demo[28973:959757] class:UIStatusBar, superClass:UIStatusBar_Base
Demo[28973:959757] superView:<UIStatusBarWindow: 0x7fcbd8d0c9b0; frame = (0 0; 320 568); opaque = NO; gestureRecognizers = <NSArray: 0x600002b4def0>; layer = <UIWindowLayer: 0x6000025581c0>>
*/

从上述样例可以看到,状态栏的类名叫UIStatusBar,它继承自UIStatusBar_Base,被添加在UIStatusBarWindow(UIWindow的子类)上被显示出来。

当我们新建一个UIWindow,将它隐藏,它是不会影响到keyWindow的状态栏控制的,但是当将它设为不隐藏并且将frame的值设为UIScreen.main.bounds的时候,keyWindow的状态栏控制将会失效。有趣的是,将frame改小或改大后,都不会导致失效,只有值为UIScreen.main.bounds的时候才会导致异常,感觉像是iOS的源码里面有代码逻辑来判断frame是否与UIScreen.main.bounds相等一样。

windowLevel不影响上述结论

iOS13之后状态栏的底层实现及状态栏无法控制的场景

iOS13之后,状态栏的底层实现完全发生了变化,从UIApplication_statusBar只会返回nil,对UIStatusBarUIStatusBar_BaseUIStatusBarWindow等类的初始化方法hook,发现都没有被调用。这说明iOS13之后,状态栏的视图实现逻辑被完全重构了。

iOS13新增了一个UIStatusBarManager类(之前也有,但是是私有的),系统源码如下:

@available(iOS 13.0, *)
open class UIStatusBarManager : NSObject {
    open var statusBarStyle: UIStatusBarStyle { get }

    open var isStatusBarHidden: Bool { get }

    open var statusBarFrame: CGRect { get } // returns CGRectZero if the status bar is hidden
}

extension UIWindowScene {
    @available(iOS 13.0, *)
    open var statusBarManager: UIStatusBarManager? { get }
}

通过runtime打印出它的成员变量和方法列表分别如下:

// 成员变量列表
"_overriddingStatusBarHidden" = "@";
"_scene" = UIScene;
"_statusBarFrameIgnoringVisibility" = "GRect={CGPoint=dd}{CGSize=dd}";
debugDescription = NSString;
debugMenuHandler = "@";
defaultStatusBarHeight = "@";
description = NSString;
hash = "@";
inStatusBarFadeAnimation = "@";
localStatusBars = NSMutableSet;
statusBarAlpha = "@";
statusBarFrame = "GRect={CGPoint=dd}{CGSize=dd}";
statusBarHeight = "@";
statusBarHidden = "@";
statusBarPartStyles = NSDictionary;
statusBarStyle = "@";
superclass = "@";
windowScene = UIWindowScene;

// 方法列表
.cxx_destruct
_settingsDiffActionsForScene:
initWithScene:
_scene
_setScene:
windowScene
isStatusBarHidden
defaultStatusBarHeightInOrientation:
statusBarStyle
statusBarHeight
setWindowScene:
updateStatusBarAppearance
updateLocalStatusBars
statusBarHidden
statusBarAlpha
setupForSingleLocalStatusBar
updateStatusBarAppearanceWithAnimationParameters:
statusBarFrameForStatusBarHeight:
defaultStatusBarHeight
_updateStatusBarAppearanceWithClientSettings:transitionContext:animationParameters:
_updateVisibilityForWindow:targetOrientation:animationParameters:
_updateStyleForWindow:animationParameters:
_updateAlpha
_visibilityChangedWithOriginalOrientation:targetOrientation:animationParameters:
activateLocalStatusBar:
_updateLocalStatusBar:
statusBarFrame
_handleScrollToTopAtXPosition:
_adjustedLocationForXPosition:
_setOverridingStatusBarHidden:
_setOverridingStatusBarHidden:animationParameters:
statusBarFrameForStatusBarHeight:inOrientation:
_statusBarFrameIgnoringVisibility
updateStatusBarAppearanceWithClientSettings:transitionContext:
deactivateLocalStatusBar:
createLocalStatusBar
handleTapAction:
_isOverridingStatusBarHidden
localStatusBars
setLocalStatusBars:
statusBarPartStyles
isInStatusBarFadeAnimation
debugMenuHandler
setDebugMenuHandler:

通过runtime调用createLocalStatusBar创建状态栏发现状态栏的类名是:_UIStatusBarLocalView,对这个类及它相关属性进行分析,然后进行hook,发现这些类运行时都没有创建和使用,线索到这里就断了。

虽然底层实现发生了变化,但是状态栏状态变更的接口和结果依然和之前版本保持一致,上面说的导致keyWindow的状态栏控制失效的场景也依然存在。但是我在实际开发中碰到了一个更诡异的问题,在我们现在的项目中,需要适配CarPlay,所以使用了Scene,使用Scene后,所有的UIWindow必须设置它的windowScene属性,它才能正确显示在对应的scene中,由于项目中引用了DoraemonKit等第三方框架,它们内部使用UIWindow进行显示,为了让项目正常运行,同事hook了一些代码:

@implementation UIView (SceneHook)
+ (void)load {
    if (@available(iOS 13.0, *)) {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL selector = @selector(initWithFrame:);
            Method method = class_getInstanceMethod(self, selector);
            if (!method) {
                NSAssert(NO, @"Method not found for [UIView initWithFrame:]");
            }
            IMP imp = method_getImplementation(method);
            class_replaceMethod(self, selector, imp_implementationWithBlock(^(UIView *self, CGRect frame) {
                ((UIView * (*)(UIView *, SEL, CGRect))imp)(self, selector, frame);
                
                if ([self isKindOfClass:UIWindow.class]) {
                    UIWindowScene *scene = [UIApplication sharedApplication].keyWindow.windowScene;
                    [(UIWindow *)self setWindowScene:scene];
                }
                return self;
            }), method_getTypeEncoding(method));
        });
    }
}

@end

我们有接入腾讯的视频播放SDK,它在播放视频时会创建一个window单例:SuperPlayerWindow,用于实现播放器小窗功能,这个window默认是隐藏的,在iOS13之前没有问题,但是在iOS13及之后会导致keyWindowVC的状态栏控制功能失效。(注意这个window是隐藏不显示的)

吐槽下,整一个视图单例,创建后就在app生命周期中一直存在,这种设计明显是不合理的。

将这个SuperPlayerWindowframe设为UIScreen.main.bounds之外的值可以解决这个问题,将上述hook代码进行如下修改也可以解决这个问题:

@implementation UIWindow (SceneHook)
+ (void)load {
    if (@available(iOS 13.0, *)) {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL selector = @selector(initWithFrame:);
            Method method = class_getInstanceMethod(self, selector);
            if (!method) {
                NSAssert(NO, @"Method not found for [UIWindow initWithFrame:]");
            }
            IMP imp = method_getImplementation(method);
            class_replaceMethod(self, selector, imp_implementationWithBlock(^(UIWindow *self, CGRect frame) {
                ((UIWindow * (*)(UIWindow *, SEL, CGRect))imp)(self, selector, frame);
                UIWindowScene *scene = [UIApplication sharedApplication].keyWindow.windowScene;
                [(UIWindow *)self setWindowScene:scene];
                return self;
            }), method_getTypeEncoding(method));
        });
    }
}

@end

上述两段代码没有看到和状态栏有什么关联的地方,但是却实实在在的产生影响,受制于iOSUIKit是闭源的,没法通过源码查找它的产生的原因,如果你有什么思路,欢迎留言讨论~

参考资料