再谈APP换肤实现

导语:此前发表的关于APP换肤实现原理的文章——《APP动态换肤方案详解》受到了不少小伙伴的点赞与支持,但也有同学指出方案使用Objective-C语言来实现是不是已经有所过时,毕竟现在Apple开发的主流语言已经是Swift了。为此本人在基于原有换肤架构的基础下,重写了一套Swift版本的动态换肤方案—— CJSkinSwift

本文初次发表于OSCHINA 再谈APP换肤实现

CJSkinSwift 动态换肤方案主要介绍皮肤资源管理以及换肤实现两部分,其中皮肤资源管理用于说明动态换肤框架下皮肤资源的存储规则,换肤实现则介绍了换肤的底层实现以及换肤框架的使用方式。

皮肤资源管理

APP换肤的本质是界面样式在视觉层面上的变化,而构成样式布局的基本元素是:图片、字体、颜色,所以在构建换肤方案的过程中我们只需考虑以上三样资源的变换管理就可以了。当然你可能会说换肤应该还包括界面布局以及动画效果的改变,确实这是换肤中的重要一环,但它们其实是紧跟着产品走的,不同的APP会有不同的页面布局,你没法将其加入到换肤方案中作为通用功能来实现,所以不在本文的讨论范围内。

先来看一下换肤资源管理模块的总体架构图:

换肤资源管理

CJSkinSwift规定使用 CJSkin.plist(文件名固定)来配置管理换肤信息,CJSkin.plist实质上是一个xml文件,它里面用字典记录了不同皮肤包的资源信息。例如下图二所示:当前项目的CJSkin.plist文件内记录了default、skin1、skin2三个皮肤包,每个皮肤包内固定包含 Color 颜色、Image 图片、Font 字体三类皮肤元素的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"default": {
"Color": {
"颜色1": "0x036EB7",
"颜色2": "0x025893"
},
"Image": {
"图片1": "https://www.xxx/xxx.png",
"图片2": "icon.png"
},
"Font": {
"字体1": {
"Name": "Marker Felt",
"Size": "14"
}
}
}
}

CJSkin.plist

注意:CJSkin.plist中 default 皮肤包名,资源key: ColorImageFont 以及Font中的 NameSize 这些key值的名称是固定的,配置时不能写错!

颜色

不同皮肤包 Color 字典中的key相同值不同:比如default皮肤包中 导航背景色 值为0x996666,skin2皮肤包中 导航背景色 的值为0x454545。

图片

Image 的说明同理,比如default和skin2皮肤包都在CJSkin.plist中对图片 top 进行了配置说明,它们分别指向了不同的在线url;不同皮肤包的图片还可以放到各自的 .bundle 文件夹内,同时在CJSkin.plist中声明图片别名。比如skin1.bundle中包含图片 top@2x.png、top@3x.png,它在CJSkin.plist的配置为 {"Image":{"top":"top.png"}} ,也可以CJSkin.plist中不做配置,而是在获取图片的时候key直接等于 skin1.bundle文件夹中存储的图片名 top

字体

Font 的配置说明也是一样,不同皮肤包的key相同,值为包含 Name、Size 两个固定key的字典,Name为空则使用系统默认字体,Size表示了字号大小。

皮肤包更新

既然是动态换肤,那么换肤方案除了支持内置皮肤的更新之外,还应该具有在线皮肤包的动态更新。其中内置皮肤资源包的管理从前面图二可以看出,项目中必须包含 CJSkin.plist 文件以便用于所有内置皮肤包的配置说明,同时CJSkin.plist中默认包含 default 皮肤,这是不可缺失的默认皮肤包。各个皮肤包内有又分别对各自的 Color、Image、Font 进行配置说明,对于不同皮肤包下的图片资源,除了可以通过在CJSkin.plist中配置说明具体的在线url外,也可以统一放置到 XXX.bundle 文件夹内,而 XXX 则表示在CJSkin.plist中配置的皮肤包名,例如图二示例中的项目包含了 default.bundleskin1.bundleskin2.bundle 三个皮肤包的图片资源。另外 default.bundle 存储的是默认皮肤资源,你也可以不加入default.bundle而是把图片存储在 Assets.xcassets 中,但CJSkin.plist中 default 皮肤的配置说明不能缺失。

换肤方案在线皮肤包分为两种情况,一种是只更新 CJSkin.plist 对应皮肤配置说明,那么只需将新的皮肤数据按照CJSkin.plist规定的格式下发,APP再在请求数据后调用更新方法即可。

1
2
/// 更新皮肤包配置信息
public static func updateSkinPlistInfo(_ skinPlistInfo: NSDictionary, _ completion: CJSkinSwift.SkinActionCompletion?)

