主题切换框架的设计
背景
现在很多App都有“换肤”功能,产品和UI的想法一天一个样,所以设计一套简单、易调用、易维护的“换肤”框架尤其重要
先查看框架的实际调用🌰
// 切换皮肤的方法调用
func change(to value: CCBThemeType) {
if value == .light {
return YFThemeManager.shared.switchTheme(CCBLightTheme(), animated: true)
} else {
return YFThemeManager.shared.switchTheme(CCBDarkBlueTheme(), animated: true)
}
}
某个支持换肤的View
class FCAlertView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupSubViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupSubViews() {
.....
//切换皮肤时, themeConfig的block会被自动触发,并通过切换后的主题的颜色值来重新渲染
themeConfig { (v, t) in
guard let view = v as? FCAlertView, let theme = t as? FCThemeColor else { return }
view.cancelBtn.setTitleColor(theme.textColorTitle, for: .normal)
view.okBtn.setTitleColor(theme.brandColor, for: .normal)
}
}
//MARK: - Property
lazy var cancelBtn: UIButton = {
let btn = UIButton()
btn.titleLabel?.font = UIFont.regular(18.0)
btn.setTitle("取消", for: .normal)
btn.addTarget(self, action: #selector(onCancelBtnClickEvent(_:)), for: .touchUpInside)
return btn
}()
lazy var okBtn: UIButton = {
let btn = UIButton()
btn.titleLabel?.font = UIFont.regular(18.0)
btn.setTitle("确定", for: .normal)
btn.addTarget(self, action: #selector(onOkBtnClickEvent(_:)), for: .touchUpInside)
return btn
}()
}
设计思路:
1.在主题切换时,所有支持主题切换的对象,触发themeConfig的Block.
2.themeConfig中block的设计.
1. 在主题切换时,触发所有的“主题对象”中的block思路;
- 使用哈希表NSHashTable来存放所有主题对象;
使用NSHashTable而不用NSSet的考虑
1.NSSet(NSMutableSet)持有其元素的强引用,同时这些元素是使用hash值及isEqual:方法来做hash检测及判断是否相等的。
2.NSHashTable是可变的,它没有不可变版本。
3.它可以持有元素的弱引用,而且在对象被销毁后能正确地将其移除。而这一点在NSSet是做不到的。
-
主题对象在显示调用themeConfig时, 把自己添加至trackedHashTable中
-
切换主题时,遍历trackedHashTable,触发所有对象中的配置block
2. block 的设计
block的设计,主要是传入参数的设计
-
在主题切换时,需要触发block,从而通过切换后的主题修改对应的主题对象,所以可以确定需要两个参数为 主题对象obj和 主题Theme
-
所有主题对象共用一套逻辑,封装成独立的Provider
根据以上思路:
1.通过协议,定义主题对象
// 主题对象
@objc
public protocol ThemeClass: class {}
// 可进行主题切换的对象
protocol Themeable: ThemeClass {
var theme: ThemeProvider { get set }
}
2.定义configClosure:
public typealias ThemeConfigClosure = (ThemeClass?, ThemeProtocol) -> ()
3. 主题功能封装:ThemeProvider
- 两个实例变量:目标对象obj + 主题配置block列表(考虑到一个对象可能会在不同的场景下有不同的主题配置,所以设计成存放多个block,而不是单个block)
- 一个实例方法:操作block列表的配置方法config
public class ThemeProvider: NSObject {
internal init(_ obj: ThemeClass) {
self.obj = obj
}
internal func _config(_ configClosure: @escaping ThemeConfigClosure) {
//以下操作
//configClosure添加至themeConfigs
// 更新YFThemeManager的主题对象哈希表
//通过obj 及 当前的主题,主动触发configClosure
}
//MARK: property
internal var themeConfigs: [ThemeConfigClosure] = []
private weak var obj: ThemeClass?
}
4.主题对象的定义:
只有实现 此Themeable的对象才是能够进行主题切换的对象;这里定义所有的NSObject均为主题对象
- 通过objc_getAssociatedObject 和 objc_setAssociatedObject为NSObject动态添加属性
- 通过 extension NSObject,添加themeConfig配置方法
extension NSObject: Themeable {
struct AssociateKey {
static var theme = "com.fc.theme.provider"
}
public var theme: ThemeProvider {
get {
if objc_getAssociatedObject(self, &AssociateKey.theme) == nil {
self.theme = ThemeProvider(self)
}
return objc_getAssociatedObject(self, &AssociateKey.theme) as! ThemeProvider
}
set(new) {
objc_setAssociatedObject(self, &AssociateKey.theme, new, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
extension NSObject {
@objc
func themeConfig(configClosure: @escaping ThemeConfigClosure) {
self.theme._config(configClosure)
}
}
5.app主题管理器:Manager,管理主题的所有操作,包括
- 主题的加载、切换、更新、移除
- 管理主题对象的NSHashTable
- 发送主题变更通知
// manager
@objcMembers
public class YFThemeManager: NSObject {
// 单例: 一个APP只会有一个管理类
public static let shared: YFThemeManager = YFThemeManager()
/// 主题变更通知
public static let themeUpdateNotification: String = "com.fc.theme.update.notification"
///加载主题
public func install(_ theme: ThemeProtocol) { ... }
//切换主题
public func switchTheme(_ theme: ThemeProtocol, animated: Bool) { ... }
/// 更新主题Bundle(删除、新增、修改)
@objc
public func update(bundle: YFThemeBundle, for theme: ThemeProtocol) { ... }
///获取主题图片
@objc
public class func themeImage(_ identifier: String, imageName: String, type: String?) -> UIImage? { ... }
///遍历trackedHashTable,以当前主题,刷新所有Themeable对象
@objc
public func refreshUI(_ animated: Bool) {
let refresh: () -> () = {
self.trackedHashTable.allObjects.forEach { (obj) in
if let themeObj = obj as? Themeable {
themeObj.theme.themeConfigs.forEach { [weak themeObj, weak self] (config) in
guard let strongSelf = self else { return }
config(themeObj, strongSelf.currentTheme)
}
} else { /**do nothing*/ }
}
}
if animated {
UIView.animate(withDuration: 0.3) {
refresh()
}
} else {
refresh()
}
}
//MARK: cache
private func storeThemeToLocal(_ theme: ThemeProtocol) {
guard !theme.themeClassName.isEmpty else {
return
}
YFStorage.shared.saveString(theme.themeClassName, key: themeCacheKey)
}
private func loadThemeFromLocal() -> ThemeProtocol? {
guard let className = YFStorage.shared.getString(themeCacheKey) else {
return nil
}
guard let themeClass = NSClassFromString(className) as? ThemeProtocol.Type else {
return nil
}
return themeClass.init()
}
//MARK: property
//当前主题
public var currentTheme: ThemeProtocol = DummyTheme()
//缓存的key
private let themeCacheKey = "com.f c.theme.manager.cache.key"
//存放主题对象的哈希表
private lazy var trackedHashTable: NSHashTable<AnyObject> = {
return NSHashTable<AnyObject>.init(options: .weakMemory)
}()
//专门开辟一个串行队列,保证增、删、改的所有操作都能全部进行
private var themeQueue: DispatchQueue = DispatchQueue(label: "com.fc.YFThemeManagerQueue")
}
数据管理
1. UI提供不同类型的颜色的对照表
不同的“皮肤”,设计会提供一套主题颜色对照表,比如 app的主题色、弹窗背景颜色、标题字体颜色、副标题背景色、 描述文案背景色、界面背景色 、actionSheet背景颜色等等, 不同的主题,对应控件颜色均不同;相同的控件,不同主题加载的图片可能也是不同的!此时设计童鞋会提供几套主题的图片及颜色集。问题是如何管理这些颜色集!?
注意到,控件与颜色是一一对应的,所以自然想到会用字典来存放。这里考虑到可读性和易维护的角度,采用Xcode的Plist文件来存放, 对应的Key代表对应的控件类型, Value对应主题配置的颜色
举个🌰:
比如紫色主题的Plist的 sourceCode:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>tabbarColor</key>
<string>#383838</string>
<key>sideMenuColor</key>
<string>#2f2f2f</string>
<key>dialogColor</key>
<string>#333333</string>
<key>indicatorColor</key>
<string>#444444</string>
<key>lineColor</key>
<string>#262626</string>
<key>backgroundColor</key>
<string>#222222</string>
<key>cardColor</key>
<string>#888888</string>
<key>navigationBarColor</key>
<string>#292929</string>
<key>textColorSubTitle</key>
<string>#888888</string>
<key>textColorTitle</key>
<string>#FFFFFF</string>
<key>brandColor</key>
<string>#CE57D0</string>
<key>cellItemColor</key>
<string>#1a1a1a</string>
</dict>
</plist>
对应主题的图片则存放在bundle文件中,相同图片不同主题,名字需要相同
2.根据UI提供的颜色对照表,通过定义一个Protocol类,对主题进行约束,从而确保每个主题的颜色集均相同并且均能成功初始化。
protocol FCThemeColor: class {
var cellItemColor: UIColor { get }
// tabbar颜色
var tabbarColor: UIColor { get }
//侧边栏的颜色
var sideMenuColor: UIColor { get }
//弹窗背景色
var dialogColor: UIColor { get }
// 指示器背景色
var indicatorColor: UIColor { get }
// 分割线颜色
var lineColor: UIColor { get }
// 公共背景颜色
var backgroundColor: UIColor { get }
// 卡片颜色
var cardColor: UIColor { get }
// 导航栏背景颜色
var navigationBarColor: UIColor { get }
// 标题颜色
var textColorTitle: UIColor { get }
// 副标题颜色
var textColorSubTitle: UIColor { get }
//品牌主题颜色
var brandColor: UIColor { get }
}
3.主题图片的存放
相同的控件,不同的主题下加载的图片可能不同,与颜色类似,也需要按照主题来存放不同的图片
Xcode自带的bundle可以很好的进行不同主题的区分,只需要根据不同的主题,创建不同的bundle,bundle中存放对应主题的图片即可(图片的命名需要相同),当获取主题图片时,仅需要通过 manager提供的方法,提供图片名称即可
4.定义一个主题的基类并且实现FCThemeColor协议
@objcMembers
class FCBaseTheme: NSObject, FCThemeColor {
class func current() -> FCThemeColor {
// 返回当前主题
}
//初始化
// 这里使用第三方库SwiftyJSON来处理数据结构
init(json: JSON) {
let cellItem = json["cellItemColor"].stringValue
let tabbar = json["tabbarColor"].stringValue
.....
cellItemColor = UIColor(from: cellItem)
tabbarColor = UIColor(from: tabbar)
......
}
//MARK: - FCThemeColor
let cellItemColor: UIColor
let tabbarColor: UIColor
.....
}
5.不同的主题,调用对应的Plist进行数据初始化,如:
//MARK: - Purple theme color
@objcMembers
class FCPurpleTheme: FCBaseTheme, ThemeProtocol {
required init() {
let buddlePath = Bundle.main.path(forResource: "PurpleColorConfig", ofType: "plist") ?? ""
let colorDict = NSDictionary(contentsOfFile: buddlePath) as? [String: Any] ?? [:]
super.init(json: JSON(colorDict))
}
}
class FCWhiteTheme: FCBaseTheme, ThemeProtocol {
required init() {
let buddlePath = Bundle.main.path(forResource: "WhiteColorConfig", ofType: "plist") ?? ""
let colorDict = NSDictionary(contentsOfFile: buddlePath) as? [String: Any] ?? [:]
super.init(json: JSON(colorDict))
}
}