前言

APP内切换主题是比较常见的需要,蛮早就思考过这个问题。网上也有很多的解决方案,不过本质上其实都类似,无非是两种情况。

  • 如果需要实现类似手Q那样动态更换主题,那么主题一定是个配置文件,能够从服务器下载。
  • 如果只要求能更换成本地的几套主题,那只通过代码初始化,写死也是可以的。

说说两者的优缺点。

  • 第一种比较灵活,但是引入的配置文件肯定要求格式的通用,毕竟不太可能要求服务端针对不同的设备提供不同的配置。那么很多人或许会选择JSON或者XML之类的格式。这样就会导致可读性不那么好,也不太直观,而且也没办法在编译期就做代码检查。
  • 第二种对比第一种就更直观,没有什么能比 label.textColor = redColor 这样的代码更易读了吧。同时因为是原生开发,也可以在编译期就做到代码检查,缺点就是灵活性差了些。

所以要做的其实是选择适合自己的类型,还有如何能更简单的做到切换而已。这个轮子其实并不太适合项目,更多是作为思考。公司项目并不要求做到类似手Q那样动态切换主题,但是为了拓展性,我还是考虑通过配置文件去加载。

目的

因为写主题配置是十分麻烦的事,我想要做到的效果是只写配置文件,代码里不需要写任何判断和设置的逻辑就能做到自动根据当前主题切换样式。所以要做的事分为以下几步:

  1. 书写配置
  2. 程序读取配置
  3. 代码根据当前主题自动设置样式

同时配置文件要求可读性良好,就算别人接手了这个模块也不需要熟悉特定的配置书写语法。

实现

配置文件

因为是本地使用,那么可以不考虑通用性,为了开发简单,我们使用Plist写配置。为了可读性良好,且尽量不增加学习成本,我想做到最好写在配置里的就是类似 label.textColor = redColor 这样的赋值语句。那能不能做到这一点呢,当然可以,我们可以借助OC的KVC。KVC本身也支持keyPath这样的赋值方式,"label.textColor" 这样的字符串在KVC中会被解析成查找 label 下的 textColor 属性。所以我们在 Plist 里的 key就可以定义成 "label.textColor",而 value就是颜色。

那么问题来了,Plist 只支持字符串、数字、日期、二进制流这几种有限的格式,那怎么让它支持颜色呢?很容易联想到颜色16进制的表示方式,比如白色使用16进制表示就是 #FFFFFF,所以直接使用字符串不就好了。但是问题没这么简单。

一种比较常见的需求是在不同的主题下需要显示不同图片 (比如我们就有白天和黑夜模式,黑夜模式的图片就要求暗一些。小声bb……),颜色如果使用了 #FFFFFF 这样的方式设置,那么图片怎么办?图片可以根据图片名,但是程序需要知道这个字符串是什么才能把它转成对应的东西,所以就需要定一套简单的规则了。

字符串非常灵活,任意的组合可以得到完全不同的内容,所以对于这种情况,选择字符串类型是比较合适的。类似编译器,有一套自己的复杂的解析规则,会把字符串转换为语法树。我们不需要这么复杂,只是简单的格式就好,但是可以设计成拥有很强拓展性的模式。所以我们需要定义一些通用的接口,生成一些解析器,这会在第二步详细说明。

读取配置

这一步比较重要的是接口的设计,关乎到使用的方便性和未来的拓展性。

我们接着说第一步末尾说到的解析器。为什么要定义解析器呢?首先每一个 Plist的对象,值传过来都是个字符串,我们要根据里面字符串的格式判断它是应该解析成颜色,或者图片又或者是其它。每种情况都不一样,所以拆分成独立的解析器会更好。不过他们都用一个共同点,遵守同一个协议。协议我们这样定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
解析优先级

