大部分項目都需要選擇一種數(shù)據(jù)持久化方案來保存用戶的偏好設置,在iOS開發(fā)中一般選擇UserDefaults。下面先簡單介紹下一般的實現(xiàn)方式,然后再介紹下我在開源項目中學到的一種新姿勢。
準備
項目中除了要支持保存基本數(shù)據(jù)類型,也要支持自定義類型。為了簡化描述,本文只選擇了兩種不同的基本數(shù)據(jù)類型和一種自定義類型。其中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協(xié)議并編碼成Data格式才能通過UserDefaults保存。以上代碼通過Mirror去遍歷對象屬性來實現(xiàn)NSCoding協(xié)議,可參考前一篇博文:利用Swift的反射機制簡化代碼。當然,也可以將自定義類型轉(zhuǎn)換成字典,然后通過UserDefaults提供的讀寫字典的方法訪問。
一般的方式
在較小的項目中由于偏好設置的數(shù)目和讀寫的次數(shù)不多,直接用下面這種一般的方式保存也不太會覺得哪里不妥或效率不高。
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)")
}
}
更優(yōu)雅的方式
上面這種方式看著不那么優(yōu)雅,每次讀寫相應設置也需要多敲一些代碼,降低了編碼效率,當偏好設置選項多起來后缺點就更明顯了。另外,讀取設置時需要知道該設置的類型,然后調(diào)用UserDefaults相應的實例方法。如PreferenceKey.launchAtLogin是Bool型,需要launchAtLogin = userDefaults.bool(forKey: PreferenceKey.launchAtLogin)。讀取自定義類型時,還需要進行類型轉(zhuǎn)換。如userInfo = userDefaults.object(forKey: PreferenceKey.userInfo) as! UserInfo。
后來在開源編輯器項目CotEditor中看到了一種比較巧妙的實現(xiàn)方式,最終可以通過類似Preferences[.launchAtLogin]的形式來讀寫相應的偏好設置,看著有點像訪問字典的方式。實際上,這種方式的主要工作就是實現(xiàn)類似字典的語法。下面就來看看如何實現(xiàn)這種訪問方式。
Preference Key
在Swift中如果想讓自定義類作為Key去訪問某個集合中的元素,那么必須滿足兩個條件:
- 用來訪問集合元素的Key類型本身需要遵循
Hashable協(xié)議。
- 集合實現(xiàn)了
subscript操作符,支持通過方括號[]訪問集合元素。
Hashable
protocol Hashable : Equatable {
var hashValue: Int { get }
}
Hashable協(xié)議中只定義了一個整型的哈希值hashValue,在Swift中任何遵循了該協(xié)議的類型都可以用來當做Set或Dictionary的Key。官方文檔里面提到,在Swift標準庫中很多基本類型遵循了Hashable協(xié)議,如字符串、整型、浮點型、布爾型等。對于自定義類型也只要提供一個hashValue即可。為了保證訪問字典時性能,需要保證同一個對象的哈希值相等,但不同對象的哈希值也是可以相等的。其實我們可以利用String類型本身已經(jīng)實現(xiàn)了Hashable協(xié)議這點省去計算哈希值的工作,下面的RawRepresentable協(xié)議可以幫我們達到目的。
RawRepresentable
RawRepresentable協(xié)議一樣很簡潔,定義了一個初始化方法init?(rawValue:),和rawValue。
正如上面提到的,Swift中的String類型本身就已經(jīng)遵循了Hashable協(xié)議,我們可以利用這一點直接在自定義類型中引入一個字符串類型的rawValue,并通過計算型屬性hashValue直接返回該字符串的hashValue,即可達到遵循Hashable協(xié)議的目的。下面定義了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")
}
注:如果直接將靜態(tài)存儲屬性放在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函數(shù)通過reduce方法將字典類型從[PreferenceKey: Any]轉(zhuǎn)換到[String: Any],以便UserDefaults注冊默認設置。
Subscript
為了支持通過方括號[]訪問PreferenceManager中的元素,還要實現(xiàn)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)
}
}
}
}
到這里就差不多結(jié)束了。不過為了更方便地訪問,再定義一個全局變量:
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的讀寫過程都封裝起來之后,即使是自定義類型的訪問也和基本類型毫無差別了,也不用去操心我們訪問的設置是什么數(shù)據(jù)類型。
但不得不說這種方式目前還是有個小遺憾,那就是Xcode有時候會一臉懵逼變白板,但出現(xiàn)的次數(shù)也不多。這應該是因為Swift類型推斷相對復雜,導致Xcode有點吃不消。大不了通過添加類型,放棄這部分類型推斷,從而減小Xcode的負擔來解決,使用Preferences[PreferenceKey.launchAtLogin]來訪問。不過隨著Swift和Xcode的不斷優(yōu)化,相信這種情況會慢慢改善。
后記
讀源碼是學習新招式的一個非常好的途徑。開始時可能只是學習和模仿,新招式積累多了,加上思考和總結(jié),慢慢地自己也會有更多創(chuàng)造性的點子。
如果大家有其他新姿勢,期待你們的分享。我把代碼放到了GitHub上了,前一種方式對應的Commit: 7d32ddb,后一種方式對應的Commit: 0f26fb5,大家可以clone一份自己感受感受。
最后,感謝CotEditor,感謝開源。