App本地配置持久化方案
概述
在App開發過程中,會遇到很多簡單配置項的持久化需求。比如App最近一次啟動的時間,App最後一次登陸的使用者ID,使用者首次使用功能的判斷條件。並且隨著業務的擴充套件,零碎的配置還會不斷增加。
UserDefaults
Apple提供了UserDefault框架來幫助我們儲存離散的配置,UserDefaults將以plist檔案的形式儲存在沙盒環境中。在不引入NoSql資料庫的情況下,這是首推的方案。
注意事項
為了提升讀取速度,App在啟動時會將UserDefaults Standard對應的plist載入到記憶體中,如果檔案過大就會增加App在啟動時的載入時間,同時提高一定的記憶體消耗。
所以在Standard中,我們應該存放需要在App啟動階段立即獲取的資訊,比如使用者最近登入的ID,App遠端配置快取的版本。
我們可以通過分表來縮減Standard的資料量。使用UserDefaults的suiteName模式建立不同的配置表,這樣配置項將儲存到各自的plist檔案中,這些獨立的plist不會在啟動時被自動載入。
配置管理的常見問題
-
使用硬編碼的String Key將配置儲存到UserDefaults中,通過複製貼上Key的字串來存取資料。
-
零散的使用UserDefaults,缺少中心化管理方案。比如需要儲存“開啟通知功能”的配置,Key通常會直接被放在業務相關程式碼中維護。
方案 1.0
管理UserDefaults
建立一個UserDefault的管理類,主要用途是對UserDefault框架使用的收口,統一使用策略。
```swift public class UserDefaultsManager { public static let shared = UserDefaultsManager() private init() {} public var suiteName:String? { didSet { /* 根據傳入的 suiteName的不同會產生四種情況: 傳入 nil:跟使用UserDefaults.standard效果相同; 傳入 bundle id:無效,返回 nil; 傳入 App Groups 配置中 Group ID:會操作 APP 的共享目錄中建立的以Group ID命名的 plist 檔案,方便宿主應用與擴充套件應用之間共享資料; 傳入其他值:操作的是沙箱中 Library/Preferences 目錄下以 suiteName 命名的 plist 檔案。 / userDefault = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard } } public var userDefault = UserDefaults.standard }
```
建立常量表
- 對配置項的Key進行中心化的註冊與維護
```swift struct UserDefaultsKey { static let appLanguageCode = "appLanguageCode" static let lastLaunchSaleDate = "resetLastLaunchSaleDate" static let lastSaleDate = "lastSaleDate" static let lastSaveRateDate = "lastSaveRateDate" static let lastVibrateTime = "lastVibrateTime" static let exportedImageSaveCount = "exportedImageSaveCount"
static let onceFirstLaunchDate = "onceFirstLaunchDate"
static let onceServerUserIdStr = "onceServerUserIdStr"
static let onceDidClickCanvasButton = "onceDidClickCanvasButton"
static let onceDidClickCanvasTips = "onceDidClickCanvasTips"
static let onceDidClickEditBarGuide = "onceDidClickEditBarGuide"
static let onceDidClickEditFreestyleGuide = "onceDidClickEditFreestyleGuide"
static let onceDidClickManualCutoutGuide = "onceDidClickManualCutoutGuide"
static let onceDidClickBackgroundBlurGuide = "onceDidClickBackgroundBlurGuide"
static let onceDidTapCustomStickerBubble = "onceDidTapCustomStickerBubble"
static let onceDidRequestHomeTemplatesFromAPI = "onceDidRequestHomeTemplatesFromAPI"
static let firSaveExportTemplateKey = "firSaveExportTemplateKey"
static let firSaveTemplateDateKey = "firSaveTemplateDateKey"
static let firShareExportTemplateKey = "firShareExportTemplateKey"
static let firShareTemplateDateKey = "firShareTemplateDateKey"
} ``` 2. 提供CURD API
```swift private let appConfigUserDefaults = UserDefaultsManager(suiteName: "com.pe.config").userDefaults
var exportedImageSaveCount: Int { return appConfigUserDefaults.integer(forKey: key) }
func increaseExportedImageSaveCount() { let key = UserDefaultsKey.exportedImageSaveCount var count = appConfigUserDefaults.integer(forKey: key) count += 1 appConfigUserDefaults.setValue(count, forKey: key) } ```
我們對UserDefaults資料來源進行了封裝,String Key的註冊也統一到常量檔案中。當我們要查詢或修改時,可以從配置表方便的查到String Key。
隨著業務的膨脹,配置項會越來越多,我們會需要根據業務功能的分類,重新整理出多個分表。
隨後我們會發現一些問題:
-
String Key的註冊雖然不麻煩,但Key中無法體現出Key歸屬與哪個UserDefaults。
-
CURD API的數量會膨脹的更快,需要更多的維護成本。那麼能不能將配置的管理更加面向物件,實現類似ORM的方式來管理呢?
方案2.0
根據上述的問題,來演化下方案2.0,我們來建立一個協議,用來規範UserDefaults的使用類。
它將包含CURD API的預設實現,初始化關聯UserDefaults,自動生成String Key。
```swift /// UserDefaults儲存協議,建議用String型別的列舉去實現該協議 public protocol UserDefaultPreference {
var userDefaults: UserDefaults { get }
var key: String { get }
var bool: Bool { get }
var int: Int { get }
var float: Float { get }
var double: Double { get }
var string: String? { get }
var stringValue: String { get }
var dictionary: [String: Any]? { get }
var dictionaryValue: [String: Any] { get }
var array: [Any]? { get }
var arrayValue: [Any] { get }
var stringArray: [String]? { get }
var stringArrayValue: [String] { get }
var data: Data? { get }
var dataValue: Data { get }
var object: Any? { get }
var url: URL? { get }
func codableObject<T: Decodable>(_ as:T.Type) -> T?
func save<T: Encodable>(codableObject: T) -> Bool
func save(string: String)
func save(object: Any?)
func save(int: Int)
func save(float: Float)
func save(double: Double)
func save(bool: Bool)
func save(url: URL?)
func remove()
} ```
定義完協議後,我們再新增一些預設實現,降低使用成本。
```swift // 生成預設的String Key public extension UserDefaultPreference where Self: RawRepresentable, Self.RawValue == String { var key: String { return "(type(of: self)).(rawValue)" } }
public extension UserDefaultPreference { // 預設使用 standard UserDefaults,可以在實現類中配置 var userDefaults: UserDefaults { return UserDefaultsManager.shared.userDefaults }
func codableObject<T: Decodable>(_ as:T.Type) -> T? {
return UserDefaultsManager.codableObject(`as`, key: key, userDefaults: userDefaults)
}
@discardableResult
func save<T: Encodable>(codableObject: T) -> Bool {
return UserDefaultsManager.save(codableObject: codableObject, key: key, userDefaults: userDefaults)
}
var object: Any? { return userDefaults.object(forKey: key) }
func hasKey() -> Bool { return userDefaults.object(forKey: key) != nil }
var url: URL? { return userDefaults.url(forKey: key) }
var string: String? { return userDefaults.string(forKey: key) }
var stringValue: String { return string ?? "" }
var dictionary: [String: Any]? { return userDefaults.dictionary(forKey: key) }
var dictionaryValue: [String: Any] { return dictionary ?? [String: Any]() }
var array: [Any]? { return userDefaults.array(forKey: key) }
var arrayValue: [Any] { return array ?? [Any]() }
var stringArray: [String]? { return userDefaults.stringArray(forKey: key) }
var stringArrayValue: [String] { return stringArray ?? [String]() }
var data: Data? { return userDefaults.data(forKey: key) }
var dataValue: Data { return userDefaults.data(forKey: key) ?? Data() }
var bool: Bool { return userDefaults.bool(forKey: key) }
var boolValue: Bool? {
guard hasKey() else { return nil }
return bool
}
var int: Int { return userDefaults.integer(forKey: key) }
var intValue: Int? {
guard hasKey() else { return nil }
return int
}
var float: Float { return userDefaults.float(forKey: key) }
var floatValue: Float? {
guard hasKey() else { return nil }
return float
}
var double: Double { return userDefaults.double(forKey: key) }
var doubleValue: Double? {
guard hasKey() else { return nil }
return double
}
func save(object: Any?) { userDefaults.set(object, forKey: key) }
func save(string: String) { userDefaults.set(string, forKey: key) }
func save(int: Int) { userDefaults.set(int, forKey: key) }
func save(float: Float) { userDefaults.set(float, forKey: key) }
func save(double: Double) { userDefaults.set(double, forKey: key) }
func save(bool: Bool) { userDefaults.set(bool, forKey: key) }
func save(url: URL?) { userDefaults.set(url, forKey: key) }
func remove() { userDefaults.removeObject(forKey: key) }
} ```
OK,我們來看下使用的案例
```swift // MARK: - Launch enum LaunchEventKey: String { case didShowLaunchGuideOnThisLaunch case launchGuideIsAlreadyShow } extension LaunchEventKey: UserDefaultPreference { }
func checkIfNeedLaunchGuide() -> Bool {
return !LaunchEventKey.launchGuideIsAlreadyShow.bool
}
func launchContentView() {
LaunchEventKey.launchGuideIsAlreadyShow.save(bool: true)
}
// MARK: - Language enum LanguageEventKey: String { case appLanguageCode } extension LanguageEventKey: UserDefaultPreference { }
static var appLanguageCode: String { get { let code = LanguageEventKey.appLanguageCode.string ?? "" return code } set { LanguageEventKey.appLanguageCode.save(codableObject: newValue) } }
// MARK: - Purchase enum PurchaseStatusKey: String { case iapSubscribeExpireDate } extension PurchaseStatusKey: UserDefaultPreference { }
func handle() { let expirationDate: Date = Entitlement.expirationDate PurchaseStatusKey.iapSubscribeExpireDate.save(object: expirationDate) }
func getValues() { let subscribeExpireDate = PurchaseStatusKey.iapSubscribeExpireDate.object as? Date }
// MARK: - GlobalConfig enum AppConfig: String { case globalConfig }
private let appConfigUserDefaults = UserDefaultsManager(suiteName: "com.pe.AppConfig").userDefaults
extension AppConfig: UserDefaultPreference { var userDefaults: UserDefaults { return appConfigUserDefaults } }
// 自定義型別 public class GlobalConfig: Codable { /// 配置版本號 let configVersion: Int /// 使用者初始試用次數 let userInitialTrialCount: Int /// 生成時間 如:2022-09-19T02:58:31Z let createDate: String
enum CodingKeys: String, CodingKey {
case configVersion = "version"
case userInitialTrialCount = "user_initial_trial_count"
case createDate = "create_date"
}
...
}
lazy var globalConfig: GlobalConfig = { guard let config = AppConfig.globalConfig.codableObject(GlobalConfig.self) else { return GlobalConfig() } return config }() { didSet { AppConfig.globalConfig.save(codableObject: globalConfig) } } ```
從上述案例可以看出,在配置項的註冊和維護成本相對方案1.0有了大幅度的降低,對UserDefaults的使用進行了規範性的約束,提供了更方便的CURD API,使用方式也更加符合面向物件的習慣。
同時為了滿足複雜結構體的儲存需求,我們可以擴充套件實現Codable物件的存取邏輯。
總結
本方案的目的是解決亂象叢生的UserDefaults的使用情況,分析後向兩個方向進行了優化:
- 提供中心化的配置方式,關聯UserDefaults、維護String Key。
- 提供類ORM的管理方式,減少業務的接入成本。
針對更復雜的、類快取集合的,或者有查詢需求的配置項管理,請儘快用NoSQL替換,避免資料量上升帶來的效率下降。