- JHThemeParserPriorityRequest: 最先解析
- JHThemeParserPriorityNormal: 普通
- JHThemeParserPriorityUnimportance: 最后解析
*/
typedef NS_ENUM(NSUInteger, JHThemeParserPriority) {
JHThemeParserPriorityUnimportance,
JHThemeParserPriorityNormal,
JHThemeParserPriorityRequest = 1000,
};

@class JHTheme;
@protocol JHThemeParserProtocol <NSObject>

/**
解析优先级
*/
@property (nonatomic, assign, readonly) JHThemeParserPriority priority;

/**
唯一标识
*/
@property (nonatomic, copy, readonly) NSString *identify;


/**
是否在解析之后直接返回 YES会将解析结果让之后的解析器继续解析
*/
@property (nonatomic, assign, readonly) BOOL continueParse;

/**
是否能解析这个值

@param value 值
@return 是否能解析这个值
*/
- (BOOL)canParseValue:(id)value;

/**
解析具体实现

@param value 值
@param currentTheme 当前主题
@return 解析结果
*/
- (id)converValue:(id)value currentTheme:(JHTheme *)currentTheme;

@end

重要的是这两个方法:

1
2
- (BOOL)canParseValue:(id)value;
- (id)converValue:(id)value currentTheme:(JHTheme *)currentTheme;

第一个方法判断这个解析器能否解析这个字符串。

  • 如果返回 true , 那么会接着调用converValue:(id)value currentTheme:(JHTheme *)currentTheme,让解析器自己内部进行解析。
  • 如果返回 false,那么这个解析器就无法解析这个字符串,也不会有之后的调用了。

举个简单的例子,这是颜色解析器内部的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@implementation JHThemeColorParser
- (BOOL)canParseValue:(NSString *)value {
if ([value isKindOfClass:[NSString class]] && ([value hasPrefix:@"c("] || [value hasPrefix:@"C("]) && [value hasSuffix:@")"]) {
return YES;
}
return NO;
}

- (id)converValue:(NSString *)value currentTheme:(JHTheme *)currentTheme {
//取出色值
value = [value substringWithRange:NSMakeRange(2, value.length - 3)];
NSArray <NSString *>*rgba = [value componentsSeparatedByString:@","];
//16进制
if (rgba.count == 1) {
return [UIColor colorWithHexString:value];
}
else if (rgba.count >= 3) {
CGFloat r = 0,g = 0,b = 0,a = 1;
r = rgba.firstObject.doubleValue / 255.0;
g = rgba[1].doubleValue / 255.0;
b = rgba[2].doubleValue / 255.0;
if (rgba.count >= 4) {
a = rgba[3].doubleValue;
}
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}
return value;
}

@end

首先 - (BOOL)canParseValue:(id)value 这个方法的实现是判断值是否符合规则,这里我定义成符合 c(#FFFFF) 或者 c(255,255,255) 格式的字符串就可以解析。前者是颜色的16进制表示法,后者是通过指定RGB数字的颜色表示法。而下面只是对这个解析格式的实现而已。这样做的好处是如果想定义一套比较复杂的解析规则,只要实现协议,自己写解析逻辑就好了。比如除了颜色的,我还实现了一些类似宏的解析逻辑。像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (BOOL)canParseValue:(NSString *)value {
if ([value isKindOfClass:[NSString class]] && ([value hasPrefix:@"d("] || [value hasPrefix:@"D("]) && [value hasSuffix:@")"]) {
return YES;
}
return NO;
}

- (id)converValue:(NSString *)value currentTheme:(JHTheme *)currentTheme {
NSString *tempStr = [[value substringWithRange:NSMakeRange(2, value.length - 3)] stringByTrim];
NSDictionary *mapDic = currentTheme.defineMap;
id map = mapDic[tempStr];
if (map) {
return map;
}
return value;
}

- (BOOL)continueParse {
return YES;
}

- (JHThemeParserPriority)priority {
return JHThemeParserPriorityRequest;
}

