查看原文
其他

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

平台研发姚琦 京东零售技术 2022-09-10



什么是暗黑模式?




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 暗黑模式适配过程中踩过的坑,以及整个方案的实现原理,希望对大家有所帮助。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存