另一种情况除了更新皮肤配置信息外,还包括更新皮肤包下的图片资源,此时则是使用下载皮肤压缩包 .zip 的形式进行更新。

Example.zip

皮肤包压缩资源示例说明:

  1. 压缩包内必须包含 CJSkin.plist 皮肤配置说明文件, newSkin 文件夹表示新增皮肤包名称(新增皮肤包可以多个),其与CJSkin.plist处于同级文件目录下;
  2. CJSkin.plist 文件内填写newSkin皮肤的配置信息,如果有多个皮肤则全部都要对应填写;
  3. newSkin文件夹内放置该皮肤包的所有图片资源,如果图片有别名则在CJSkin.plist内配置说明;例如: {"newSkin":{"Image":{"顶部图片":"top"}}},对应的实际图片可以是 top@2x.png、top@3x.png,或者 top.jpeg
  4. 将newSkin文件夹、CJSkin.plist文件放入新建文件夹(Example),并压缩为 Example.zip便是最终的皮肤包压缩资源。

Example.zip部署到服务器后,直接调用下载更新方法便能够完成新增皮肤包的在线更新。

1
2
3
4
5
6
/// 下载皮肤包压缩资源并自动解压更新
/// - Parameters:
/// - url: 压缩包资源下载地址
/// - completion: 结果回调
/// - Returns: Void
public static func downloadSkinZip(url: String, completion: @escaping CJSkinSwift.SkinActionCompletion)

换肤实现

看完前面对换肤资源管理模块的介绍,你可能已经猜到 CJSkinSwift 其实是约定了不同皮肤资源的存储方式,然后再在读取的时候通过key来映射获取当前皮肤包下的具体资源,从而完成换肤。

换肤架构

CJSkinSwift 中的 CJSkinTool 便是承担了换肤控制中心模块中对皮肤资源进行映射读取的职责,其中提供了对颜色、图片、字体资源的快捷读取方式。

1
2
3
4
5
6
7
8
9
10
11
/// 快速获取当前皮肤包资源转换工具类
public func SkinTool(_ key: String, _ type: CJSkinSwift.CJSkinValueType) -> CJSkinSwift.CJSkinTool

/// 从当前皮肤包,快速获取颜色,可指定颜色透明度
public func SkinColor(_ key: String, _ alpha: CGFloat = 1) -> UIColor

/// 从当前皮肤包,快速获取字体,可指定字体类型(只在字体样式为系统字体的情况下有效)
public func SkinFont(_ key: String, _ fontType: CJSkinSwift.CJSkinFontType = .skinFontRegular) -> UIFont

/// 从当前皮肤包,快速获取图片
public func SkinImage(_ key: String, _ refreshSkinTarget: NSObject?) -> UIImage

此处需要对图片资源的读取 SkinImage() 另外说明一下,由于图片资源比较特殊,它可能会存在三种存储方式。

  • 一是CJSkin.plist中配置的在线url图片,此时使用 SkinImage(_ key: String, _ refreshSkinTarget: NSObject?) 获取图片,如果图片还未下载成功返回的将会是 UIImage.init() 空白图片,你可使用CJSkinTool 的 asyncGetSkinImage方法异步下载图片;也可以通过在 refreshSkin{} 中获取图片并指定refreshSkinTarget,这样当图片异步下载完成后将会自动调用refreshSkin()重刷图片。
1
2
3
imageView.refreshSkin = { (weakSelf: NSObject) in
(weakSelf as! UIImageView).image = SkinImage("top", weakSelf)
}
  • 二是开发阶段直接内置存储在 XXX.bundle 中的皮肤图片,由于图片在指定bundle中,这种情况下是无法只是指定图片名 UIImage.init(named: "name") 来获取图片的,而必须声明具体的图片路径 UIImage.init(named: "xxx.bundle/name")SkinImage() 方法已经对此进行了封装处理。

  • 三是以skin.zip的形式整体下载的皮肤压缩包资源,当 downloadSkinZip() 下载更新完成后,对应的皮肤图片资源将存储在沙盒中,此时获取图片涉及到了NSData与UIImage的转换,SkinImage() 方法也已经对此进行了封装处理。

静态换肤

静态换肤是指UI组件能够根据当前皮肤主题显示对应的样式,但当APP发生皮肤切换事件后,UI组件和所在页面不会自动刷新样式,只能重新设置或者页面重载才可更新换肤。实际开发中可以根据实际情况,对不是常驻存留的页面(一般都是次级之后的页面)使用静态换肤,使用方式也非常简单,直接获取资源赋值即可。

