京东 App适配 iOS 暗黑模式业务实践

早过忘川 提交于 2020-03-18 00:46:25

某厂面试归来,发现自己落伍了!>>>

以下文章来源于京东零售技术,作者平台研发姚琦

什么是暗黑模式?

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才会被标记为稍后执行。

如果涉及接口调用等异步场景,是否会增加接入成本呢?我们通过下面的代码示例看一下业务是如何进行适配的:

 1// 接入前
 2cell.viewA.backgroundColor = [UIColor redColor];
 3cell.viewB.image = [UIImage imageNamed:@"xxx"];
 4
 5
 6// 接入后
 7@weakify(cell)
 8[cell jdbappearance_bindUpdater:^(JDBAppearance *apperance, UIView *bindView) {
 9    @strongify(cell)
10    cell.viewA.backgroundColor = [UIColor jdbappearance_colorBR];
11    cell.viewB.image = [UIImage jdbappearance_imageNamed:@[@"light_xx", @"dark_xx"]];
12}];

因为每次调用jdbappearance_bindUpdater 时,会立刻执行一次Block,所以不论是否涉及异步场景,接入方式都是统一的,并不会带来额外的接入成本。

自定义Updater:

Block机制基本可以满足所有的适配场景,但是实际开发中,我们可能希望有一些便捷的方法,比如直接调用一个方法jd_setBackgroundColor设置UIView的背景色。

这样的需求也是可以满足的,我们来看一下如何封装这样的API:

 1@implementation UIView (CustomUpdater)
 2
 3
 4- (void)jdb_setBackgroundColor:(NSArray *)colors
 5{
 6    [self jdbappearance_bindUpdater:^(JDBAppearance * _Nonnull appearance, UIView * _Nonnull bindView) {
 7        bindView.backgroundColor = [UIColor jdbappearance_colorWithHex:colors];
 8    } updaterKey:@"jdb_setBackgroundColor"];
 9}
10
11
12@end

注意绑定Block的时候需要指定一个updaterKey,updaterKey允许一个UIView绑定多个Block。使用方式也很简单,并且不需要考虑循环引用的问题:

1[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 的逻辑:

 1@implementatiton ObserveWindow
 2
 3
 4- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
 5{
 6    if (@available(iOS 13.0, *)) {
 7        if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
 8            // 1. 修改 App 内部样式
 9            // 2. 修改其他 window 的 overrideUserInterfaceStyle
10            // 3. 通知业务更新 UI
11        }
12    }
13}
14
15
16@end

多任务界面快照

在适配过程中,我们发现一个问题:在多任务界面,会出现 App 展示的颜色和系统颜色模式刚好相反。

进一步分析后,发现 App 在进入后台时,traitCollectionDidChange 执行了2次,这两次执行过程中系统的 userInterfaceStyle 分别是 UIUserInterfaceStyleDark 和 UIUserInterfaceStyleLight。

这是为什么呢?我们查看了下traitCollectionDidChange被调用时的堆栈:

看了堆栈就明白了,系统在进入后台时会创建快照,这个快照其实就是系统多任务界面展示的快照,调用2次是为了分别对深色和浅色进行快照,当进入多任务界面时,系统会根据当前的颜色模式展示正确的快照。

为什么我们会遇到颜色模式相反的问题呢,这里要先介绍一下“跟随系统”的功能:

App 中有一个开关,用来控制是否跟随系统颜色模式。当用户首次选择切换到暗黑模式,会默认开启跟随系统,此时 App 模式会和系统模式保持一致。如果关闭“跟随系统”的开关,则不再监听系统模式的切换,以 App 内用户选择的模式为准。

当关闭“跟随系统”的开关后,App 内的颜色模式有可能和系统的不一致,当出现不一致的时候,快照就会出错,比如Dark模式截取了Light模式的图。为了避免这种错误,我们加了一个判断条件,只有“跟随系统”开启的情况下才会开启快照功能。

修改后的traitCollectionDidChange实现如下:

 1-(void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
 2{
 3    if (@available(iOS 13.0, *)) {
 4  UIApplicationState state = [UIApplication sharedApplication].applicationState;
 5        if (state == UIApplicationStateBackground) {
 6            // 系统切换到后台时,会对颜色模式取反截2张图
 7            JDBAppearanceManager *manager = [JDBAppearanceManager sharedInstance];
 8            if (manager.followSystemMode) {
 9                // 如果跟随系统,就更新UI,系统会在UI更新完成后进行快照
10            }
11        } else {
12            // 触发场景:系统控制中心切换模式、后台进入前台、Xcode调试菜单切换模式
13if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
14                // 1. 修改 App 内部样式
15                // 2. 修改其他 window 的 overrideUserInterfaceStyle
16                // 3. 通知业务更新 UI
17            }
18        }
19    }
20}

个性化定制

基础组件的定位,除了为京东 App 的暗黑模式适配提供支持,我们还希望可以给更多的 App 使用。暗黑基础组件在支持现有功能的基础上,也支持个性化定制功能或者API,接入方可以根据自己的需求灵活选择:

  • App 内部切换开关
  • 多任务快照
  • 自定义 Updater
  • 自定义颜色模式

希望大家不要重复采坑

本文详细介绍了京东 App iOS 暗黑模式适配过程中踩过的坑,以及整个方案的实现原理,希望对大家有所帮助。

欢迎点击“京东智联云”了解更多精彩内容!

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!