再談APP換膚實現

語言: CN / TW / HK

導語:此前發表的關於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

注意: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中包含圖片 [email protected][email protected],它在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 的形式進行更新。

Example.zip

面板包壓縮資源示例說明:

  1. 壓縮包內必須包含 CJSkin.plist 面板配置說明檔案, newSkin 資料夾表示新增面板包名稱(新增面板包可以多個),其與CJSkin.plist處於同級檔案目錄下;
  2. CJSkin.plist 檔案內填寫newSkin面板的配置資訊,如果有多個面板則全部都要對應填寫;
  3. newSkin資料夾內放置該面板包的所有圖片資源,如果圖片有別名則在CJSkin.plist內配置說明;例如: {"newSkin":{"Image":{"頂部圖片":"top"}}} ,對應的實際圖片可以是 [email protected][email protected] ,或者 top.jpeg
  4. 將newSkin資料夾、CJSkin.plist檔案放入新建資料夾(Example),並壓縮為 Example.zip便是最終的面板包壓縮資源。

Example.zip部署到伺服器後,直接呼叫下載更新方法便能夠完成新增面板包的線上更新。

/// 下載面板包壓縮資源並自動解壓更新
/// - Parameters:
///   - url: 壓縮包資源下載地址
///   - completion: 結果回撥
/// - Returns: Void
public 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)->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!)
    }
}

下面是動態換膚的使用示例:

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

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

換膚流程