1
2
3
4
5
6
7
let button = UIButton.init()
//设置颜色
button.backgroundColor = SkinColor("背景色")
//设置图片
button.setImage(SkinImage("按钮", nil), for: .normal)
//设置字体
button.titleLabel?.font = SkinFont("标题")

动态换肤

与静态换肤对应的是动态换肤,动态换肤主要针对的是常驻存留页面,比如UITabBarController上的主页,它们的生命周期是跟随着APP的生命周期走的,只要APP不被重启或主动重绘那就不存在页面重刷。因此当换肤事件发生时,这些页面上的样式元素就需要具备主动监听换肤事件并自动重刷UI的能力,否则换肤后APP必须重启才能生效这将使得使用体验大打折扣。

在《APP动态换肤方案详解》关于 CJSkin 换肤方案的介绍中,由于CJSkin使用的Objective-C语言具有运行时Runtime的特性,我们可以使用Runtime + 消息转发来实现动态换肤。但是 CJSkinSwift 是Swift版本的,纯Swift并没有Runtime机制,当然Swift也可以混编Objective-C从而借助OC的运行时特性来达成目的,但总感觉这种方案太过笨重,这里换了一种更加取巧的实现思路。

NSObject 的分类中增加扩展属性 refreshSkinrefreshSkin 是一个闭包,当对refreshSkin闭包赋值的同时完成当前对象对换肤通知事件的监听,使用的时候我们在闭包代码块内进行静态换肤样式的设置。当换肤事件发生时,所有接收到换肤通知的实例对象将会自动重新执行 refreshSkin 代码块从而达到动态换肤的效果。而且由于这是 NSObject 的分类,也就意味着除了可以对UIView系列的UI组件进行换肤事件的监听外,其他任意类的实例对象都可以进行换肤设置,这大大增加了换肤框架使用的灵活性。

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
/// 设置换肤UI刷新
public typealias SkinUISetUpBlock = (_ weakSelf: NSObject)->Void
public extension NSObject {
private var addUISetUPNotification: Bool! {
get {
let addNotification = objc_getAssociatedObject(self, &SkinUISetUpAddNotificationKey) ?? false
return (addNotification as! Bool)
}
set {
objc_setAssociatedObject(self, &SkinUISetUpAddNotificationKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}

/// 设置换肤UI刷新
var refreshSkin: SkinUISetUpBlock {
get {
var setUp: SkinUISetUpBlock?
if objc_getAssociatedObject(self, &SkinUISetUpKey) != nil {
setUp = objc_getAssociatedObject(self, &SkinUISetUpKey) as? SkinUISetUpBlock
}
return setUp!
}
set {
objc_setAssociatedObject(self, &SkinUISetUpKey, newValue, .OBJC_ASSOCIATION_COPY)
if false == self.addUISetUPNotification {
// iOS9.0之后,使用 addObserver(_:selector:name:object:) 方式注册的通知,无需再在dealloc/deinit方法中主动移除通知观察者了
//详情见https://developer.apple.com/documentation/foundation/notificationcenter/1413994-removeobserver
NotificationCenter.default.addObserver(self, selector: #selector(changeSkin), name: Notification.Name(CJSkinUpdateNotification), object: nil)
self.addUISetUPNotification = true
}
changeSkin()
}
}

@objc private func changeSkin(){
weak var weakSelf = self
self.refreshSkin(weakSelf!)
}
}

下面是动态换肤的使用示例:

1
2
3
4
5
6
7
8
9
let button = UIButton.init()
button.refreshSkin = { (weakSelf: NSObject) in
let wSelf = (weakSelf as! UIButton)
wSelf.backgroundColor = SkinColor("背景色")
wSelf.titleLabel?.font = SkinFont("标题")
//设置图片的渲染模式为展示原图;并且设置afterDownloadRefreshSkinTarget=wSelf,使得在线图片“按钮”、“按钮高亮”下载完成后将回调refreshSkin()进行UI换肤
wSelf.setImage(SkinImageRenderingMode("按钮", .alwaysOriginal, wSelf), for: .normal)
wSelf.setImage(SkinImageRenderingMode("按钮高亮", .alwaysOriginal, wSelf), for: .highlighted)
}

提示:其实在《APP动态换肤方案详解》篇关于 CJSkin 的换肤方案中也已经实现了该方案,调用方式是 button.skinChangeBlock = ^(UIButton *weakSelf) {}

换肤流程

最后上一张CJSkinSwift换肤方案的流程图,更多详情请查看 CJSkinSwift

如果你有更好的想法,欢迎留言交流!

换肤流程