换肤背景
用户体验是衡量一款 APP 质量的重要考核点,而换肤则是提升用户体验的重要一环。换肤包括但不限于 APP 主动更换主题(比如根据春节、圣诞、元旦等节假日更换节日主题)、局部页面换肤(白天夜间模式切换、阅读页面字体颜色的调整)、APP 用户自定义皮肤的编辑等等。这些在现如今的主流 APP 上都能找到身影,甚至 iOS 系统在 iOS13 之后就已经提供了暗黑模式以用于换肤的实现。实现换肤方案需要考虑的要点主要包含以下几方面:
换肤元素
换肤最终体现在视觉层面的表现一般是:图片、字体、颜色的改变,当然也可能包括布局排版、动画特效的变动,但后两者涉及的需求场景较少,所以不在本文的讨论范围内。
皮肤资源管理
皮肤包内会包含图片、颜色色值、字体包等各种皮肤资源,它们是构成换肤的最底层要素,能够便捷有效地管理皮肤资源是实现换肤方案中的重要一环。皮肤资源管理具体包括如何在尽可能减少开发量的情况下快速更换皮肤资源打包生成不同样式的渠道包,APP 内存在多套内置皮肤资源又应该怎样管理,能否在 APP 不升级发版的情况下在线更新皮肤等等。
换肤框架的易用性
开发者引入换肤框架是否会增加额外大量的工作量,换肤框架提供的 API 接口复不复杂,换肤框架与原有项目的耦合性高不高…...这些都是设计一款换肤方案需要考虑的要点。
换肤类型
根据换肤方式的不同,主要分为两类。
本地换肤
APP 所能支持的换肤资源在 APP 打包初始化时就已确定,安装之后不能再动态下载更新,如果更新必须重新打包送审,其中最具代表性的便是 iOS13 之后系统支持的暗黑与明亮模式。当然也可以在打包时提前将多套皮肤资源内置到 APP 资源包中,后面再由接口控制皮肤切换。本地换肤的缺点是换肤不够灵活,并且包的体积过大,还依赖苹果审核。
远程换肤
换肤资源由接口下发,下发后可选择为增量更新或者是等全部图片皮肤资源下载完成后再全局换肤。至于皮肤资源的下载时机则可以根据业务的实际需要设计为用户主动点击触发,或者读取后台配置自动更新。
现有换肤方案分析
分析市面上已有换肤方案的实现,大概分为以下几类。
1、分类(Category)
在分类中增加扩展属性,并用扩展属性来设置皮肤样式,比如给 UILabel 增加扩展属性skin_textColor
,该属性可以根据当前皮肤主题读取对应的文本颜色用以显示,使用时弃用系统原有的textColor
,改用skin_textColor
来设置样式。这种方案的不足点是需要对所有的 UI 控件以及每一个控件对应的 UI 属性都进行分类重写,这样的工作量是巨大的,而且换肤方案没法覆盖那些自定义的 UI 组件,因为你无法预知自定义 UI 组件的样式是怎样的。另一方面开发者引入换肤框架后还得对所在项目代码进行大量修改,毕竟你得将系统原有的样式属性设置改为扩展属性后换肤才会生效。
2、基于 UIView 的分类实现
该方案是基于 UI 组件都是继承自UIView
的前提下实现的,在UIView
的分类中增加扩展属性skinMap
,用于记录了当前组件所要执行的所有换肤方法信息,存储时 key 为当前调用方法名,value 为皮肤资源名称;同时在分类中统一重写所有 UI 组件的样式设置方法。当发生换肤事件后由换肤管理中心发出换肤通知,所有接收到通知的 UI 组件对象都会查找执行自身已存储的换肤方法,自动触发样式刷新。
@interface UIView (Skin)
@property (nonatomic, copy) NSDictionary *skinMap;
@end
@implementation UIView (Skin)
static void *kUIViewSkinMap;
- (void)setSkinMap:(NSDictionary *)skinMap {
objc_setAssociatedObject(self, &kUIViewSkinMap, skinMap, OBJC_ASSOCIATION_COPY_NONATOMIC);
// 判断是否已注册监听换肤通知
if (!_registerSkinNotic) {
// 注册监听换肤通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(skinChanged:) name:kSkinDidChangeNotification object:nil];
}
// 触发样式设置
[self skinChanged:nil];
}
- (NSDictionary *)skinMap {
return objc_getAssociatedObject(self, &kUIViewSkinMap);
}
- (void)skinChanged:(NSDictionary *)notic {
NSDictionary *skinMap = self.skinMap;
if ([self isKindOfClass:[UILabel class]]) {
UILabel *obj = (UILabel *)self;
if (skinMap[NSStringFromSelector(@selector(setTextColor:))]) {
obj.textColor = [SkinManager colorWithKey:skinMap[NSStringFromSelector(@selector(setTextColor:))]];
}
if (skinMap[NSStringFromSelector(@selector(setBackgroundColor:))]) {
obj.backgroundColor = [SkinManager colorWithKey:skinMap[NSStringFromSelector(@selector(setBackgroundColor:))]];
}
//...依次重写处理UILabel的其他所有属性
}
if ([self isKindOfClass:[UIButton class]]) {
UIButton *obj = (UIButton *)self;
if (skinMap[NSStringFromSelector((@selector(setTitleColor:forState:))]) {
[obj setTitleColor:skinMap[NSStringFromSelector(@selector(setTitleColor:forState:))] forState:UIControlStateNormal];
}
//...依次重写处理UIButton的其他所有属性
}
//if([self isKindOfClass:[UIImageView class]]){}
//...依次重写处理其他所有UI组件
}
@end
//换肤设置示例
[UILabel new].skinMap = @{NSStringFromSelector(@selector(setTextColor:)):@"文本颜色",
NSStringFromSelector(@selector(setBackgroundColor:)):@"背景颜色"};
复制代码
这种方案和方案 1 类似同样对 UI 控件的样式设置方法进行了重写,并且它的重写是统一封装在了 UIView 分类方法的内部,虽然说理论上能够做到对系统已有 UI 控件的所有属性都重写覆盖,但其扩展性还是比较差的。当系统框架新增了样式属性,或者是由开发者开发的其他第三方 UI 控件的样式属性,想要进行换肤设置那该方案也就无能为力了。
3、转发并缓存属性设置方法
新建 NSObject 的分类并实现一个类似performSelector:withObject:
的调用方法,在该调用方法内转发原本的样式设置方法,同时注册监听换肤通知以及保存当前样式设置方法的缓存。当接收到换肤通知后,当前 UI 组件对象同样会遍历执行自身已存储的所有方法缓存,自动触发样式刷新。
@implementation NSObject (Skin)
- (void)CJSkinInvokeMethodForSelector:(SEL)aSelector withArguments:(NSArray *)params {
// 1、 判断是否已注册监听换肤通知
if (!_registerSkinNotic) {
// 注册监听换肤通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(skinChanged:) name:kSkinDidChangeNotification object:nil];
}
// 2、存储当前方法缓存
[self cacheParams:params forSelector:aSelector];
// 3、 转发原本的属性设置方法
[self performSelector:aSelector params:params];
}
- (void)performSelector:(SEL)aSelector withParams:(NSArray *)params {
// 使用NSInvocation进行方法转发
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:[self methodSignatureForSelector:aSelector]];
[inv setTarget:self];
[inv setSelector:aSelector];
[inv setArgument:¶ms atIndex:index];
[inv invoke];
}
- (void)skinChanged:(NSDictionary *)notic {
for (NSString *selName in [self.cachedMethods allKeys]){
NSArray *params = self.cachedMethods[selName];
[self performSelector:NSSelectorFromString(selName) params:params];
}
}
@end
//换肤设置示例
[[UILabel new] CJSkinInvokeMethodForSelector:@selector(setBackgroundColor:) withArguments:@[[SkinManager colorWithKey:key]]];
复制代码
这种方案应该是所有换肤方案中设计思路最好的一种,它很好的解耦了组件原本的样式设置方法与换肤框架的耦合关系,理论上该方案无需修改就能够满足所有 UI 组件的换肤设置。然而此种换肤方案在设计上还是有一些需要注意的地方:一是换肤资源的管理应该和换肤框架尽可能地解耦,因为在实际开发中,皮肤资源是免不了必须要由开发者来管理配置的,而至于换肤框架的内部实现则不必关心。二是换肤框架要能够根据当前皮肤主题自动映射显示对应的皮肤样式,而且这一步的操作应该封装在框架内部,而不是由开发者另外去判断设置。三是皮肤资源要能够支持在线增删改,这也对应了前面所说的换肤类型中的远程换肤。但是市面上依据此种思路设计的换肤方案并没有很好的解决上面所说的三个问题。
综上可以看出,以上的换肤方案都多多少少会有存在不足。也正是基于此,我重新设计实现了一套完整的动态换肤方案,下面我们就来分析一下。
动态换肤方案
皮肤资源读取
从下往上,最底层是皮肤资源管理模块,模块内可以内置皮肤包1、皮肤包2、皮肤包3...
多个皮肤资源包,每一个皮肤包内则包含颜色、图片、字体三类皮肤资源的配置信息。另外也可以在后期通过接口远程更新皮肤包,需要更新的皮肤包需要按照下图所示固定格式的方式来生成压缩包。
换肤资源使用 CJSkin.plist 文件(文件名固定)来配置管理换肤信息。如下图所示:当前项目的 CJSkin.plist 文件内记录了 default、skin1、skin2 三个皮肤包,每个皮肤包内固定包含Color
、Image
、Font
(颜色、图片、字体)三类皮肤元素的信息。
不同皮肤包 Color 字典中的 key 相同值不同:比如 default 皮肤包中 导航背景色
值为 0x996666,skin2 皮肤包中 导航背景色
的值为 0x454545。
Image 的说明同理,比如 default 和 skin2 皮肤包中都有 顶部图片
,但分别指向了不同的 url;另外不同皮肤包的图片还可以放到各自的 default.bundle、skin1.bundle 文件夹内,同时在 CJSkin.plist 中声明图片别名,比如 skin1.bundle 中包含图片 top.png,它在 CJSkin.plist 的配置为“ 顶部图片 : top.png
”。
Font 的配置说明也是一样,不同皮肤包的 key 相同,值为包含 Name、Size 两个固定 key 的字典,Name 为空则使用系统默认字体,Size 表示了字号大小。
新建皮肤资源管理工具类 CJSkinTool,通过它可以便捷获取当前皮肤包下的资源信息。
//从当前皮肤包,快速获取颜色
FOUNDATION_EXPORT UIColor* SkinColor(NSString *key);
//从当前皮肤包,快速获取图片(只能获取本地图片)
FOUNDATION_EXPORT UIImage* SkinImage(NSString *key);
//从当前皮肤包,快速获取字体
FOUNDATION_EXPORT UIFont* SkinFont(NSString *key);
/** 快速获取皮肤资源,颜色转换工具类实例 */
FOUNDATION_EXPORT CJSkinTool* SkinColorTool(NSString *key);
/** 快速获取皮肤资源,图片转换工具类实例 */
FOUNDATION_EXPORT CJSkinTool* SkinImageTool(NSString *key);
/** 快速获取皮肤资源,字体转换工具类实例 */
FOUNDATION_EXPORT CJSkinTool* SkinFontTool(NSString *key);
@interface CJSkinTool : NSObject
/**皮肤资源key */
@property (nonatomic, copy, readonly) NSString *key;
/**皮肤资源类型 0颜色,1图片,2字体 */
@property (nonatomic, assign, readonly) CJSkinValueType valueType;
/**
从当前皮肤包初始化皮肤资源的实例方法
@param key 皮肤值的key
@param type 皮肤值的类型
@return CJSkinTool
*/
+ (CJSkinTool *)skinToolWithKey:(NSString *)key type:(CJSkinValueType)type;
/**
获取皮肤资源
@return UIColor、UIImage或者UIFont
*/
- (id)skinValue;
@end
复制代码
静态换肤
有了前面皮肤资源管理工具类 CJSkinTool 的支持,到此已经完全可以支持静态换肤的实现。这里说的静态换肤是指 UI 组件能够根据当前皮肤主题显示对应的样式,但当 APP 发生皮肤切换事件后,UI 组件和所在页面不会自动刷新样式,只能重新设置或者页面重载才可更新换肤。
UIButton *button = [UIButton new];
button.backgroundColor = SkinColor(@"背景色");
[button setImage:SkinImage(@"按钮") forState:UIControlStateNormal];
button.titleLabel.font = SkinFont(@"标题");
复制代码
动态换肤
动态换肤基于前面讲到的“现有换肤方案分析”—— 转发并缓存属性设置方法的原理实现,总体的换肤流程见下图。
动态换肤框架在基于方法转发的基础上,针对前面提到的问题要点做了改进:
动态换肤方法内封装处理了根据当前皮肤主题自动读取对应皮肤资源的逻辑,还有便是对于动态转发方法的参数进行了链式分析,因为传入的参数本身可能就是调用函数或者是 NSArray 类型的参数集合。例如将前面静态换肤中 UIButton 的样式设置改为动态换肤则是这样:
UIButton *button = [UIButton new];
[button CJSkinInvokeMethodForSelector:@selector(setBackgroundColor:) withArguments:@[SkinColorTool(@"背景色")]];
[button CJSkinInvokeMethodForSelector:@selector(setImage:forState:) withArguments:@[SkinImageTool(@"按钮"),@(UIControlStateNormal)]];
[button.titleLabel CJSkinInvokeMethodForSelector:@selector(setFont:) withArguments:@[SkinFontTool(@"标题")]];
复制代码
可能你已经注意到了,动态换肤设置颜色的参数 **SkinColorTool(@"背景色")
**其实是一个CJSkinTool
对象,这是因为CJSkinInvokeMethodForSelector:withArguments:
动态转发方法内对CJSkinTool
类型的参数做了特殊处理,首先根据CJSkinTool
的 key 读取生成当前皮肤主题下对应的UIColor
色值,然后才继续转发样式设置方法,图片字体的处理逻辑同理。
支持在线图片样式的设置,换肤框架当判断到需要设置的是在线图片时会自动下载,并在下载完成后才进行样式更新。假设有一张 key 为 按钮 的图片,皮肤包1
中声明的是本地图片,皮肤包2
中的声明是网络图片,那么切换皮肤 1 会读取本地图片即时刷新,皮肤 2 则是等图片下载完成后异步刷新。
设置动态换肤,在调用原本的样式设置方法之前会存储该换肤方法的上下文信息,而且存储key
是方法 SEL
,存储值是当前参数,调用同一个换肤方法则会更新存储最新的方法参数,因为对于样式的设置,我们只需关心最后一次的设置就可以了。理论上这种设计是没问题的,但却忽略了一个问题,某些 UI 组件样式方法的重复调用并不是覆盖设置,而是设置不同状态下的样式,比如 UIButton:
UIButton *button = [UIButton new];
//设置点击图片
//[button setImage:image forState:UIControlStateNormal];
[button CJSkinInvokeMethodForSelector:@selector(setImage:forState:) withArguments:@[SkinImageTool(@"按钮"),@(UIControlStateNormal)]];
[button CJSkinInvokeMethodForSelector:@selector(setImage:forState:) withArguments:@[SkinImageTool(@"按钮高亮"),@(UIControlStateHighlighted)]];
复制代码
这样设置之后,当发生换肤事件后其实 UIButton 只会更新在高亮状态下的按钮图片。要解决这个问题,我的解决方案是生成存储换肤方法上下文信息的 key 时,除了记录 SEL
外另外再加上指定的参数信息,这样也就保证了虽然是同一个样式设置方法setImage:forState:
,但却能够存储不同状态下的方法上下文信息。
另一种实现方式。针对第 3 点提到的样式覆盖问题,其实还有更好的解决方案,方案灵感来源于前面讲到的“现有换肤方案分析”—— 基于 UIView 的分类实现,该方案是在 UIView 的分类中新增扩展属性,并通过扩展属性来实现换肤。那同样的我们也可以给 NSObject 的分类增加扩展属性 skinChangeBlock
, skinChangeBlock
是一个代码块,在 block 代码块内我们可以进行静态换肤样式的设置。当发生换肤,重新执行 skinChangeBlock
就可达到动态换肤的效果。而且由于这是 NSObject 的分类,也就意味着除了可以对 UIView 系列的 UI 组件进行换肤事件的监听外,其他任意类的实例对象都可以进行换肤设置,这大大增加了换肤框架使用的灵活性。
@interface NSObject (Skin)
@property (nonatomic, copy) void(^skinChangeBlock)(id weakSelf);
@end
@implementation NSObject (ZWTSkin)
static void *kSkinChangeBlockKey;
- (void)setSkinChangeBlock:(void (^)(id))skinChangeBlock {
objc_setAssociatedObject(self, &kSkinChangeBlockKey, skinChangeBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
// 判断是否已注册监听换肤通知
if (!_registerSkinNotic) {
// 注册监听换肤通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(skinChanged:) name:kSkinDidChangeNotification object:nil];
}
__weak typeof(self)wSelf = self;
skinChangeBlock(wSelf);
}
- (void (^)(id))skinChangeBlock {
return objc_getAssociatedObject(self, &kSkinChangeBlockKey);
}
- (void)skinChanged:(NSDictionary *)notic {
if (self.skinChangeBlock) {
__weak typeof(self)wSelf = self;
self.skinChangeBlock(wSelf);
}
}
@end
//换肤设置示例
UIButton *button = [UIButton new];
button.skinChangeBlock = ^(UIButton *weakSelf) {
weakSelf.backgroundColor = SkinColor(@"背景色");
[weakSelf setImage:SkinImage(@"按钮") forState:UIControlStateNormal];
[weakSelf setImage:SkinImage(@"按钮高亮") forState:UIControlStateHighlighted];
};
复制代码
写在最后
到此也就已经分析梳理完了 iOS APP 动态换肤方案的细节实现。主要关键点是将换肤框架拆分为两部分:底层的资源管理层级以及用于监听和管理换肤事件的换肤控制中心,其中换肤控制中心的实现则借用了 Object-C 所具有的运行时(Runtime)的语言特性。
更多详情请查看 CJSkin。
如果你有更好的想法,欢迎留言交流。
评论