Skip to content

使用 QMUITheme 实现换肤并适配 iOS 13 Dark Mode

MoLice edited this page Jul 23, 2019 · 8 revisions

背景

一、iOS 13 Dark Mode

iOS 13 系统新增了 Dark Mode,需要项目自行适配,系统提供的适配方案简述如下:

  1. UIView 层面
    1. 对于 UIColor,使用 UIColor (DynamicColors) 里提供的新 API 去初始化颜色,例如 +[UIColor colorWithDynamicProvider:],或者用 Xcode 11 在 Assets 里为每个颜色创建一个 Color Set 然后在 Appearances 里选择带有 Dark 的选项来生成一个动态颜色,代码里通过 +[UIColor colorNamed:] 来获取颜色。
    2. 对于 UIImage,使用 Xcode 11 在 Assets 里的 Appearances 选择带有 Dark 的选项来创建一个动态的图片。
    3. 对于 UIVisualEffect,使用 iOS 10 之后的 UIBlurEffectStyle(例如 UIBlurEffectStyleRegular),系统会自动切换 Light/Dark 样式。
    4. 其他内容可在 traitCollectionDidChange: 里根据 self.traitCollection.userInterfaceStyle 的值来判断当前的主题。
  2. UIViewController 层面,通过在 traitCollectionDidChange: 里根据 self.traitCollection.userInterfaceStyle 的值来判断当前的主题。

二、系统适配方案的局限性

  1. 对于 UIColor,系统有 API 生成动态颜色,但 CGColor 却没有,所以使用到 CGColor 的地方都需要重写 traitCollectionDidChange: 在里面重新设置一遍,无法保持代码风格的一致性。

  2. 在 App 回到后台的时候,系统会分别使用 Light Mode 和 Dark Mode 为 App 当前界面截两张图,从而保证你切换了主题后再唤醒 App,不会看到颜色跳变的过程,保证了体验,但由于 CALayer 带有隐式动画,在截图时 UIView 已经渲染完新样式了,CALayer 还是旧的,从而表现出明显的 UI 问题(注意下图最终唤醒 App 后,色块(CALayer)能看到从白变黑的过程,而界面上其他地方的颜色是看不到变化过程的)。

  3. 对于 UIColorUIImage 这些对象,如果你将其用于 UIView.backgroundColorUIView.tintColor 这种系统原生自带的属性,当设备主题发生切换时,UIView 会自动去刷新这些值(因为它知道有这些值需要刷新),但如果你是一个自定义 View 里的自定义属性(例如 QMUIGridView.separatorColor),系统并不知道你总共有哪些属性需要更新,所以你依然需要借助 traitCollectionDidChange: 来重新设置一遍,导致 UI 代码需要分散到多个地方,不好维护。

  4. 从开发者的角度,让一个项目兼容 iOS 13 Dark Mode,和让项目在所有 iOS 版本下都能支持 Dark Mode,这两者的工作量相差不大,都是要对已有的 UI 代码做大量修改。如果要兼容 iOS 13 Dark Mode,最大的收益肯定是同时支持所有 iOS 版本,然而系统提供的方案并不考虑 iOS 12 及以下版本的实现。

三、QMUI 提供的方案

基于以上状况,我们设计了 QMUITheme 组件,它解决的问题是:

  1. 支持全 iOS 版本的换肤,可设置多个主题。

  2. 兼容 iOS 13 Dark Mode,可将 Dark Mode 映射为 App 中的某一个主题,对业务而言只需要关心业务主题,不需要关心设备当前是否开启了 iOS 13 Dark Mode。

  3. 支持 UIColorCGColorUIImageUIVisualEffect 的动态化,在 View 初始化的时候直接使用这些动态对象即可,不需要强制写在某些 updateXxx、xxxDidChange 的方法里,以保持最优雅的编码风格。

  4. 支持 UIKit 自带的 View 组件和业务自定义 View 组件里与颜色、图片相关属性的自动刷新。

QMUITheme 使用教程

