iOS APP换肤功能的实现

在做APP开发的时候,会遇到更换主题的需求,现在的APP里也是有着不少这样做的,其中主题样式最多的应该要数QQ吧,可以在主题商店里下载各种喜欢的主题。

从他们这样主题的方式来看,不难看出,必定是通过文件管理的方式来做到的,因为在使用过程中从服务器下载下来的主题样式可不能直接的都写在APP里啊。

在这之前呢,我也没有做过主题切换相关的功能,所以在拿到这个需求的时候,参考QQ的使用考虑到了以下几点吧:

  1. 皮肤样式要满足多样性,可以动态的更新和切换
  2. 封装好后对于原有的代码不能过于的耦合
  3. 换肤要考虑到图片和颜色
  4. 不能过于复杂,需要减少学习接入的时间

先来在理想的情况下想这样一个问题:

现在各种APP、网站等平台上都有用户系统,但是你并不一定能够记住你所有平台账号上自己填写的个人信息。值得可喜的事是基本上各个平台上都有填写手机号这个功能,如果现在你有权限去后台查询个人信息的话,只需要在后台数据库中输入你的手机号,那么你在这个平台上的所有个人信息都能查的到了。

注意,各个平台可不止你一个用户,但是他却能很快的找到你的个人信息,这是因为他把你的手机号最为了你在这个平台的唯一标识,只要通过你的唯一标识就能在当前平台找到唯一的你的个人信息。

反应到APP中来,为什么不能给每个组件的主题属性设置一个唯一的标识呢?

有了需求和想法后就是上网查找资料,以期望能找到符合需求的好的一些框架(github上的一些框架和实现),但遗憾的是还是没有找到。

参考了网上的框架还有别人的讲解,自己写了一套实现吧,能够做到:

  1. 基本的换肤
  2. 根据颜色值添加纯色图片
  3. 能够自动下载使用网络图片
  4. 等等

还不算完善,现在里面添加了一些常用的组件的标识,其他的以后再添加吧,如果需要在自己的项目中添加,可以参考demo工程中给出的实现,都是很简单就能做到的。

先说说思路

首先,考虑到换肤,所以,对于需要换肤的颜色、图片肯定不能直接显式的展示出来,必须有一个标识符(就叫他identifier吧)来标识着它在不同主题中的身份。如这样使用:

1
self.contentView.backgroundColorIdentifier = AUUColorBackground;

在里面我就不知道颜色值是多少,但是我知道在换肤的时候只要按照这个identifier去找到匹配的颜色并设置上就成功了。

然后,考虑到可能有多套主题,在网上看的好多示例都只是白天和黑夜的切换,然后想了想QQ的主题,可以动态的从服务器上下载,然后选择不同的主题,于是想到了使用文件的方式来处理,文件如下:

上面这是放在本地的几套测试主题,当然了,其中的颜色和图片都是我随便加的(😢),测试用,将就将就就行了。

其中每个json文件保存了本套主题所有的颜色、图片的信息,其中的每一个标识符都表示着一种颜色、图片属性,当然了,要做到换肤,另一套里肯定也得有对应的标识符了,具体的在下节第6步中讲。

标识符也有了,代码中也没有显式的去使用了,那目的也差不多达到了,下面就该是实现了。

实现

为了做到对原有代码的小的改动,这里基本上都是使用的category来实现的。

对于其他视图组件的扩充,可以参考demo工程中的HMSegmentedControl+AUUHelper类的实现。

1. AUUThemeManager

它是一个单例,是主题信息的管理器,做当前主题信息的一些管理管理工作。

对于APP中的主题列表管理什么的可以自己写一个管理类,自己管理,参考demo工程中的AUUTestThemeManager类。

2. 做些初始化

重点在NSObject+AUUTheme中,NSObject是所有类的根类,所以主要的操作都在里面。里面自己注册了更换主题的通知和实现,所以就不需要在每个viewcontroller中做主题设定的实现了,其次,在里面还添加了一些辅助的方法和参数用来辅助更换主题。

其中NSObject+AUUMethodCache是封装好的一个方法实现类,可以根据给定的方法名、参数等来实现方法的调用。

3. 在设置颜色、图片的时候做缓存

在做主题更换的时候,需要对所有UIKit组件的category中需要设置颜色、图片的方法做修改,并在里面调用以下方法来进行方法的缓存:

1
2
3
4
5
// 这个方法内部其实是调用了 cacheParams:forSelector:argTransfer:方法,在里面对图片、颜色、主题属性等做了处理,如果调用 cacheParams:forSelector: 是没有处理的block
// 注意两个方法名
- (id)cacheThemeParams:(NSArray *)params forSelector:(SEL)sel;
- (id)cacheParams:(NSArray *)params forSelector:(SEL)sel argTransfer:(AUUArgumentTransfer)transfer;

params : 参数数组,因为在OC中是面向对象的思想,所以,如果参数中有整型、枚举等基本数据类型的参数需要变成对象的形式,不用担心,在后面使用的时候会自动的识别并转变回来。

selName : 正确设定的方法名,用于在主题设定的时候使用。

