用Swift更优雅地管理用户偏好设置

阅读原文

大部分项目都需要选择一种数据持久化方案来保存用户的偏好设置,在iOS开发中一般选择UserDefaults。下面先简单介绍下一般的实现方式,然后再介绍下我在开源项目中学到的一种新姿势。

准备

项目中除了要支持保存基本数据类型,也要支持自定义类型。为了简化描述,本文只选择了两种不同的基本数据类型和一种自定义类型。其中launchAtLoginBool型,launchCountInt型,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.launchAtLoginBool型,需要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中任何遵循了该协议的类型都可以用来当做SetDictionary的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,感谢开源。

参考链接

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容