心辰·Dev

搭建 APP 内皮肤切换框架

开源方案

RNThemeManager 提供了一种优雅的方案。这种方案将主题的配色或字体配置保存在静态 Plist 中,每次切换主题时,用单例 manager 类从 Plist 配置文件中读取样式信息。然后创建了一套可更换皮肤主题的父视图控件,控件初始化时(init)在通知中心注册为观察者,监听主题切换的通知,在接到通知时,将颜色和字体更改为指定的样式。这个更换皮肤的框架非常简洁,但在成熟项目中应用,需要把已有页面中的控件都继承自那套统一的可更换皮肤的父控件,或者把自定义的 view 注册到通知中心并自己实现变化皮肤的方法,代码改动较大,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 自定义的 ViewController 或 View 遵从 RNThemeUpdateProtocol 并实现 applyTheme 方法
@interface MYViewController : UIViewController
<RNThemeUpdateProtocol>

// 在 -viewDidLoad 中注册到通知中心
[[NSNotificationCenter defaultCenter] addObserver:self action:@selector(applyTheme) withObject:nil];

// 在 -viewWillAppear (或 layout 视图的方法中)
[self applyTheme];

- (void)applyTheme {
self.view.backgroundColor = [[RNThemeManager sharedManager] colorForKey:@"backgroundColor"];
self.textField.font = [[RNThemeManager sharedManager] fontForKey:@"textFieldFont"];
self.textField.layer.cornerRadius = [RNThemeManager sharedManager].styles[@"cornerRadius"].floatValue;
}

Plan A

首先仍然采取在本地用静态 Plist 存储皮肤配色的方法,使用单例类来统一读取和管理。创建 UIViewController 和 UIView 的 category ,用 runtime 方法给它们添加存储皮肤方案信息的字典类型的属性 SkinDic。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import "UIView+Skin.h"
#import <objc/runtime.h>

static char key_Skin_Key;

@implementation UIView (Skin)

-(void)setSkinDic:(NSDictionary *)skinDic {
objc_setAssociatedObject(self, &key_Skin_Key, skinDic, OBJC_ASSOCIATION_RETAIN);
}

-(NSDictionary *)skinDic {
return objc_getAssociatedObject(self, &key_Skin_Key);
}

@end

当触发皮肤切换时,通过 TabBarController 取到所有的 ViewController,遍历所有的视图并深度遍历到它的子视图,将一套皮肤外观的改变规则应用到每个子视图。如果子视图本身设置了皮肤字典 SkinDic,就按皮肤字典的配置方式改变视图的外观。如果子视图没有设置,就从父视图继承。
去改变视图外观时,会根据不同的视图用到不同的实例方法,所以要进行判断,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-(void)shiftSkinToView:(UIView *)view
withSkinType:(BXSkinType)skinType {
if ([view respondsToSelector:@selector(textColor)]) {
// 处理 label, textview, textfield 等的 textColor
if(view.skinDic) {
// 如果有 skinDic,则用 skinDic 处理颜色
}
else {
// 如果没有 skinDic,则从父视图继承
}
} else {
// 设置背景颜色变化
}

// 递归地处理 subviews
for (UIView *subView in view.subviews) {
[self shiftSkinToView:subView
withSkinType:skinType];

}
}

这套方案思路很清晰,但是在上述方法中简单地去判断视图的 Class 类型无法对特殊页面有针对性地配置颜色。可以在 UIView 的 Category 中添加 Bool 类型的属性 isReject ,如果某个特殊的视图不应用全局皮肤颜色,就可以把 isReject 置为 1 来保留特殊颜色。那么我们在上述方法中要添加大量的处理逻辑,这个方法就会显得太过繁杂了。另外在切换皮肤主题时,视图的显示刷新方面也会遇到一些难题。

There’s always a Plan B

自然地会想到把 Plan A 中那套复杂的换肤方法shiftSkinToView:拆分到不同的UIView 的子类中。创建 UIView 的子类如 UIButton 、 UILabel 等类的扩展,在初始化实例时,运用 Method Swizzing 技术替换掉系统初始化方法,并在自定义的初始化方法中应用一套换肤规则,那么这些 UIView 的子类在初始化实例时都会自动应用换肤规则。再替换掉系统的关于控件颜色的 getter setter 方法,在 setter 方法中始终去找皮肤字典属性里对应的值并设置颜色,这样无论在写业务逻辑时设置了什么颜色都无法影响这套换肤规则生效。
具体的 Method Swizzing 过程在这里不再赘述,以 UILabel 为例,替换掉三种初始化相关的方法:initinitWithFrame:awakeFromNib,再替换掉关于 textColor 的 setter 方法:setTextColor:即可。
swizzled_label_setTextColor:的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)swizzled_label_setTextColor:(UIColor *)textColor{
if (!self.isReject) {
// 生成皮肤主题对应的 UIcolor
skinColor = ...
...
if (skinColor) {
[self swizzled_label_setTextColor:skinColor];
} else {
// 在 skinDic 中没找到相应颜色
[self swizzled_label_setTextColor:textColor];
}
} else {
// 不响应换肤操作
[self swizzled_label_setTextColor:textColor];
}
}

其他的处理大部分和 Plan A 相似,至此一套较完整的换肤方案已经出炉,之后如果再遇到什么坑再来讨论如何填。

Update: Runtime 的方案在实际应用中在复杂 UI 界面会有性能损失问题,一番搜索后发现了官方的 UIAppearance API,后面会单独写篇相关博文。