transfer:方法参数的转换器。

来个UIButton的示例,UIButton原本的设置背景图片的方法为setBackgroundImage:forState:,现在需要修改成下面的样子:

1
2
3
4
- (void)setBackgroundImageWithIdentifier:(NSString *)identifier forState:(UIControlState)state
{
[self cacheThemeParams:@[identifier.imageType, @(state)] forSelector:@selector(setBackgroundImage:forState:)];
}

identifier : 图片的标识符,就是将原来本来设置的图片变成对应于主题文件中的标识符。

在上面方法调用的时候,有个identifier.imageType这样的使用方式,这个在接下来就会讲到这样写的用意。

或者不想用已经添加的缓存方法,需要自己来实现实际参数的转换,可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)setBackgroundImageWithIdentifier:(NSString *)identifier forState:(UIControlState)state
{
[self registerThemeChangeNotification];
[[self cacheParams:@[identifier.imageType, @(state)]
forSelector:@selector(setBackgroundImage:forState:)
argTransfer:^id(id object, SEL sel, NSArray *params, NSUInteger argIndex, BOOL *skip) {
id arg = params[argIndex];
if ([arg isKindOfClass:[NSString class]]) {
NSString *argString = (NSString *)arg;
if ([argString.themeTransferType isEqualToString:AUUThemeImageTypeString]) {
return [UIImage imageWithIdentifier:argString];
}
}
return arg;
}] execute];
}

然后在项目中使用的时候就需要这么来调用了:

1
[self.button setBackgroundImageWithIdentifier:AUUThemeImageBackgroundIdentifier forState:UIControlStateNormal];

此时,在NSObjectcategory中就会对当前对象缓存下这个设定的方法和参数,缓存的方式为:

1
2
3
4
5
6
7
cachedMethods,当前类缓存的方法和参数列表
{
selname : { // 其中某个方法的参数列表
hash : <<[p1, p2, p3], transfer>>,
hash : <<[p1, p2], transfer>>
}
}

重点

因为设置图片、颜色时用的都是字符串identifier,所以在第5步去找有效的颜色、图片的时候不好区分,所以在这里做了一个算是无用的有用功,代码如下:

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
@interface
/**
用于缓存当前这个identifier字符串所对应的对象类型
*/
@property (retain, nonatomic) NSString *themeTransferType;
/**
只读,返回值还是自身,用于设定当前identifier字符串所对应的对象类型为颜色
*/
@property (retain, nonatomic, readonly) NSString *colorType;
/**
只读,返回值还是自身,用于设定当前identifier字符串所对应的对象类型为图片
*/
@property (retain, nonatomic, readonly) NSString *imageType;
/**
只读,返回值还是自身,用于设定当前identifier字符串所对应的对象类型为自定义的皮肤属性类型
*/
@property (retain, nonatomic, readonly) NSString *apperanceType;
@end
@implemention
const char *kThemeTransferTypeAssociateKey = (void *)@"kThemeTransferTypeAssociateKey";
- (NSString *)themeTransferType {
return objc_getAssociatedObject(self, kThemeTransferTypeAssociateKey);
}
- (void)setThemeTransferType:(NSString *)themeTransferType {
objc_setAssociatedObject(self, kThemeTransferTypeAssociateKey, themeTransferType, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSString *)colorType {
[self setThemeTransferType:AUUThemeColorTypeString];
return self;
}
- (NSString *)imageType {
[self setThemeTransferType:AUUThemeImageTypeString];
return self;
}
- (NSString *)apperanceType {
[self setThemeTransferType:AUUThemeApperanceTypeString];
return self;
}
@end

通过一个只读的属性来设置标识的类型,然后存储在一个变量中,就能让我在第5步的时候,区分出设置的是图片还是颜色还是其他的什么属性了。

4. 修改主题

在自己写的主题管理器中找到要切换的主题,然后取出主题信息的json并转换成字典,然后调用AUUThemeManager中的方法就能切换:

1
2
[[AUUThemeManager sharedManager] changeThemeWithSourcePath:themeModel.themePath
themeInfo:themeInfo];

在修改主题后,首先AUUThemeManager会发送一个通知给所有注册了此通知的组件。这样对于所有组件的子控件都能自己自动的更换颜色、图片了,所以就不需要在ViewController中去做遍历然后设置每一个组件的样式,省去了不少的事。

5. 枚举所有缓存的设定方法

收到通知以后,遍历在第三步中缓存的设定方法,然后使用正确的方法参数重新调用这些方法。

1
2
3
4
5
6
7
8
9
10
- (void)performAllCachedSelectorWithArgumentTransfer:(AUUArgumentTransfer)transfer
{
for (NSString *selName in [self.cachedMethods allKeys])
{
for (_AUUMethodsInfo *info in [self.cachedMethods[selName] allValues])
{
[self performSelector:NSSelectorFromString(selName) params:info.params argTransfer:info.argTransfer];
}
}
}

其中的一个很有用的方法就是:

1
-(void)performSelectorWithName:(NSString *)selName params:(NSArray *)params;

这个方法算是封装的NSInvocation调用的一个方法,在其中识别了方法中每个参数的类型,然后自动的做相应的转换,就能够正确的调用了。

对于各个类型的标识,可以看runtime的头文件,或者直接在网上看苹果的Opensource)。

