一起养成写作习惯!这是我参与「掘金日新计划 · 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之后)
Apple
在iOS7
中提供了新的状态栏控制方法,由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
中,或者我需要VC
的childViewControllers
来控制状态栏的样式等。这就涉及到控制权限问题了。
子控制器的控制权限问题
这种类型非常简单,如果你想要使用子控制器来更改状态栏的状态,重写UIViewController
的childForStatusBarStyle
方法即可:
override var childForStatusBarStyle: UIViewController? {
let childVC = children[0]
// 将控制权限交给childVC
return childVC
}
override var childForStatusBarHidden: UIViewController? {
let childVC = children[0]
// 将控制权限交给childVC
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
之前,我们可以通过如下代码来分析状态栏:
// 获取到状态栏
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
,对UIStatusBar
,UIStatusBar_Base
,UIStatusBarWindow
等类的初始化方法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
及之后会导致keyWindow
中VC
的状态栏控制功能失效。(注意这个window是隐藏不显示的)
吐槽下,整一个视图单例,创建后就在app生命周期中一直存在,这种设计明显是不合理的。
将这个SuperPlayerWindow
的frame
设为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
上述两段代码没有看到和状态栏有什么关联的地方,但是却实实在在的产生影响,受制于iOS
的UIKit
是闭源的,没法通过源码查找它的产生的原因,如果你有什么思路,欢迎留言讨论~