下面以 QMUI Demo 为例,讲解 QMUITheme 组件的使用。QMUI Demo 拥有5个主题,其中“Dark”主题对应 iOS 13 Dark Mode。在 iOS 13 下,每次 QMUI Demo 启动时都会根据当前设备是否开启了 Dark Mode,来强制将 QMUI Demo 的主题设置为“Default” 或 “Dark”。

一、基本概念解释

  1. QMUIThemeManager

    这是全局的主题管理器,可以注册、移除主题,切换当前主题,在 iOS 13 下负责监听系统 Dark Mode 的切换,并将其映射为已注册的某个业务主题。

  2. 主题(theme)

    对项目而言,主题可以是任意的对象类型,存在多个主题时,不同主题也可以是不相同的类型,甚至可以是一个无意义仅占位用的 NSNull。一个主题对象通常用于存储全局的颜色、图片等信息。在 QMUI Demo 里,每个主题对应一个 QMUI 配置表,也即 NSObject<QDThemeProtocol> * 类型。

    QMUIThemeManager 而言,一个主题对应一个唯一的 identifier。与主题类似,identifier 对类型也没有要求,只需要支持 NSCopying 协议即可,所以可以用 NSStringNSNumber等常见类型,怎么方便怎么来。

  3. 动态对象

    换肤的直接操作对象通常只有 UIColor(包含 CGColor)、UIImageUIVisualEffect 这三种,在以前,当你拿到一个 UIColor 对象,它对应什么实体的色值,是确定的,但在 iOS 13 或 QMUITheme 体系下,同一个 color 对象在不同的主题下可能会展示出不同的色值,于是这种对象我们称之为“动态对象”。动态对象均需要以特定的方式来创建,例如:

    // 创建一个动态颜色
    UIColor *dynamicColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
        return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor;
    }];
    
    // 创建一个动态图片
    UIImage *dynamicImage = [UIImage qmui_imageWithThemeProvider:^UIImage * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
        return [UIImage imageNamed:[identifier isEqualToString:@"Dark"] ? @"image_dark" : @"image_default"];
    }];
    
    // 创建一个动态模糊效果
    UIVisualEffect *dynamicEffect = [UIVisualEffect qmui_effectWithThemeProvider:^UIVisualEffect * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
        return [UIBlurEffect effectWithStyle:[identifier isEqualToString:@"Dark"] ? UIBlurEffectStyleDark : UIBlurEffectStyleLight];
    }];

二、使用步骤

首先,请先确认你希望以什么样的形式封装你的主题对象,以 QMUI Demo 为例,每个主题对应一个配置表,每个配置表均为一个 NSObject<QDThemeProtocol> * 类型。

然后,在尽量早的时机初始化 QMUIThemeManager,以保证其他使用颜色、图片的地方能获取到正确的值。QMUI Demo 中选择的时机是 application:didFinishLaunchingWithOptions:

// 1. 先注册主题监听,在回调里将主题持久化存储(存到数据库或者 NSUserDefaults),避免启动过程中主题发生变化时读取到错误的值
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleThemeDidChangeNotification:) name:QMUIThemeDidChangeNotification object:nil];

// 2. 然后设置用于生成主题的 block,在需要的时候 QMUIThemeManager 会通过这个 block 得到一个主题对象
QMUIThemeManager.sharedInstance.themeGenerator = ^__kindof NSObject * _Nonnull(NSString * _Nonnull identifier) {
    if ([identifier isEqualToString:@"Default"]) return QMUIConfigurationTemplate.new;
    if ([identifier isEqualToString:@"Dark"]) return QMUIConfigurationTemplateDark.new;
    return nil;
};