6. 找到正确的参数

这里主要是指colorimage,其他类型的参数,使用运行时的方法都能找到。

为什么这里需要单独说呢,在第3步的时候也说了,设置图片和颜色的时候用的都是字符串去做的标识,所以在这里设置调用方法来设置正确类型的时候,就无法区分,所以对标识符也做了个category,用来在此处找到正确的类型。

对于标识符,我想到的是使用分级标识符的方式,分级的去处理,比如:

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
{
"colors": {
"color" : "FFFFFF",
"color.background" : "ffffff",
"color.background.viewcontroller": "ffffff",
"color.background.tableview": "0xffffff",
"color.text" : "363535",
"color.text.normal": "363535",
"color.text.hightlighted" : "a040ad",
"color.text.selected" : "a040ad",
"color.text.title" : "5e5b5b",
"color.text.subtitle" : "7c7878"
},
"images": {
"image": {
"type": 0,
"name": "3.jpg"
},
"image.background.navbar" : {
"type": 2,
"color": "ffffff"
},
"image.refresh.arrow" : {
"color": "a040ad",
"type": 2
},
"image.head.head1": {
"type": 0,
"name": "7.jpeg"
},
"image.head.head2": {
"type": 0,
"name": "8.jpg"
},
"image.head.head3": {
"type" : 3,
"path" : "http://diy.qqjay.com/u2/2014/0610/dd7df7e069995cf7dcdf5971592632d2.jpg"
}
},
"appearance" : {
"appearance.segment" : {
"background" : "ffffff",
"titlecolor" : "363535",
"indicatorColor" : "363535",
"selectedTitleColor" : "a040ad",
"bottomBorder" : "363535"
}
}
}

如果当前的标识符有问题或者没有,就可以找上一级的标识符。

image中的每个标识符下的内容,只有type和其对应的属性才是主要的,其他的都随意了,看个人需要,其中type的类型如下:

  • 0 - 对应name,在APP打包时放在项目里了,或者小图片,使用imageNamed读取
  • 1 - 对应name在本地,是通过下载主题的方式放到APP沙盒里,或者图片比较大,使用imageWithContentsOfFile去读取
  • 2 - 对应color,纯色图片,需要提供一个色值
  • 3 - 对应path,是网络图片,这里会使用SDWebImage去实现图片的异步加载,所以需要在项目中引入这个类库。

举个例子:

如果我设置颜色的时候,我想设置的是color.background.viewcontroller,但是我写错了,写成了color.background.viewcontroll,这样肯定找不到颜色值,会有下面的操作过程:

  1. 去掉末尾标识去找color.background这个颜色值
  2. 如果我还是粗心的没有在json配置文件中添加这个颜色值,那么还可以往上一级去找
  3. 就这样一级一级的找,如果知道找到最顶级了还没有
  4. 那么就会读取AUUThemeManager里设置的defaultColor

对于图片的操作也是类似的流程,颜色查找的实现如下:

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
+ (UIColor *)colorWithIdentifier:(NSString *)identifier {
NSDictionary *colors = [AUUThemeManager sharedManager].themeInfos[@"colors"];
if (colors) { // 颜色列表
// 从主题中找到颜色信息
NSString *colorString = colors[identifier];
return colorString ? ([UIColor colorWithHexString:colorString] ?: [self superColorWithColors:colors identifier:identifier]) : [self superColorWithColors:colors identifier:identifier];
}
return [AUUThemeManager sharedManager].defaultColor;
}
+ (UIColor *)superColorWithColors:(NSDictionary *)colors identifier:(NSString *)identifier {
// 找上一级的颜色
// color.identifier.useingidentfiier
NSString *superIdentifier = [identifier superIdentifier];
if (!superIdentifier) { // 没有上一级的颜色信息
NSString *defaultColorString = colors[@"__default__"];
if (defaultColorString) { // 找到了,就返回这个默认的颜色,没有找到的话,就出去了,返回设置的默认颜色了。
UIColor *color = [UIColor colorWithHexString:defaultColorString];
if (color) {
return color;
}
}
}
else // 有上一级的颜色信息
{
return [UIColor colorWithIdentifier:superIdentifier];
}
return [AUUThemeManager sharedManager].defaultColor;
}

7. 调用当前对象的方法设定正确的样式

方法名有了,参数也有了,那么就能使用NSInvocation来实现方法的调用了,主题的更换就OK了。

关于NSInvocation可以看这里

末尾 + 代码

代码地址

在最后呢,说一下,这个只是暂时想到的一种实现方式,能够做到主题切换的功能,自我感觉对于原有代码的耦合和改动还是比较小的,所有需要做主题切换的都只需要自己添加一个category即可。

如有问题,可以留言。

备注

本次修改时间为 2017-03-01,所对应的最后代码版本为

1
commit cf35aebaa2161e6f31cdd14dd41b4fcbd60eb645

后续完善的改动请看最新代码。。。