当处理代表某种状态形式的属性时,通常会在每次修改值时触发某种关联的逻辑。例如,我们可以根据一组规则验证每个新值,可以以某种方式转换分配的值,或者每当值更改时都可以通知一组观察者。
在这种情况下,Swift 5.1的属性包装器功能非常有用,因为它使我们能够将此类行为和逻辑直接附加到属性本身上,这通常为代码重用和归纳开辟了新的机会。让我们看一下属性包装器是如何工作的,并探讨一些可以在实践中使用它们的情况的示例。
透明地包装值
顾名思义,属性包装器本质上是一种类型,它包装一个给定的值,以便将附加的逻辑附加到该值上,并且可以使用结构体或类来实现,方法是使用@propertyWrapper
属性对其进行注释。除此之外,唯一真正的要求是每个属性包装类型应该包含一个名为wrappedValue
的存储属性,该属性告诉 Swift 要包装的是哪个底层值。
例如,假设我们想创建一个属性包装器,自动将分配给它的所有字符串值大写。可以这样实现:
代码语言:javascript复制@propertyWrapper struct Capitalized {
var wrappedValue: String {
didSet { wrappedValue = wrappedValue.capitalized }
}
init(wrappedValue: String) {
self.wrappedValue = wrappedValue.capitalized
}
}
请注意,我们需要显式地将传递到初始值中的任何字符串大写,因为属性观察器只有在值或对象完全初始化之后才会触发。
要将新的属性包装器应用于任何String
属性,只需使用@Capitalized
对其进行注释,Swift 就会自动将该注释与上述类型匹配。我们可以这样做,以确保用户类型的firstName
和lastName
属性始终大写:
struct User {
@Capitalized var firstName: String
@Capitalized var lastName: String
}
属性包装器的厉害之处在于,它们的行为完全透明,这意味着我们仍然可以像处理普通字符串一样处理上述两个属性——无论是在初始化用户类型,还是在修改其属性值时:
代码语言:javascript复制// Wei Zhy
var user = User(firstName: "wei", lastName: "zhy")
// Wei Xian
user.lastName = "xian"
类似地,只要属性包装器定义了init(wrappedValue:)
初始值设定项(就像我们的Capitalized
那样),那么我们甚至可以在本地为包装的属性分配默认值,如下所示:
struct Document {
@Capitalized var name = "Untitled document"
}
因此,属性包装器使我们能够透明地包装和修改任何存储的属性——使用@propertyWrapper
标记的类型和与该类型名称匹配的注释的组合。但这只是个开始。
属性的属性
属性包装器也可以有自己的属性,并且支持进一步的定制,甚至可以将依赖项注入到包装器类型中。
例如,假设我们正在开发一个消息应用程序,它使用 Foundation 的 UserDefaults
API在磁盘上存储各种用户设置和其他轻量级数据。这样做通常需要编写某种形式的映射代码,以便将每个值与其底层的UserDefaults
存储进行同步——通常需要为我们要存储的每个数据段进行复制。
然而,通过在通用属性包装器中实现这种逻辑,我们可以使其易于重用——因为这样做可以让我们简单地将包装器附加到任何希望由UserDefaults
支持的属性。下面是这样一个包装器的样子:
@propertyWrapper struct UserDefaultsBacked<Value> {
let key: String
var storage: UserDefaults = .standard
var wrappedValue: Value? {
get { storage.value(forKey: key) as? Value }
set { storage.setValue(newValue, forKey: key) }
}
}
就像任何其他结构体一样,我们上面的UserDefaultsBacked
类型将自动获得一个成员构造器,其中包含所有具有默认值的属性的默认参数——这意味着我们可以通过简单地指定每个属性要由哪个UserDefaults
键支持来初始化它的实例:
struct SettingsViewModel {
@UserDefaultsBacked<Bool>(key: "mark-as-read")
var autoMarkMessagesAsRead
@UserDefaultsBacked<Int>(key: "search-page-size")
var numberOfSearchResultsPerPage
}
编译器将基于通用
UserDefaultsBacked
包装器的类型自动推断出我们每个属性的类型。
上面的设置使我们的新属性包装器易于使用,只要我们希望一个属性由用户默认值.standard
,但由于我们参数化了该依赖关系,如果愿意,我们还可以选择使用自定义实例——例如,为了方便测试,或者能够在同一应用程序组中的多个应用程序之间共享值:
extension UserDefaults {
static var shared: UserDefaults {
let combined = UserDefaults.standard
combined.addSuite(named: "group.johnsundell.app")
return combined
}
}
struct SettingsViewModel {
@UserDefaultsBacked<Bool>(key: "mark-as-read", storage: .shared)
var autoMarkMessagesAsRead
@UserDefaultsBacked<Int>(key: "search-page-size", storage: .shared)
var numberOfSearchResultsPerPage
}
但是,我们上面的实现有一个相当严重的缺陷。尽管上面两个属性都声明为非可选,但它们的实际值仍然是可选的,因为UserDefaultsBacked
类型指定Value?
作为其 wrappedValue
属性的类型。
谢天谢地,这个缺陷很容易修复。我们所要做的就是将defaultValue
属性添加到包装器中,然后在底层UserDefaults
存储不包含属性键的值时使用它。为了使这些默认值的定义方式与通常定义属性默认值的方式相同,我们还将为包装器提供一个自定义初始值初始化器,该初始化器使用wrappedValue
作为新defaultValue
参数的外部参数标签:
@propertyWrapper struct UserDefaultsBacked<Value> {
var wrappedValue: Value {
get {
let value = storage.value(forKey: key) as? Value
return value ?? defaultValue
}
set {
storage.setValue(newValue, forKey: key)
}
}
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
init(wrappedValue defaultValue: Value,
key: String,
storage: UserDefaults = .standard) {
self.defaultValue = defaultValue
self.key = key
self.storage = storage
}
}
有了上述条件,我们现在可以将我们的两个属性变成非可选值,如下所示:
代码语言:javascript复制struct SettingsViewModel {
@UserDefaultsBacked(key: "mark-as-read")
var autoMarkMessagesAsRead = true
@UserDefaultsBacked(key: "search-page-size")
var numberOfSearchResultsPerPage = 20
}
这很好了。然而,我们的一些UserDefaults
值实际上可能是可选的,如果我们必须不断地指定nil
作为这些属性的默认值,那将是不幸的——因为这不是我们在不使用属性包装器时必须做的事情。
为了解决这个问题,我们还为包装器添加了一个方便的API,它的值类型准守ExpressibleByNilLiteral
协议(Optional
即准守次协议),在这个API中,我们将自动插入nil
作为默认值:
extension UserDefaultsBacked where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.init(wrappedValue: nil, key: key, storage: storage)
}
}
有了上述更改,我们现在可以轻松地将UserDefaultsBacked
包装器与可选值和非可选值一起自由使用:
struct SettingsViewModel {
@UserDefaultsBacked(key: "mark-as-read")
var autoMarkMessagesAsRead = true
@UserDefaultsBacked(key: "search-page-size")
var numberOfSearchResultsPerPage = 20
@UserDefaultsBacked(key: "signature")
var messageSignature: String?
}
然而,还有一件事我们需要考虑,因为我们现在可以将nil
分配给UserDefaultsBacked
属性。为了避免在这种情况下发生崩溃,我们必须更新属性包装,首先检查是否有任何赋值为nil
,然后再继续将其存储在当前UserDefaults
实例中,如下所示:
// 因为我们的属性包装器的值类型不是可选的,但是
// 仍然可以包含`nil`值,我们必须引入这个
// 使我们能够将任何赋值转换为类型的协议
// 我们可以与`nil`相比:
private protocol AnyOptional {
var isNil: Bool { get }
}
extension Optional: AnyOptional {
var isNil: Bool { self == nil }
}
@propertyWrapper struct UserDefaultsBacked<Value> {
var wrappedValue: Value {
get { ... }
set {
if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key)
} else {
storage.setValue(newValue, forKey: key)
}
}
}
...
}
属性包装器作为实际类型实现的事实给了我们很大的力量——我们可以给它们属性、初始值设定项甚至扩展——这反过来又使我们能够使我们的调用站点真正整洁干净,并充分利用Swift强大的类型系统。
解码和重写
尽管为了利用值语义,大多数属性包装器可能会实现为结构体,但有时我们可能希望通过使用类来选择引用语义。
例如,假设我们正在进行一个项目,该项目使用特性标志来支持新特性和实验的测试和逐步展开,并且我们希望构建一个属性包装器,让我们以不同的方式指定这些标志。因为我们希望在代码库中共享这些值,所以我们将把包装器实现为一个类:
代码语言:javascript复制@propertyWrapper final class Flag<Value> {
var wrappedValue: Value
let name: String
fileprivate init(wrappedValue: Value, name: String) {
self.wrappedValue = wrappedValue
self.name = name
}
}
有了新的包装器类型,我们现在可以开始将标志定义为封装的FeatureFlags
类型中的属性——这将作为我们应用程序所有功能标志的唯一真实来源:
struct FeatureFlags {
@Flag(name: "feature-search")
var isSearchEnabled = false
@Flag(name: "experiment-note-limit")
var maximumNumberOfNotes = 999
}
在这一点上,上面的Flag
属性包装可能看起来有点多余,因为它实际上除了存储其wrappedValue之
外什么都不做——但这将很快改变。
使用功能标志的一种非常常见的方法是通过网络下载它们的值,例如每次应用程序启动时,或者根据特定的时间间隔。然而,即使在使用Codable
时,实现这一点通常也会涉及到相当多的样板文件,因为对于那些可能尚未添加到后端的标志(或者在测试或回滚完成后已删除的标志),我们很可能希望返回到应用程序的默认值。
因此,让我们使用Flag
属性包装器来实现这种形式的解码。因为我们想使用每个标志的name
作为其编码键,所以我们要做的第一件事是定义一个新的CodingKey
类型,它允许我们这样做:
private struct FlagCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init(name: String) {
stringValue = name
}
// CodingKey协议需要这些初始化器:
init?(stringValue: String) {
self.stringValue = stringValue
}
init?(intValue: Int) {
self.intValue = intValue
self.stringValue = String(intValue)
}
}
接下来,我们需要一种方法来引用我们的每个标志,而不知道它们的泛型类型——但我们不需要诉诸于完全类型擦除,而是要添加一个名为DecodableFlag
的协议,该协议将使每个标志能够根据其Value
类型解码自己的值:
private protocol DecodableFlag {
typealias Container = KeyedDecodingContainer<FlagCodingKey>
func decodeValue(from container: Container) throws
}
有了上述内容,我们现在就可以编写解码代码了,只要Flag
类型的泛型值类型是可解码的,我们就可以使标志类型条件性符合新的DecodableFlag
协议:
extension Flag: DecodableFlag where Value: Decodable {
fileprivate func decodeValue(from container: Container) throws {
let key = FlagCodingKey(name: name)
// 我们只想尝试解码存在的值,如果后端数据中缺少标志,使我们的应用程序返回其默认值:
if let value = try container.decodeIfPresent(Value.self, forKey: key) {
wrappedValue = value
}
}
}
最后,让我们通过使FeatureFlags
符合Decodable
协议来完成我们的解码实现。在这里,我们将使用反射对每个标志属性进行动态迭代,然后要求每个标志尝试使用当前解码容器对其值进行解码,如下所示:
extension FeatureFlags: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: FlagCodingKey.self)
for child in Mirror(reflecting: self).children {
guard let flag = child.value as? DecodableFlag else {
continue
}
try flag.decodeValue(from: container)
}
}
}
虽然我们确实需要实现一些底层的基础设施,但我们现在有了一个非常灵活的特性标志系统——能够在服务器端和客户端指定标志值,并且只需向FeatureFlags
类型添加@flag
注释属性,就可以定义新的标志。
投影值
正如我们在本文中所探讨的,属性包装器的一个主要好处是,它们使我们能够以一种完全不影响我们的调用站点的方式向属性添加逻辑和行为——因为无论属性是否包装,值都是以完全相同的方式读写的。
但是,有时我们实际上可能希望访问属性包装器本身,而不是其包装的值。在使用Apple的新SwiftUI框架构建UI时,这种情况尤为常见,该框架大量使用属性包装器来实现其各种数据绑定API。
例如,这里我们正在构建一个QuantityView
,该视图允许使用Stepper
视图指定某种形式的数量。为了将该状态绑定到我们的视图,我们用@State
对其进行了注释,然后通过以前缀$
传递给步进器,使其直接访问该包装状态(而不仅仅是其当前的Int
值) - 像这样:
struct QuantityView: View {
...
@State private var quantity = 1
var body: some View {
// 以“ $”为前缀的包装属性传递的是属性包装器本身,而不是其值:
Stepper("Quantity: (quantity)",
value: $quantity,
in: 1...99
)
}
}
上面的功能似乎是为SwiftUI量身定制的功能,但实际上它是可以添加到任何属性包装程序中的功能,例如前面的Flag
类型。我们上述属性的“美元前缀”版本称为其包装器的投影值,是通过向任何包装器类型添加projectedValue
属性来实现的:
@propertyWrapper final class Flag<Value> {
var projectedValue: Flag { self }
...
}
这样,任何带有Flag注
释的属性现在也可以作为投影值传递,即作为对其包装器自身的引用。同样,这与SwiftUI无关,实际上,在使用UIKit时,我们也可以采用相同的模式——例如,通过让UIViewController
在初始化时接受Flag
的实例。
这是一个示例,说明了如何实现视图控制器,以便在使用我们的应用程序的调试版本时打开或关闭给定的基于Bool
的功能标志:
class FlagToggleViewController: UIViewController {
private let flag: Flag<Bool>
private lazy var label = UILabel()
private lazy var toggle = UISwitch()
init(flag: Flag<Bool>) {
self.flag = flag
super.init(nibName: nil, bundle: nil)
}
...
override func viewDidLoad() {
super.viewDidLoad()
label.text = flag.name
toggle.isOn = flag.wrappedValue
toggle.addTarget(self,
action: #selector(toggleFlag),
for: .valueChanged
)
...
}
@objc private func toggleFlag() {
flag.wrappedValue = toggle.isOn
}
}
要初始化上述视图控制器,我们将使用与使用SwiftUI传递@State
引用时相同的基于$
前缀的语法:
let flags: FeatureFlags = ...
let searchToggleVC = FlagToggleViewController(
flag: flags.$isSearchEnabled
)
毫无疑问,我们将在以后的文章中进一步探讨以上对属性包装器的使用——因为它可以使我们的代码更具声明性,实现基于属性的观察API,执行相当复杂的数据绑定等等。
结论
属性包装器无疑是Swift 5.1中最令人兴奋的新功能之一,因为它为代码重用和可定制性打开了许多门,并启用了功能强大的新方法来实现属性级功能。即使在诸如SwiftUI这样的声明性框架之外,属性包装器也有大量潜在的用例,其中许多不需要我们对整体代码进行任何大的更改——因为属性包装器大部分都是完全透明地运行。
但是,这种透明度既可以是优势,也可以是责任。一方面,它使我们能够以与未包装的属性完全相同的方式访问和分配包装的属性———但另一方面,存在的风险是,我们最终将在太多的抽象后面隐藏太多的非显而易见的功能。
Thanks for reading! ?
译自 John Sundell 的 Property wrappers in Swift