写点什么

再谈 APP 换肤实现

用户头像
Geen练
关注
发布于: 刚刚

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


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

皮肤资源管理

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


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

图一


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

{  "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 中 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 再在请求数据后调用更新方法即可。


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


另一种情况除了更新皮肤配置信息外,还包括更新皮肤包下的图片资源,此时则是使用下载皮肤压缩包 .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 部署到服务器后,直接调用下载更新方法便能够完成新增皮肤包的在线更新。


/// 下载皮肤包压缩资源并自动解压更新/// - Parameters:///   - url: 压缩包资源下载地址///   - completion: 结果回调/// - Returns: Voidpublic static func downloadSkinZip(url: String, completion: @escaping CJSkinSwift.SkinActionCompletion)
复制代码

换肤实现

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



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

/// 快速获取当前皮肤包资源转换工具类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()重刷图片。

  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 组件和所在页面不会自动刷新样式,只能重新设置或者页面重载才可更新换肤。实际开发中可以根据实际情况,对不是常驻存留的页面(一般都是次级之后的页面)使用静态换肤,使用方式也非常简单,直接获取资源赋值即可。


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 组件进行换肤事件的监听外,其他任意类的实例对象都可以进行换肤设置,这大大增加了换肤框架使用的灵活性。


/// 设置换肤UI刷新public typealias SkinUISetUpBlock = (_ weakSelf: NSObject)->Voidpublic 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!)    }}
复制代码


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


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


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



发布于: 刚刚阅读数: 2
用户头像

Geen练

关注

coding, learning... 2019.09.08 加入

码农,专注技术探讨与分享 个人博客:https://lele8446.top GitHub:https://github.com/lele8446

评论

发布
暂无评论
再谈APP换肤实现