App本地配置持久化方案

语言: CN / TW / HK

概述

在App开发过程中,会遇到很多简单配置项的持久化需求。比如App最近一次启动的时间,App最后一次登陆的用户ID,用户首次使用功能的判断条件。并且随着业务的扩展,零碎的配置还会不断增加。

UserDefaults

Apple提供了UserDefault框架来帮助我们存储离散的配置,UserDefaults将以plist文件的形式存储在沙盒环境中。在不引入NoSql数据库的情况下,这是首推的方案。

注意事项

为了提升读取速度,App在启动时会将UserDefaults Standard对应的plist加载到内存中,如果文件过大就会增加App在启动时的加载时间,同时提高一定的内存消耗。

所以在Standard中,我们应该存放需要在App启动阶段立即获取的信息,比如用户最近登录的ID,App远程配置缓存的版本。

我们可以通过分表来缩减Standard的数据量。使用UserDefaults的suiteName模式创建不同的配置表,这样配置项将存储到各自的plist文件中,这些独立的plist不会在启动时被自动加载。

配置管理的常见问题

  1. 使用硬编码的String Key将配置存储到UserDefaults中,通过复制粘贴Key的字符串来存取数据。

  2. 零散的使用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 }

```

创建常量表

  1. 对配置项的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。

随着业务的膨胀,配置项会越来越多,我们会需要根据业务功能的分类,重新整理出多个分表。

随后我们会发现一些问题:

  1. String Key的注册虽然不麻烦,但Key中无法体现出Key归属与哪个UserDefaults。

  2. 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的使用情况,分析后向两个方向进行了优化:

  1. 提供中心化的配置方式,关联UserDefaults、维护String Key。
  2. 提供类ORM的管理方式,减少业务的接入成本。

针对更复杂的、类缓存集合的,或者有查询需求的配置项管理,请尽快用NoSQL替换,避免数据量上升带来的效率下降。