京东 App适配 iOS 暗黑模式业务实践
什么是暗黑模式?
iOS 13 苹果推出了暗黑模式,暗黑模式在夜间可以更好的保护视力,也可以节省 App 电量消耗。但是 Apple 提供的暗黑模式只支持 iOS 13,为了给用户带来更好的体验,我们希望 iOS 13 以下的系统也可以支持暗黑模式。另外我们还给用户提供了自主选择的权利,可以在 App 内手动关闭暗黑模式,不跟随系统主题变化。
京东 App 涉及业务模块众多,整个适配工作量巨大,为了解决上述问题,并让各模块通过统一的接口快速接入,我们开发了暗黑基础组件,提供以下能力:
支持 iOS 9 及以上系统,同时兼容 iOS 13 系统暗黑模式
支持整体切量、降级
支持跟随系统模式,也可以选择不跟随,使用 App 内部的模式
内置调试工具,帮助开发者快速调试,提升效率
支持颜色模式扩展
基础组件设计方案如下:
业务接入
业务接入时需要调用基础组件提供的jdbappearance_bindUpdater方法,传入一个Block并在其中处理UI更新的逻辑,基础组件会绑定Block和UIView,然后将UIView存储在HashTable中,在合适的时机通过遍历HashTable和执行绑定的Block来更新UI。业务组件的接入方案如下:
需要注意的是,遍历HashTable的时候并不是所有的Block都会执行,这里会判断UIView的window是否存在,如果window有值,就执行UIView绑定的Block,否则会先把这个Block标记为稍后执行,当UIView下次出现在window中时(didMoveToWindow 被调用的时候)就会执行这个Block。
另外不用担心Block会在每次 didMoveToWindow 时被调用,因为只有颜色模式变化的时候,Block才会被标记为稍后执行。
如果涉及接口调用等异步场景,是否会增加接入成本呢?我们通过下面的代码示例看一下业务是如何进行适配的:
// 接入前
cell.viewA.backgroundColor = [UIColor redColor];
cell.viewB.image = [UIImage imageNamed:@"xxx"];
// 接入后
@weakify(cell)
[cell jdbappearance_bindUpdater:^(JDBAppearance *apperance, UIView *bindView) {
@strongify(cell)
cell.viewA.backgroundColor = [UIColor jdbappearance_colorBR];
cell.viewB.image = [UIImage jdbappearance_imageNamed:@[@"light_xx", @"dark_xx"]];
}];
因为每次调用jdbappearance_bindUpdater 时,会立刻执行一次Block,所以不论是否涉及异步场景,接入方式都是统一的,并不会带来额外的接入成本。
自定义Updater:
Block机制基本可以满足所有的适配场景,但是实际开发中,我们可能希望有一些便捷的方法,比如直接调用一个方法jd_setBackgroundColor设置UIView的背景色。
这样的需求也是可以满足的,我们来看一下如何封装这样的API:
@implementation UIView (CustomUpdater)
- (void)jdb_setBackgroundColor:(NSArray *)colors
{
[self jdbappearance_bindUpdater:^(JDBAppearance * _Nonnull appearance, UIView * _Nonnull bindView) {
bindView.backgroundColor = [UIColor jdbappearance_colorWithHex:colors];
} updaterKey:@"jdb_setBackgroundColor"];
}
@end
注意绑定Block的时候需要指定一个updaterKey,updaterKey允许一个UIView绑定多个Block。使用方式也很简单,并且不需要考虑循环引用的问题:
[cell jdb_setBackgroundColor:@[@"#FFFFFF", @"#1D1B1B"]];
App 内切换暗黑模式
这个功能允许用户在 App 内手动开启或者关闭暗黑模式,但是存在一个问题:
如果系统开启了暗黑,但是 App 内关闭了,此时一些系统控件的颜色仍然是深色的(例如通过UIImagePickerController调起的系统相册),从而导致系统控件颜色和 App 颜色不一致。
在阐述解决方案之前,先来介绍一下UITraitCollection:
UITraitCollection是 iOS 8 开始新增的一个类,管理着 App 中的用户界面相关的一些系统特征,每个视图都拥有自己的UITraitCollection。
iOS 13 颜色模式相关的信息,就存储在userInterfaceStyle属性中。如果我们想给视图单独指定userInterfaceStyle,需要使用 iOS 13 新增的 API overrideUserInterfaceStyle,另外设置overrideUserInterfaceStyle是对子视图生效的。
可是这么多视图,我们应该修改谁的属性呢?下面这张图描述了视图之间的层级关系以及UITraitCollection的传递路线:
UITraitCollection是自上而下传递的,但是 UIScreen 和 UIWindowScene 并未提供 overrideUserInterfaceStyle 这个API,我们只能修改UIWindow的属性,使UIWindow及其所有子视图展示我们设置的颜色:
如果开启了暗黑,将所有window的overrideUserInterfaceStyle设置为 UIUserInterfaceStyleDark。
如果关闭了暗黑,将所有window的overrideUserInterfaceStyle设置为 UIUserInterfaceStyleLight。
如果在 overrideUserInterfaceStyle 修改后,又有新的 window 出现,这种情况要怎么处理呢?我们注册了UIWindowDidBecomeVisibleNotification通知,这个通知会在一个 UIWindow 对象变为可见的时候发出,在接收到通知后,设置这个window的overrideUserInterfaceStyle属性。
总结:通过修改window的overrideUserInterfaceStyle属性,大多数系统控件的颜色都能和App的颜色保持一致。
监听系统模式切换
为什么要提这个呢?用traitCollectionDidChange监听不就可以了吗?
因为我们发现,在修改overrideUserInterfaceStyle后,当切换系统颜色模式时,window及其子视图的traitCollectionDidChange并没有被调用。
虽然官方文档中并没有找到明确的说明,但是经过验证,只要我们将window的 overrideUserInterfaceStyle设置为UIUserInterfaceStyleDark 或 UIUserInterfaceStyleLight,window 及其子视图我们都没法监听。只有默认的UIUserInterfaceStyleUnspecified才会生效。
那怎么办呢?我们刚刚把所有window的 overrideUserInterfaceStyle都改了😂😂😂
办法总比困难多!仔细来分析一下,我们修改window的overrideUserInterfaceStyle是为了同步修改系统控件的颜色。那我们是不是可以创建一个独立的ObserveWindow,在切换模式的时候,如果是ObserveWindow就跳过,只修改其他window的overrideUserInterfaceStyle。这样就可以在ObserveWindow中实现traitCollectionDidChange方法,处理监听系统模式切换以及更新 App UI 的逻辑:
@implementatiton ObserveWindow
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
{
if (@available(iOS 13.0, *)) {
if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
// 1. 修改 App 内部样式
// 2. 修改其他 window 的 overrideUserInterfaceStyle
// 3. 通知业务更新 UI
}
}
}
@end
多任务界面快照
在适配过程中,我们发现一个问题:在多任务界面,会出现 App 展示的颜色和系统颜色模式刚好相反。
进一步分析后,发现 App 在进入后台时,traitCollectionDidChange 执行了2次,这两次执行过程中系统的 userInterfaceStyle 分别是 UIUserInterfaceStyleDark 和 UIUserInterfaceStyleLight。
这是为什么呢?我们查看了下traitCollectionDidChange被调用时的堆栈:
看了堆栈就明白了,系统在进入后台时会创建快照,这个快照其实就是系统多任务界面展示的快照,调用2次是为了分别对深色和浅色进行快照,当进入多任务界面时,系统会根据当前的颜色模式展示正确的快照。
为什么我们会遇到颜色模式相反的问题呢,这里要先介绍一下“跟随系统”的功能:
App 中有一个开关,用来控制是否跟随系统颜色模式。当用户首次选择切换到暗黑模式,会默认开启跟随系统,此时 App 模式会和系统模式保持一致。如果关闭“跟随系统”的开关,则不再监听系统模式的切换,以 App 内用户选择的模式为准。
当关闭“跟随系统”的开关后,App 内的颜色模式有可能和系统的不一致,当出现不一致的时候,快照就会出错,比如Dark模式截取了Light模式的图。为了避免这种错误,我们加了一个判断条件,只有“跟随系统”开启的情况下才会开启快照功能。
修改后的traitCollectionDidChange实现如下:
-(void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
{
if (@available(iOS 13.0, *)) {
UIApplicationState state = [UIApplication sharedApplication].applicationState;
if (state == UIApplicationStateBackground) {
// 系统切换到后台时,会对颜色模式取反截2张图
JDBAppearanceManager *manager = [JDBAppearanceManager sharedInstance];
if (manager.followSystemMode) {
// 如果跟随系统,就更新UI,系统会在UI更新完成后进行快照
}
} else {
// 触发场景:系统控制中心切换模式、后台进入前台、Xcode调试菜单切换模式
if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
// 1. 修改 App 内部样式
// 2. 修改其他 window 的 overrideUserInterfaceStyle
// 3. 通知业务更新 UI
}
}
}
}
个性化定制
基础组件的定位,除了为京东 App 的暗黑模式适配提供支持,我们还希望可以给更多的 App 使用。暗黑基础组件在支持现有功能的基础上,也支持个性化定制功能或者API,接入方可以根据自己的需求灵活选择:
App 内部切换开关
多任务快照
自定义 Updater
自定义颜色模式
希望帮助大家不重复踩坑
本文详细介绍了京东 App iOS 暗黑模式适配过程中踩过的坑,以及整个方案的实现原理,希望对大家有所帮助。