碰到的问题
最近在适配UI的时候,发现在iOS13及以后的版本进入一个视频播放页面后,调用更改状态栏的API无效了。后面发现是由于iOS13以后对Scene的适配以及一个隐藏的UIWindow导致的,iOS13加入Scene后,系统状态栏的实现发现了很大的变化。这里对iOS状态栏进行一个系统的总结备后续参考。
如何控制状态栏
使用UIApplication控制(iOS7之前)
在iOS7之前,一般都是通过直接使用UIApplication来变更状态栏的样式和隐藏状态。
1 2 3 4 5 6
| @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之后)
Apple在iOS7中提供了新的状态栏控制方法,由VC去负责自己生命周期中的状态栏控制。使用新方法首先要在Info.plist中将View controller-based status bar appearance 设为YES。
1 2 3 4
| ... <key>UIViewControllerBasedStatusBarAppearance</key> <true/> ...
|
然后在相应的VC中重写两个控制方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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中,或者我需要VC的childViewControllers来控制状态栏的样式等。这就涉及到控制权限问题了。
子控制器的控制权限问题
这种类型非常简单,如果你想要使用子控制器来更改状态栏的状态,重写UIViewController的childForStatusBarStyle方法即可:
1 2 3 4 5 6 7 8 9 10 11
| override var childForStatusBarStyle: UIViewController? { let childVC = children[0] return childVC }
override var childForStatusBarHidden: UIViewController? { let childVC = children[0] return childVC }
|
这样状态栏的控制权限就交给了子控制器childVC。
UINavigationController的控制权限问题
这种情况涉及到导航栏,会复杂一些,分为状态栏是否可见两种场景,它的规则如下:
- 当
NavigationBar不可见时,由它栈顶的topViewController控制状态栏。
- 当
NavigationBar可见时,由它自身控制状态栏。这种情况下,由它的barStyle属性决定状态栏的样式:
- 当
barStyle = .default 时,NavigationBar显示为白色,此时StatusBar显示为黑色
- 当
barStyle = .black 时,NavigationBar 显示为白色,此时StatusBar显示为白色
如果你既不想隐藏状态栏,又想让topViewController来控制状态栏,那么可以像上面子控制器的控制权限问题的方法一样,重写childForStatusBarStyle和childForStatusBarHidden即可。
Modal Presentation的控制权限问题
当我们presnet一个VC时,它的状态栏由谁控制,取决于它是否是全屏展示。现在VC默认的modalPresentationStyle是UIModalPresentationStyle.automatic,并不是全屏展示,所以默认情况下它自身并不控制状态栏。但是当我们将modalPresentationStyle改为UIModalPresentationStyle.fullScreen后,它变成了全屏展示,就获得了状态栏的控制权。
如果既不想让present的VC全屏显示,又想让它控制状态栏,那么将它的modalPresentationCapturesStatusBarAppearance属性设置为true即可。
iOS13之前状态栏的底层实现及状态栏无法控制的场景
在iOS13之前,状态栏是通过UIWindow来实现的,可以很方便的通过UIApplication拿到状态栏对应的视图。
以前还有通过状态栏获取网络状态等骚操作。
在iOS13之前,我们可以通过如下代码来分析状态栏:
1 2 3 4 5 6 7 8
| id bar = [[UIApplication sharedApplication] valueForKey:@"_statusBar"]; NSLog(@"class:%@, superClass:%@", [bar class], [bar superclass]); NSLog(@"superview:%@", [bar superview]);
|
从上述样例可以看到,状态栏的类名叫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,对UIStatusBar,UIStatusBar_Base,UIStatusBarWindow等类的初始化方法hook,发现都没有被调用。这说明iOS13之后,状态栏的视图实现逻辑被完全重构了。
iOS13新增了一个UIStatusBarManager类(之前也有,但是是私有的),系统源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @available(iOS 13.0, *) open class UIStatusBarManager : NSObject { open var statusBarStyle: UIStatusBarStyle { get }
open var isStatusBarHidden: Bool { get }
open var statusBarFrame: CGRect { get } }
extension UIWindowScene { @available(iOS 13.0, *) open var statusBarManager: UIStatusBarManager? { get } }
|
通过runtime打印出它的成员变量和方法列表分别如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| // 成员变量列表 "_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了一些代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @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及之后会导致keyWindow中VC的状态栏控制功能失效。(注意这个window是隐藏不显示的)
吐槽下,整一个视图单例,创建后就在app生命周期中一直存在,这种设计明显是不合理的。
将这个SuperPlayerWindow的frame设为UIScreen.main.bounds之外的值可以解决这个问题,将上述hook代码进行如下修改也可以解决这个问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @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
|
上述两段代码没有看到和状态栏有什么关联的地方,但是却实实在在的产生影响,受制于iOS的UIKit是闭源的,没法通过源码查找它的产生的原因,如果你有什么思路,欢迎留言讨论~
参考资料