场景是APP有一个主题色,如果按照上面只是通过指定颜色去写,那就会很麻烦,而且一旦主题色更换,也要全局修改。这个宏解析器就定义了一种字符串格式像这样 d(mainColor)。配置文件有一个独立的”宏字典”,解析器取出 mainColor 然后在”宏字典”里查找 mainColor对应的 value是什么,这个 value 我只要定义成c(#FFFFF) 或者其它解析器可以解析的格式,就可以做到类似宏定义的替换效果。需要注意的是 - (BOOL)continueParse 需要返回 YES,这样解析的结果才能返回给其它的解析器继续解析。

所以可以看到,这样是非常灵活的。

自动设置样式

这一步其实相对来说比较容易了,但是也有不少值得说的地方。我这里定义了一个叫 themeManager 的主题管理类。接口这样设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
自定义操作block

@param aObj 更改的对象
@param key 键
@param value 值
@return 自定义更改的对象
*/
typedef id(^JHThemeCustomAction)(id aObj, NSString *key, id value);

@class JHThemeManager;
@protocol JHThemeManagerObserver<NSObject>
@optional
//主题改变回调
- (void)themeManager:(JHThemeManager *)themeManager didChangeTheme:(JHTheme *)theme;
@end

@interface JHThemeManager : NSObject
+ (instancetype)shareThemeManager;

@property (nonatomic, strong, readonly) JHTheme *currentTheme;

/**
注册自定义解析器

@param parserClass 解析器需要实现 JHThemeParserProtocol接口
*/
- (void)registerParser:(Class)parserClass;

/**
添加单个主题

@param theme 主题对象
*/
- (void)addTheme:(JHTheme *)theme;

/**
添加多个主题

@param themes 主题数组
*/
- (void)addThemes:(NSArray <JHTheme *>*)themes;

/**
根据主题名称更改当前主题

@param themeName 主题名称
*/
- (void)updateThemeByName:(NSString *)themeName;


/**
根据当前主题更新对象的样式

@param obj 对象
@param themeKey 主题下的Key 空则为obj的类名
@param block 自定义操作
*/
- (void)updateObjStyleWithCurrentTheme:(id)obj
themeKey:(NSString *)themeKey
block:(JHThemeCustomAction)block;


/**
根据当前主题更新对象的样式

@param obj 对象
@param themeKey 主题下的Key 空则为obj的类名
@param themeName 主题名称
@param block 自定义操作
*/
- (void)updateObjStyleWithCurrentTheme:(id)obj
themeKey:(NSString *)themeKey
themeName:(NSString *)themeName
block:(JHThemeCustomAction)block;

- (void)addObserver:(id<JHThemeManagerObserver>)observer;
- (void)removeObserver:(id<JHThemeManagerObserver>)observer;

@end

- (void)registerParser:(Class)parserClass 注册的是遵守了解析器协议的类,只有注册了才支持解析这种类型的字符串。内部实现是通过数组保存示例,解析的时候根据优先级循环调用已经注册的解析器进行解析。

然后重点是自动。因为我们的 keykeyPath的形式,所以直接通过KVC把解析结果赋值就行。

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
- (void)updateObjStyleWithCurrentTheme:(id)toObj
themeKey:(NSString *)themeKey
themeName:(NSString *)themeName
block:(JHThemeCustomAction)block {
if (toObj == nil) return;

if (themeKey.length == 0) {
themeKey = NSStringFromClass([toObj class]);
}

JHTheme *aTheme = nil;
if (themeName.length == 0) {
aTheme = self.currentTheme;
}
else {
aTheme = [self themeByName:themeName];
}
//获取当前主题所有存储的内容
NSDictionary *content = aTheme.content[themeKey];
if ([content isKindOfClass:[NSDictionary class]] == NO) return;

[content enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {

//自定义修改
if (block) {
obj = block(toObj, key, [self converValueWithObj:obj]);
if (obj == nil) return;
}

NSString *cacheKey = [themeKey stringByAppendingFormat:@".%@", key];
NSNumber *cache = [self.keyPathCache objectForKey:cacheKey];
//没有缓存过
if (cache == nil) {
NSArray <NSString *>*keyPaths = [key componentsSeparatedByString:@"."];

id lastObj = toObj;

//单个key
if (keyPaths.count == 1) {
//当前key不存在
BOOL exist = [lastObj respondsToSelector:NSSelectorFromString(keyPaths.firstObject)];
NSAssert(exist, @"%@不存在 设置主题失败", key);

if (exist == false) {
cache = @(NO);
[self.keyPathCache setObject:cache forKey:cacheKey];
return;
}
else {
cache = @(YES);
[self.keyPathCache setObject:cache forKey:cacheKey];
}
}
//keyPath
else {
BOOL flag = YES;
for (NSInteger i = 0; i < keyPaths.count - 1; ++i) {
NSString *key = keyPaths[i];
SEL selector = NSSelectorFromString(key);
if ([lastObj respondsToSelector:selector]) {
lastObj = [lastObj valueForKey:key];
}
else {
flag = NO;
break;
}
}

if (flag) {
flag = [lastObj respondsToSelector:NSSelectorFromString(keyPaths.lastObject)];
}

cache = @(flag);
[self.keyPathCache setObject:cache forKey:cacheKey];
if (flag == NO) {
NSLog(@"%@不存在 设置主题失败", key);
return;
}
}
}

//key无效
if (cache.boolValue == NO) return;

//多参数
if ([key containsString:@":"]) {
NSArray *separatedValues = [key componentsSeparatedByString:@"."];
NSString *method = separatedValues.lastObject;
SEL aSEL = NSSelectorFromString(method);
//真正发消息的对象
id sendObj = [toObj valueForKeyPath:[key stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@".%@", method] withString:@""]];

NSArray *valueArr = [obj isKindOfClass:[NSArray class]] ? obj : @[obj];
YYClassMethodInfo *info = [[YYClassMethodInfo alloc] initWithMethod:class_getInstanceMethod([sendObj class], aSEL)];
//冒号之后的参数为实际参数
NSArray <NSString *>*argumentTypeEncodings = ^{
NSInteger index = [info.argumentTypeEncodings indexOfObject:@":"];
if (index != NSNotFound) {
return (NSArray *)[info.argumentTypeEncodings subarrayWithRange:NSMakeRange(index + 1, info.argumentTypeEncodings.count - index - 1)];
}
return @[];
}();

//参数个数和key的方法不一致
if (argumentTypeEncodings.count != valueArr.count) {
return;
}

NSMethodSignature *sign = [sendObj methodSignatureForSelector:aSEL];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sign];
invocation.target = sendObj;
invocation.selector = aSEL;

for (NSInteger idx1 = 0; idx1 < argumentTypeEncodings.count; ++idx1) {
id obj1 = valueArr[idx1];
id tempObj = [self converValueWithObj:obj1];
YYEncodingType encodingType = YYEncodingGetType(argumentTypeEncodings[idx1].UTF8String);
switch (encodingType) {
case YYEncodingTypeBool:
{
BOOL aValue = [tempObj boolValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeInt8:
{
char aValue = [tempObj charValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeUInt8:
{
unsigned char aValue = [tempObj unsignedCharValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeInt16:
{
short aValue = [tempObj shortValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeUInt16:
{
unsigned short aValue = [tempObj unsignedShortValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeInt32:
{
int32_t aValue = [tempObj intValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeUInt32:
{
uint32_t aValue = [tempObj intValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeInt64:
{
long long aValue = [tempObj longLongValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeUInt64:
{
unsigned long long aValue = [tempObj unsignedLongLongValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeFloat:
{
float aValue = [tempObj floatValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeDouble:
{
double aValue = [tempObj doubleValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeLongDouble:
{
long double aValue = [tempObj doubleValue];
[invocation setArgument:&aValue atIndex:idx1 + 2];
}
break;
case YYEncodingTypeObject:
{
[invocation setArgument:&tempObj atIndex:idx1 + 2];
}
break;
default:
break;
}
}

//调用
[invocation invoke];
}
else {
[toObj setValue:[self converValueWithObj:obj] forKeyPath:key];
}
}];
}

这么长一段代码,我都不想看😑,幸好还记得怎么实现的。按照上面我们说的,应该非常简单才对,但是实际上还要处理一些其他情况。举个例子,UIButton 在设置图片、文字颜色等属性的时候需要带上 UIControlState,那就需要传两个参数。Plist可以把这种多参数保存为数组,但是KVC就没办法做到赋值了,这时候就要借助到OC的运行时了。

我们知道,OC的方法调用,本质都是发送消息,而消息的参数是不确定的,所以我们可以借助底层提供的 NSInvocation 这种通过方法签名发消息的类。如果了解过OC的消息转发机制的同学,应该见过这个类。它其实是一个调用器,设置了方法签名,再设置了参数就可以像普通消息发送了。方法名我们定义成配置的 key,多参数就和OC的方法名一样,用”:”隔开,类似这样:setImage:forStatevalue 是一个数组。实现用了YYKit的一些方法,只是写起来简单一些,内部就是调用runtime的API,这样就做到了多参数的兼容。

然而实际使用的时候,其实很麻烦。有一个比较巧妙的方式是通过分类去添加参数。比如 UIButtonsetImage:forState,我们可以添加这些分类:

1
2
3
@property (nonatomic, strong) UIImage *jh_normalImg;
@property (nonatomic, strong) UIImage *jh_hightlightedImg;
...

实现是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)setJh_normalImg:(UIImage *)jh_normalImg {
[self setImage:jh_normalImg forState:UIControlStateNormal];
}

- (UIImage *)jh_normalImg {
return [self imageForState:UIControlStateNormal];
}

- (void)setJh_hightlightedImg:(UIImage *)jh_hightlightedImg {
[self setImage:jh_hightlightedImg forState:UIControlStateHighlighted];
}

- (UIImage *)jh_hightlightedImg {
return [self imageForState:UIControlStateHighlighted];
}

通过分类指定其它的参数就可以像单个参数那样使用了。

至于自动调用,其实很简单。我们给 NSObject 添加一个分类,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@implementation NSObject (JHThemeManager)

- (void)jh_updateTheme {
[self jh_updateThemeByKey:nil];
}

- (void)jh_updateThemeByKey:(NSString *)key {
[self jh_updateThemeByKey:key block:nil];
}

- (void)jh_updateThemeByKey:(NSString *)key
block:(JHThemeCustomAction)block {
[self jh_updateThemeByKey:key theme:nil block:block];
}

- (void)jh_updateThemeByKey:(NSString *)key
theme:(NSString *)theme
block:(JHThemeCustomAction)block {
[[JHThemeManager shareThemeManager] updateObjStyleWithCurrentTheme:self themeKey:key themeName:theme block:block];
}

@end

其实也只不过是对 [[JHThemeManager shareThemeManager] updateObjStyleWithCurrentTheme:self themeKey:key themeName:theme block:block]; 的封装,这个key是配置里的 key,一般定义成类名。theme 就是主题的名字,传 nil的话就获取当前主题。然后创建在你的控制器基类、各种View的基类中合适的时机调用 - (void)jh_updateTheme 就会自动在生成时进行主题设置。

配置文件是这样的:
img

结尾

实际使用中,这个轮子并不太方便。一是因为代码都在 Plist 里,不能非常直观的看到样式的设置过程。而且要求key与类中的属性,代码一旦有修改,Plist也需要做修改。最重要的一点是 Value 字符串的书写不能出错,但这是不太容易的,因为人就是个Bug。

比较好的解决方式就是写个GUI工具,能浏览和生成配置,而不是通过手工添加。