Swift 编写UserDefaults属性包装器的更好方法
UserDefaults是Apple平台上使用最广泛的API之一。具体来说,大多数开发人员无法正确处理默认值。
自从Swift引入属性包装器以来,最常见(也是最好的)用例之一就是实现将基础值存储在中的属性包装器UserDefaults。这是一种方便的方法,并且删除了用于保存和检索来自的值的通用样板UserDefaults。不幸的是,大多数库和代码库均错误地实现了默认值,这通常也导致有关存储在中的可选和非可选值的不良设计UserDefaults。Swift论坛上甚至有关于这个确切问题的最新话题。
默认值正确完成
如果您认为自己知道所有要了解的内容UserDefaults,请等到阅读David Smith出色的(“非正式”)文档,即NSUserDefaults in Practice。您可能会学到一些新东西。
“默认值问题”以两种方式体现。第一种方法是检查nil并在需要时返回默认值。
// option 1: check for nil and return a default
return UserDefaults.standard.object(forKey: "my-key") ?? defaultValue
第二种方法是检查nil并显式设置默认值。
// option 2: set a default if nil is found
let defaults = UserDefaults.standard
if defaults.object(forKey: "my-key") == nil {
defaults.set(initialValue, forKey: "my-key")
}
return defaults.object(forKey: "my-key")
这两个选项都存在问题,尽管选项2明显更糟,正如David指出的那样:
这有一个微妙的长期缺陷:如果您想更改初始值,则无法区分用户设置的值(他们希望保留)还是初始值。您设置(要更改)。
尽管选项1避免了这种初始值缺陷,但可以选择使用恰好UserDefaults提供这种情况的API 。
UserDefaults.standard.register(defaults: ["my-key": defaultValue])
大卫再次写道:
这具有许多优点:
- 它从不存储到磁盘,因此不可能将其与用户设置的值混淆
- 用户设置的任何内容都会自动覆盖它,因此无需将其包装在if语句中以检查是否应避免这样做
- 它避免了在应用启动过程中进行磁盘写操作,这会减慢速度并使磁盘磨损
您可以-registerDefaults:根据需要多次调用,并且它将合并通过它的字典,这意味着您可以在关心它们的代码附近保留设置的注册。
令人惊讶的是,SwiftUI提供了@AppStorage属性包装器,该包装器还错误地获取了默认值。如果使用此功能,则需要register(defaults:)在应用启动过程中的某个位置手动调用所有键值对。此外,它仅在iOS 14及更高版本中可用。
可选问题
与“默认值问题”相关的是使用可选项。大多数开发人员都同意,我们应该尽量消除Swift中的可选选项。如果使用来提供默认值register(defaults:),则检索它将始终返回某些内容(即非可选值)。这意味着在许多情况下,我们可以确定中存在一个值UserDefaults。
我在代码库中看到的一个常见问题是一个键值对,用于确定用户是否首次启动该应用程序。通常,这采用带有Bool值的“ isFirstAppLaunch”键的形式。通常情况下,一些手写的包装器UserDefaults将使用一般object(forKey:)得到的值,它给你留下的无论是“truthy”的结果true,false或nil。尽管bool(forKey:)存在解决此特定问题的事实,但我仍然看到这在大型代码库中发生。
无论如何,在不使用的情况下,register(defaults:)我们留下了许多变通办法来处理(合法的可能是或无效的)nil值。
现有库调查
当前有一些库提供的属性包装器UserDefaults。但是,我所知道的每个问题都具有以下问题的组合:(1)未注册默认值,
(2)可选项未得到很好的处理,
(3)对于这样一个简单的任务,库非常复杂。
SwiftyUserDefaults可能是最受欢迎的。这是一个有趣的库,它展示了高级Swift语言功能,例如@dynamicMemberLookup。但是,这对于我的使用来说太复杂了。我认为它尝试做太多事情,尤其是当我需要的只是一个属性包装器时。值得注意的是,它没有使用register(defaults:)。
Sindre Sorhus的Defaults库与SwiftyUserDefaults非常相似。它也非常复杂,可以做很多事情。但是,它可以正确使用register(defaults:)。
UserDefaults这些库除了提供属性包装程序的核心功能外,还提供了用于通过KVO观察更改的完整基础结构。我认为这不应该成为库的一部分。我认为大多数开发人员可分为以下几类:
(1)他们不需要这种观察;
(2)他们在代码库中明确避免使用KVO;
(3)如果代码库大量使用KVO,则它可能具有通用观察器或其他已实现的包装器。
此外,UserDefaults还允许通过通知进行观察。因此,我认为观察是客户端要处理的任务(如果需要的话),他们可以通过使用通知,使用RxSwift之类的反应性扩展,使用通用的KVO包装器或手动编写KVO代码来实现,这只是几行,并且不太困难(尤其是Swift对Objective-C API的改进)。
从实践中的NSUserDefaults中,我要强调:
NSUserDefaults旨在用于相对少量的数据,非常频繁地查询,并偶尔进行修改。与更适合那些用途的解决方案相比,以其他方式使用它可能会比较慢或占用更多内存。
这进一步证实了省略观察者。如果您UserDefaults经常查询存储在其中的数据,但只是偶尔对其进行修改,那么您就没有什么可观察的了。在大多数使用情况下,用户将访问iOS上的设置视图或macOS上的“首选项”面板,配置一些选项,而很少返回再次对其进行修改。以我的经验,大多数偏好不需要实时观察,而是在下次使用时会被查询。当然,可能需要实时响应某些首选项的更改(例如,如果它们更改了您应用的外观)。同样,我认为更适合逐案处理,或在此库之外构建组件以供观察。
另一个问题是这些库的支持Codable和NSCoding类型,我认为这是不好的鼓励。UserDefaults不用于存储大型数据Blob。您应该使用适当的数据库,或者只是将这些类型Codable和NSCoding类型写入磁盘。
同样,从实践中的NSUserDefaults:
只有可以存储在plists中的类型才能存储在中NSUserDefaults。如果要存储任意对象,则需要使用NSKeyedArchiver或类似方法NSData首先从它们中进行制造。通常,这意味着您正在尝试存储除用户设置以外的其他内容…
最后,还有Guillermo Muntaner的Burritos库,其中包含许多不同的属性包装器。这是迄今为止最简单的实现,对此我深表感谢。尽管如此,它仍无法按我的意愿处理默认值和可选值。总体而言,该项目更像是一个示例展示。
新库:Foil
我决定为此编写一个自己的小型库,称为Foil,该库解决了到目前为止我已经讨论的所有问题。
- 使用以下命令正确处理默认值 register(defaults:)
- 消除了在可能的情况下必须处理可选项的问题
- 提供实用,简单且轻量级的实现
Foil支持所有能够存储在中的属性列表类型UserDefaults,包括RawRepresentable类型,这意味着它可以直接使用enum类型。它明确省略了对Codable和的支持NSCoding。UserDefaultsSerializable尽管不建议使用单个协议来实现自定义类型。任何观察都留给客户。
实现属性包装器
属性包装器的代码很小,可能类似于您所看到的其他实现。
@propertyWrapper
public struct WrappedDefault<T: UserDefaultsSerializable> {
private let _defaultValue: T
private let _userDefaults: UserDefaults
public let key: String
public var wrappedValue: T {
get {
self._userDefaults.fetch(self.key)
}
set {
self._userDefaults.save(newValue, for: self.key)
}
}
public init(keyName: String,
defaultValue: T,
userDefaults: UserDefaults = .standard) {
self.key = keyName
self._defaultValue = defaultValue
self._userDefaults = userDefaults
userDefaults.registerDefault(value: defaultValue, key: keyName)
}
}
请注意,默认值会在初始化期间立即注册。扩展方法fetch()和save()onUserDefaults封装会处理可选内容(通过强制展开,因为我们知道这样做是安全的)。您可以UserDefaults(suiteName: "someDomain")根据需要传递自定义商店,例如。最后,您存储的类型必须符合UserDefaultsSerializable。为内置类型提供了默认一致性。因为我们提供了默认值,所以我们知道永远不会nil。
在某些情况下,nil可能是您的密钥的有效值。在这种情况下,提供了第二个属性包装器@WrappedDefaultOptional,该包装器允许将值设为,nil并忽略defaultValue:参数(默认为nil)。
使用Foil
使用Foil就像声明使用包装器的属性一样简单。建议您定义一些中心位置来存储所有设置,如下所示:
// define centralized settings
final class AppSettings {
static let shared = AppSettings()
@WrappedDefault(keyName: "flagEnabled", defaultValue: true)
var flagEnabled: Bool
@WrappedDefaultOptional(keyName: "timestamp")
var timestamp: Date?
}
// elsewhere…
// get or set properties
AppSettings.shared.flagEnabled
AppSettings.shared.timestamp
使库保持较小的一部分意味着省略某种全局入口点,如SwiftyUserDefaults中的DefaultsAdapter组件,我觉得这有点尴尬和麻烦。我上面定义的类很容易编写,但是更重要的是,客户端可能已经在使用自己的抽象了。AppSettings
处理键
您可能想知道那些“字符串型”键。如果使用此(推荐的)实现集中所有设置的实现,则无需enum为所有键名定义一个。您只需要声明属性,然后通过即可访问它们AppSettings.shared。我认为这适用于绝大多数项目。但是,如果要将键定义为enum,则可以编写一个小的扩展名:
enum AppSettingsKey: String {
case flagEnabled
case timestamp
}
extension WrappedDefault {
init(key: AppSettingsKey, defaultValue: T) {
self.init(keyName: key.rawValue, defaultValue: defaultValue)
}
}
然后,您可以将enum值用作键:
@WrappedDefault(key: .flagEnabled, defaultValue: true)
var flagEnabled: Bool
@WrappedDefaultOptional(key: .timestamp)
var timestamp: Date?
最后,有一个潜在的漏洞来源需要指出。您可能会意外地定义两个具有相同键名但默认值不同的属性。初始化的第二个属性的默认值将覆盖第一个属性。Christian Tietze在此撰写了有关此问题的文章,但错误地认为SwiftyUserDefaults通过一起定义键名和默认值来解决此问题。不幸的是,在SwiftyUserDefaults中,没有什么阻止您编写如下内容:
// SwiftyUserDefaults
extension DefaultsKeys {
var launchCount: DefaultsKey<Int> {
DefaultsKey("launchCount", defaultValue: 0)
}
var launchCount2: DefaultsKey<Int> {
DefaultsKey("launchCount", defaultValue: 99)
}
}
尽管也同时定义了键名和默认值,但Foil容易受到此bug的影响,而这正是使用基于字符串的键所固有的-这就是UserDefaults工作原理。您可以通过UserDefaults直接滥用API轻松地引入相同的错误。避免此错误的唯一方法是确保所有键名都是唯一的,并集中所有键值对的定义,就像我AppSettings在上面的示例中对类所做的那样。
存放URL特别
最后一点:URL涉及到是特别的UserDefaults。当我写Foil的时候,我遇到了一个奇怪的错误。尝试保存A时,URL我打了一个断言,并出现错误:“试图插入非属性列表对象NSInvalidArgumentException”。我不得不强迫Swift使用URL-specific方法来设置a,URL而不是使用通用set(_:, forKey:)API。
UserDefaults.standard.set(someURL as? URL, forKey: key)
尝试读回该值时,我遇到了另一个断言,并出现错误:“无法将类型的值强制转换_NSInlineData为NSURL”。再次,我不得不使用URL-specific方法来获取URL而不是通用object(forKey:)API。
let url = UserDefaults.standard.url(forKey: key)
所有其他类型都可以使用通用方法set(_:, forKey:),object(forKey:)并且可以按预期工作。很奇怪。在实践中重新阅读NSUserDefaults之后,我了解了原因:
该-setURL:forKey:方法可以实现其在fiol上的功能,但它的独特之处在于它是唯一NSUserDefaults一种可以存储非plist类型的方法。如果要存储NSURLs,则必须使用它而不是-setObject:forKey:
根据上面的错误消息,它看起来像是在内部,UserDefaults正在URL向/从Datanamed的私有子类转换_NSInlineData。
你知道的越多。(还记得我说过您可能会学到新东西吗?)
结论
Foil是一个库,它封装了我编写UserDefaults属性包装器的首选方法。
加入我们一起学习SwiftUI
QQ:3365059189
SwiftUI技术交流QQ群:518696470
教程网站:www.openswiftui.com