大部分项目都需要选择一种数据持久化方案来保存用户的偏好设置,在iOS开发中一般选择UserDefaults
。下面先简单介绍下一般的实现方式,然后再介绍下我在开源项目中学到的一种新姿势。
准备
项目中除了要支持保存基本数据类型,也要支持自定义类型。为了简化描述,本文只选择了两种不同的基本数据类型和一种自定义类型。其中launchAtLogin
是Bool
型,launchCount
是Int
型,userInfo
定义如下:
final class UserInfo: NSObject, NSCoding {
var id = 0
var name = ""
convenience init(id: Int, name: String) {
self.init()
self.id = id
self.name = name
}
convenience required init?(coder aDecoder: NSCoder) {
self.init()
for child in Mirror(reflecting: self).children {
if let key = child.label {
setValue(aDecoder.decodeObject(forKey: key), forKey: key)
}
}
}
func encode(with aCoder: NSCoder) {
for child in Mirror(reflecting: self).children {
if let key = child.label {
aCoder.encode(value(forKey: key), forKey: key)
}
}
}
}
注:由于自定义类需要遵循NScoding
协议并编码成Data
格式才能通过UserDefaults
保存。以上代码通过Mirror
去遍历对象属性来实现NSCoding
协议,可参考前一篇博文:利用Swift的反射机制简化代码。当然,也可以将自定义类型转换成字典,然后通过UserDefaults
提供的读写字典的方法访问。
一般的方式
在较小的项目中由于偏好设置的数目和读写的次数不多,直接用下面这种一般的方式保存也不太会觉得哪里不妥或效率不高。
struct PreferenceKey {
static let launchAtLogin = "LaunchAtLogin"
static let launchCount = "LaunchCount"
static let userInfo = "UserInfo"
}
func demo() {
let userDefaults = UserDefaults.standard
// Register default preferences.
var userInfoData = NSKeyedArchiver.archivedData(withRootObject: UserInfo(id: 0, name: ""))
let defaultPreferences: [String: Any] = [
PreferenceKey.launchAtLogin: false,
PreferenceKey.launchCount: 0,
PreferenceKey.userInfo: userInfoData,
]
userDefaults.register(defaults: defaultPreferences)
// Test data.
var launchAtLogin = true
var launchCount = 10
var userInfo = UserInfo(id: 121, name: "Fox")
userInfoData = NSKeyedArchiver.archivedData(withRootObject: userInfo)
// Write preferences.
userDefaults.set(launchAtLogin, forKey: PreferenceKey.launchAtLogin)
userDefaults.set(launchCount, forKey: PreferenceKey.launchCount)
userDefaults.set(userInfoData, forKey: PreferenceKey.userInfo)
// Read preferences.
launchAtLogin = userDefaults.bool(forKey: PreferenceKey.launchAtLogin)
launchCount = userDefaults.integer(forKey: PreferenceKey.launchCount)
userInfoData = userDefaults.object(forKey: PreferenceKey.userInfo) as! Data
userInfo = NSKeyedUnarchiver.unarchiveObject(with: userInfoData) as! UserInfo
// Check preferences.
for (key, value) in userDefaults.dictionaryRepresentation() {
print("\(key): \(value)")
}
}
更优雅的方式
上面这种方式看着不那么优雅,每次读写相应设置也需要多敲一些代码,降低了编码效率,当偏好设置选项多起来后缺点就更明显了。另外,读取设置时需要知道该设置的类型,然后调用UserDefaults
相应的实例方法。如PreferenceKey.launchAtLogin
是Bool
型,需要launchAtLogin = userDefaults.bool(forKey: PreferenceKey.launchAtLogin)
。读取自定义类型时,还需要进行类型转换。如userInfo = userDefaults.object(forKey: PreferenceKey.userInfo) as! UserInfo
。
后来在开源编辑器项目CotEditor中看到了一种比较巧妙的实现方式,最终可以通过类似Preferences[.launchAtLogin]
的形式来读写相应的偏好设置,看着有点像访问字典的方式。实际上,这种方式的主要工作就是实现类似字典的语法。下面就来看看如何实现这种访问方式。
Preference Key
在Swift中如果想让自定义类作为Key去访问某个集合中的元素,那么必须满足两个条件:
- 用来访问集合元素的Key类型本身需要遵循
Hashable
协议。
- 集合实现了
subscript
操作符,支持通过方括号[]
访问集合元素。
Hashable
protocol Hashable : Equatable {
var hashValue: Int { get }
}
Hashable
协议中只定义了一个整型的哈希值hashValue
,在Swift中任何遵循了该协议的类型都可以用来当做Set
或Dictionary
的Key。官方文档里面提到,在Swift标准库中很多基本类型遵循了Hashable
协议,如字符串、整型、浮点型、布尔型等。对于自定义类型也只要提供一个hashValue
即可。为了保证访问字典时性能,需要保证同一个对象的哈希值相等,但不同对象的哈希值也是可以相等的。其实我们可以利用String
类型本身已经实现了Hashable
协议这点省去计算哈希值的工作,下面的RawRepresentable
协议可以帮我们达到目的。
RawRepresentable
RawRepresentable
协议一样很简洁,定义了一个初始化方法init?(rawValue:)
,和rawValue
。
正如上面提到的,Swift中的String
类型本身就已经遵循了Hashable
协议,我们可以利用这一点直接在自定义类型中引入一个字符串类型的rawValue
,并通过计算型属性hashValue
直接返回该字符串的hashValue
,即可达到遵循Hashable
协议的目的。下面定义了PreferenceKey
类型,最后它将作为key去访问集合元素。
final class PreferenceKey<T>: PreferenceKeys { }
class PreferenceKeys: RawRepresentable, Hashable {
let rawValue: String
required init!(rawValue: String) {
self.rawValue = rawValue
}
convenience init(_ key: String) {
self.init(rawValue: key)
}
var hashValue: Int {
return rawValue.hashValue
}
}
extension PreferenceKeys {
static let launchAtLogin = PreferenceKey<Bool>("LaunchAtLogin")
static let launchCount = PreferenceKey<Int>("LaunchCount")
static let userInfo = PreferenceKey<UserInfo>("UserInfo")
}
注:如果直接将静态存储属性放在PreferenceKeys
中,编译时就会报错:
Static stored properties not support in generic types.
CotEditor通过继承PreferenceKeys
,在子类PreferenceKey
中支持泛型来巧妙地避开上面的问题。
Preference Manager
为了方便管理用户的偏好设置,可采用单例模式定义一个PreferenceManager
,主要负责包括模块初始化、注册默认配置,提供访问集合元素的方法等工作。
final class PreferenceManager {
static let shared = PreferenceManager()
let defaults = UserDefaults.standard
private init() {
registerDefaultPreferences()
}
private func registerDefaultPreferences() {
// Convert dictionary of type [PreferenceKey: Any] to [String: Any].
let defaultValues: [String: Any] = defaultPreferences.reduce([:]) {
var dictionary = $0
dictionary[$1.key.rawValue] = $1.value
return dictionary
}
defaults.register(defaults: defaultValues)
}
}
let defaultPreferences: [PreferenceKeys: Any] = [
.launchAtLogin: false,
.launchCount: 0,
.userInfo: NSKeyedArchiver.archivedData(withRootObject: UserInfo(id: 0, name: "")),
]
上面的代码中registerDefaultPreferences
函数通过reduce
方法将字典类型从[PreferenceKey: Any]
转换到[String: Any]
,以便UserDefaults
注册默认设置。
Subscript
为了支持通过方括号[]
访问PreferenceManager
中的元素,还要实现subscript
方法。这里虽然显得有点繁琐,项目中计划支持的类型都需要写一遍,包括自定义类型,如UserInfo
。但也只是需要编写一次即可,后面需要新增支持的类型时再添加新方法。
extension PreferenceManager {
subscript(key: PreferenceKey<Any>) -> Any? {
get { return defaults.object(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<URL>) -> URL? {
get { return defaults.url(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<[Any]>) -> [Any]? {
get { return defaults.array(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<[String: Any]>) -> [String: Any]? {
get { return defaults.dictionary(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<String>) -> String? {
get { return defaults.string(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<[String]>) -> [String]? {
get { return defaults.stringArray(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<Data>) -> Data? {
get { return defaults.data(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<Bool>) -> Bool {
get { return defaults.bool(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<Int>) -> Int {
get { return defaults.integer(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<Float>) -> Float {
get { return defaults.float(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<Double>) -> Double {
get { return defaults.double(forKey: key.rawValue) }
set { defaults.set(newValue, forKey: key.rawValue) }
}
subscript(key: PreferenceKey<UserInfo>) -> UserInfo? {
get {
var object: UserInfo?
if let data = defaults.data(forKey: key.rawValue) {
object = NSKeyedUnarchiver.unarchiveObject(with: data) as? UserInfo
}
return object
}
set {
if let object = newValue {
let data = NSKeyedArchiver.archivedData(withRootObject: object)
defaults.set(data, forKey: key.rawValue)
}
}
}
}
到这里就差不多结束了。不过为了更方便地访问,再定义一个全局变量:
let Preferences = PreferenceManager.shared
下面是访问偏好设置的最终方式:
func demo() {
let userDefaults = UserDefaults.standard
// Test data.
var launchAtLogin = true
var launchCount = 10
var userInfo: UserInfo? = UserInfo(id: 123, name: "Fox")
// Write preference.
Preferences[.launchAtLogin] = launchAtLogin
Preferences[.launchCount] = launchCount
Preferences[.userInfo] = userInfo
// Read preference.
launchAtLogin = Preferences[.launchAtLogin]
launchCount = Preferences[.launchCount]
userInfo = Preferences[.userInfo]
// Check preferences.
for (key, value) in userDefaults.dictionaryRepresentation() {
print("\(key): \(value)")
}
}
相比开头的那种方式是不是看起来很清爽?简直和读写字典元素一毛一样。我们通过这种方式把NSKeyedArchiver/NSKeyedUnarchiver
的编解码过程和UserDefaults
的读写过程都封装起来之后,即使是自定义类型的访问也和基本类型毫无差别了,也不用去操心我们访问的设置是什么数据类型。
但不得不说这种方式目前还是有个小遗憾,那就是Xcode有时候会一脸懵逼变白板,但出现的次数也不多。这应该是因为Swift类型推断相对复杂,导致Xcode有点吃不消。大不了通过添加类型,放弃这部分类型推断,从而减小Xcode的负担来解决,使用Preferences[PreferenceKey.launchAtLogin]
来访问。不过随着Swift和Xcode的不断优化,相信这种情况会慢慢改善。
后记
读源码是学习新招式的一个非常好的途径。开始时可能只是学习和模仿,新招式积累多了,加上思考和总结,慢慢地自己也会有更多创造性的点子。
如果大家有其他新姿势,期待你们的分享。我把代码放到了GitHub上了,前一种方式对应的Commit: 7d32ddb,后一种方式对应的Commit: 0f26fb5,大家可以clone一份自己感受感受。
最后,感谢CotEditor,感谢开源。