// 3. 再针对 iOS 13 开启自动响应系统的 Dark Mode 切换
// 如果不需要兼容 iOS 13 Dark Mode,则不需要这一段代码
if (@available(iOS 13.0, *)) {
    // 先通过这个 block 来决定当系统的 Dark Mode 发生切换时,要如何映射到业务的主题
    QMUIThemeManager.sharedInstance.identifierForTrait = ^__kindof NSObject<NSCopying> * _Nonnull(UITraitCollection * _Nonnull trait) {
        if (trait.userInterfaceStyle == UIUserInterfaceStyleDark) {
            return @"Dark";// 表示当检测到系统开启了 Dark Mode 时,将主题自动切换到 Dark
        }
        
        if ([QMUIThemeManager.sharedInstance.currentThemeIdentifier isEqual:@"Dark"]) {
            return @"Default";// 如果系统关闭了 Dark Mode,则将当前的 Dark 主题切换回 Default
        }
        
        return QMUIThemeManager.sharedInstance.currentThemeIdentifier;
    };
    
    // 然后让 QMUIThemeManager 自动响应系统的 Dark Mode 切换
    QMUIThemeManager.sharedInstance.respondsSystemStyleAutomatically = YES;
}

做完以上的初始化配置后,剩下的就是业务界面的适配了,按照上文提到的,将 color、image、effect 都换成对应的动态对象。

view.backgroundColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, __kindof NSObject * _Nullable theme) {
    return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor;
}];
layer.backgroundColor = [UIColor qmui_colorWithThemeProvider:^UIColor * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, __kindof NSObject * _Nullable theme) {
    return [identifier isEqualToString:@"Dark"] ? UIColor.blackColor : UIColor.whiteColor;
}].CGColor;
imageView.image = [UIImage qmui_imageWithThemeProvider:^UIImage * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
    return [UIImage imageNamed:[identifier isEqualToString:@"Dark"] ? @"image_dark" : @"image_default"];
}];
visualEffectView.effect = [UIVisualEffect qmui_effectWithThemeProvider:^UIVisualEffect * _Nonnull(__kindof QMUIThemeManager * _Nonnull manager, NSString * _Nullable identifier, NSObject<QDThemeProtocol> *theme) {
    return [UIBlurEffect effectWithStyle:[identifier isEqualToString:@"Dark"] ? UIBlurEffectStyleDark : UIBlurEffectStyleLight];
}];

通常来说,一个项目里的颜色、模糊效果一般都只有可枚举的固定的几个,建议将这些颜色、模糊效果缓存起来(使用 static 变量,或者用一个单例去保存,可参考 QDThemeManager),这样可以大大减少代码量,也不需要去记住创建动态对象的语法。

// 适当做一些抽取,就可以减少大量的代码
view.backgroundColor = UIColor.qd_backgroundColor;
layer.backgroundColor = UIColor.qd_separatorColor.CGColor;
visualEffectView.effect = UIVisualEffect.qd_standardBlurEffect;

到此大部分界面已经可以兼容,而对于自定义的 View 组件,它们的自定义属性需要在主题切换时被刷新,则可参照下方来注册属性给 QMUIThemeManager。

@implementation CustomView

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        // ...
        
        // 将组件里的样式定义为 property,然后通过 qmui_registerThemeColorProperties: 注册给 QMUIThemeManager,这样当主题发生变化时,会遍历整个界面的所有 view,重新设置被注册的属性
        [self qmui_registerThemeColorProperties:@[NSStringFromSelector(@selector(borderColor)),
                                                  NSStringFromSelector(@selector(contentImage)),
                                                  NSStringFromSelector(@selector(backgroundEffect))]];
    }
    return self;
}

@end

如果上述提供的功能仍未能满足业务的需求,也可直接重写 UIView/UIViewController 的 qmui_themeDidChangeByManager:identifier:theme: 方法,在内部写自己的逻辑。

至此整个 App 的主题实现和 Dark Mode 兼容工作就全部完成了。

至于主题的切换,如果 App 只是兼容 iOS 13 Dark Mode,则不需要理会这一点,如果 App 里有提供主动切换主题的操作给用户,则可参照以下代码:

QMUIThemeManager.sharedInstance.currentThemeIdentifier = @"Dark";// 切换到名为 Dark 的主题
//
QMUIThemeManager.sharedInstance.currentTheme = darkTheme;// 切换到 darkTheme 主题对象