我们喜欢Swift,因为它是一种简洁直观的编程语言。最近,SwiftUI让我们心跳加速,因为它为我们提供了很棒的工具,用很少、简单的代码就可以创建令人惊叹的用户体验。但是随着属性包装器的引入,突然出现了许多@-符号、$-符号和下划线。事情不是变得更复杂了吗?

在老式的Objective-C中,这些符号随处可见,但在Swift中,为了简化语法,它们似乎已经消失了——直到苹果在Swift 5.1中通过属性包装重新引入它们。那么这样做有什么好处呢? 属性包装器是如何工作的? 您需要了解它们哪些信息?

在本文中,我们将快速了解如何使用现有的属性包装器(对于那些还不熟悉它们的人来说)。我们将继续使用一些非常有用的示例,然后解释如何编写自己的属性包装器。 我们还将研究当前实现的一些限制,并讨论何时以及如何最好地使用属性包装器。

幸运的是,苹果已经在iOS中为我们提供了几个属性包装器。因此,在开始创建自己的属性包装器之前,让我们先来了解一下如何使用它们!


1. How to Use Property Wrappers

我们将从iOS开发中一个众所周知的问题的简单例子开始:UserDefaults。我们经常看到这样的自定义访问器属性:

var username: String {
    get {
        UserDefaults.standard.string(forKey: "user-name")
    }
    set {
        UserDefaults.standard.set(newValue, forKey: "user-name")
    }
}

这些用于读取和写入UserDefaults的访问器通常分散在应用程序中,导致大量代码重复。在某种程度上,它们向使用它们的类公开了实现细节,使得代码更加冗长。

从ios14开始,@AppStorage属性包装器可以用更简单的语法来实现同样的结果。它在SwiftUI视图中特别有用。

假设我们在视图中有一个属性username,我们想要与UserDefaults同步:

struct MyView: View {
    @AppStorage("user-name")
    var username = ""
}

我们可以像使用其他属性一样使用username属性。您可以将其视为一个计算属性,用于读取和写入值,但AppStorage包装器不是自己编写代码,而是为您提供代码。指定的字符串"user-name"对应于UserDefaults使用的键。

有三种方法可以访问属性包装器的不同部分:_username, $username和username:

  • _username, 您可以访问AppStorage属性包装器本身。
  • username, 您将访问包装器的wrappedValue属性—换句话说:它等价于_username.wrappedValue。
  • $username, 您可以访问包装器的projectedValue属性,该属性可用于由属性包装器提供的附加上下文。我们稍后会更详细地讨论。

但这难道不是一种语法上的便利吗? 事实上,使用属性包装器可以做的所有事情,在没有它们的情况下也可以做(至少在SwiftUI之外)。但是,如果使用正确的方式,属性包装器可以使代码更干净、更简洁。


2. Applications

现在我们已经熟悉了基本知识,让我们通过一些真实的例子来更好地了解使用属性包装器可以做些什么!

2.1. BetterCodable

BetterCodable利用属性包装器来简化Codable的使用,以解决常见的编码/解码问题。例如,您可以为给定的属性指定日期格式,或定义有损数组,这些数组在对其值的解码失败时不会失败。它们只是简单地跳过无法读取的值并继续解码。

当然,您可以自己编写所有这些内容,甚至可以很好地将其隐藏在扩展中,但有了这些属性包装器,您根本不需要编写自定义编码/解码,您可以在声明处指定编码/解码。

struct Response: Codable {
    @LossyArray var integers: [Int]
    @DefaultEmptyArray var strings: [String]
    @LosslessValue var boolean: Bool
    @DateValue<ISO8601Strategy> var start: Date
    @DateValue<TimestampStrategy> var end: Date
}

在上面的例子中,JSON有效负载如下所示

{
    "integers": [3, 4, 5, null],
    "boolean": "true",
    "start": "1970-01-01T00:00:00+02:00",
    "end": 978307200.0
}

仍然会被解码为以下对象:

Response(
    integers: [3, 4, 5],
    strings: [], 
    boolean: true,
    start: Date(timeIntervalSince1970: 0), // 01.01.1970 at 00:00
    end: Date(timeIntervalSince1970: 978307200.0) // 01.01.2001 at 00:00
)

代替失败的解码过程(在一个相当大的JSON中,您无法控制),整数数组可以包含将被忽略的非整数值,字符串数组可以是空或根本不存在,而无需在结构中是可选的。LosslessValue可以在不同的值表示之间进行转换。例如,如果一个布尔值被编码为JSON中的字符串("true")而不是布尔值(true),解码仍然会成功。这两个日期都使用了不同的编码/解码策略,codebable通常不支持这种策略(没有编写复杂的自定义解码)。

2.2. Fluent

Fluent是一个访问本地数据库的框架,例如SQLite或SQL数据库。尽管它对许多应用程序非常有用,但在版本3中也有一些缺点:例如,您必须为想要用作标识符的属性提供一个键路径。为单个属性定义自定义键也相当困难。

这就是为什么他们在新版本4.0中引入了属性包装器,使这些任务变得更容易:

final class User: Model {
    static let schema = "users"
    @ID(custom: .id)
    var id: UUID?
    @Field(key: "email")
    var email: String
    @Field(key: "password")
    var passwordHash: String
    init() { }
    init(id: UUID? = nil, email: String, passwordHash: String) {
        self.id = id
        self.email = email
        self.passwordHash = passwordHash
    }
}

在本例中,我们想要用作标识符的属性简单地用@**ID(custom:)**属性包装器标记。我们使用@**Field(key:)**包装器指定哪些属性和它们各自的键存储在数据库中。因此,我们可以在定义属性的地方设置数据库配置,从而很容易添加新属性。

2.3. SwiftUI

如果您熟悉SwiftUI,那么您肯定还会遇到一些属性包装器,比如@State、@Environment、@EnvironmentObject、@observetobject、@StateObject等等。(如果你没有,你可能想跳过这部分。)让我们来看看其中的一些,以及它们在状态管理技术上的差异。

struct HomeView: View {
    @Environment(\.presentationMode)
    var presentationMode
    @EnvironmentObject
    var store: Store
    @ObservedObject
    var service: DataService
    @State 
    private var query = ""
    var body: some View {
        ...
    }
}

在本例中,我们从视图的环境(视图配置,如accentColor或当前的presentationMode存储在其中)访问值presentationMode和一个可观察对象store。此外,我们希望在HomeView的初始化器中有一个服务,我们希望观察它(当它改变时,视图会得到更新)。 此外,query被用作包含搜索文本字段内容的视图的内部状态。

从这个例子中,我们可以看到属性包装器可以用于从不同的位置(例如视图的环境)提取值和/或添加可观察性。 如果您想了解更多关于属性包装器如何在SwiftUI中使用的信息,请查看我们关于SwiftUI架构的文章。

2.4. Invariants & Mappings

属性包装器还可以用来确保不变量和映射值。例如,您可能希望将百分比值存储为0到1之间的浮点数,但在应用程序中,您希望将其访问为0到100之间的值。或者您可能想要确保一个值被固定在给定的范围内。

示例:您可以构建一个属性包装器来将一个值夹入一个特定的范围(例如,如果设置的值超出该范围,则取该范围的最大/最小值)。 在本例中,我们构建了一个包含红色、绿色和蓝色值的RGBColor结构体。

struct RGBColor {
    @Clamped(0...255)
    var red: Double
    @Clamped(0...255)
    var green: Double
    @Clamped(0...255)
    var blue: Double
}

当用户给红色赋值350时,我们希望保持在RGB颜色的有效范围内,而赋值255(因为它最接近赋值)。

3. How to Build Your Own Property Wrappers

到目前为止,您应该对属性包装器是什么以及如何利用它们来简化代码有了相当好的感觉。所以让我们看看如何创建我们自己的!

3.1. 🔑 A Keychain property wrapper

Apple仅为UserDefaults提供了一个属性包装器(@AppStorage),没有为Keychain访问提供属性包装器。因此,让我们更改它并创建一个Keychain属性包装器!这是我们想要的用法:

@SecureAppStorage("user-name")
var userName: String?

3.2. What we use to build it

为了使这一点更简单,我们已经编写了一个KeychainItem结构体,它帮助我们用一个简单的接口连接到Keychain

struct KeychainItem {
    let service: String
    let account: String
    func get() -> String? { ... }
    func set(_ value: String?) { ... }
}

完整的实现不是本文的重点,但是如果您感兴趣,可以在这里找到它。

3.3. How we build it

现在让我们围绕KeychainItem编写包装器!

@propertyWrapper
struct SecureAppStorage {
    var item: KeychainItem
    init(_ account: String, service: String = Bundle.main.bundleIdentifier!) {
        self.item = .init(service: service, account: account)
    }
    public var wrappedValue: String? {
        get {
            item.get()
        }
        nonmutating set {
            item.set(newValue)
        }
    }
}

在第一行中,我们将以下结构体标记为@propertyWrapper。在初始化式中,用户可以指定要使用的keychain帐户和服务。 (帐户是Keychain项在服务中引用的标识符。) wrappedValue实现了一个带有自定义访问器(get/set)的属性,用于从Keychain读取和写入相应的值。 当您调用任何使用SecureAppStorage属性包装器的属性时,该值将被隐式访问。

如果要进一步允许初始/默认值,可以在属性包装器的初始化式中添加第一个属性wrappedValue。 在我们的例子中,我们可以使用下面的初始化式:

init(wrappedValue: String, 
     _ account: String, 
     service: String = Bundle.main.bundleIdentifier!) {
    ...
}

例如,这将提供以下功能:

@SecureAppStorage("user-name")
var username = "empty-user-name"

初始化器的第一个属性随后用“empty-user-name”字符串填充,其余属性在属性包装器名称后的括号中指定。

3.4. How we extend it

在某些情况下,我们可能想要直接访问KeychainItem,所以我们需要一种快速访问它的方法。当然,我们可以在SecureAppStorage上编写一个computed属性并使用_username访问它。<我的属性名>如上所述,但我们也可以在这里使用更短的语法:$username。我们需要做的就是在SecureAppStorage包装器中添加一个projectedValue属性:

extension SecureAppStorage {
    var projectedValue: KeychainItem {
        item
    }
}

换句话说:$username只是访问属性包装器的projectedValue的别名。

4. ⚠️ Pitfalls & Limitations

在前几节中,我们已经看到了属性包装器的强大功能,以及它们如何显著提高代码的可读性。 但与大多数工具一样,也有一些缺点和需要注意的事情(参见Swift 5.2和5.3)。

4.1. Property wrappers are always private.

如果不使用自定义计算属性将其公开,从给定类型外部访问属性包装器是不可能的,即使在不同文件中的类型扩展中。

在以下示例中,不能从KeyValueStore类外部访问_username:

final class KeyValueStore {
    @SecureAppStorage("user-name")
    var username: String?
}

4.2. Property wrappers can’t be aliased.

比方说,你想要使用上面的@Clamped属性包装器在你的应用程序中表示不同的百分比值,因此想要使用@Percentage包装器,像这样:

@Percentage
var opacity: Double = 0.5

由于属性包装器是结构(大多数时候),继承不能用于创建一个Percentage of Clamped子类。 假设我们不想使用继承,那么就没有直观的方法来为属性包装器创建别名。

让我们假设在上面的例子中,我们想要写以下代码(没有有效的Swift代码!)

typealias Percentage = Clamped(0...1)

有一些方法可以做到这一点,但它们远非理想——更多信息请参阅本文

4.3. Using property wrappers for non-property variables is not allowed.

在Swift中定义属性或变量之间的语法差异非常小,因为唯一的差异是它们的上下文。但是,属性包装器不能用于变量—只能用于封闭类型的属性。

4.4. You cannot override properties with wrapped properties.

Swift不允许您使用不同的包装属性覆盖包装属性,而不使用自定义的getter和setter。如果我们试图像这样重写子类中包装的属性值:

class SuperClass {
    @SuperClassWrapper
    var value: Value
}
class SubClass: SuperClass {
    @SubClassWrapper
    override var value: Value
}

Swift会抛出两条错误消息:

Property 'value' with attached wrapper cannot override another property.
Cannot override with a stored property 'value'. 

取而代之的是下面的方法,但是它与使用属性包装器的想法背道而驰。

class SuperClass {
    @SuperClassWrapper
    var value: Value
}
class SubClass: SuperClass {
    @SubClassWrapper
    var _value: Value
    override var value: Value {
        get { _value }
        set { _value = newValue }
    }
}

4.5. Dependencies between properties and property wrappers

让我们假设,你构建了一个KeyValueStore类型,包含所有的UserDefaults和Keychain元素,如下所示:

class KeyValueStore {
    @SecureAppStorage("username") 
    var username
    @SecureAppStorage("password")
    var password
    @AppStorage("isFirstLaunch")
    var isFirstLaunch = false
    @AppStorage("isLoggedIn")
    var isLoggedIn: Bool
}

现在,因为你想在应用的不同部分使用store(例如针对不同用户),你想使用不同的服务名称和UserDefaults。你可以尝试将两个属性serviceName和defaults添加到store中,然后像这样将它们传递给各自的属性包装器:

class KeyValueStore {
    let serviceName: String
    let defaults: UserDefaults
    @SecureAppStorage("username", service: serviceName) 
    var username
    @SecureAppStorage("password", service: serviceName)
    var password
    @AppStorage("isFirstLaunch", defaults: defaults)
    var isFirstLaunch = false
    @AppStorage("isLoggedIn", defaults: defaults)
    var isLoggedIn: Bool
}

但是属性包装器不是惰性加载的,这意味着您不能使用任何对self或其任何属性的引用来初始化它们。

绕过这个限制的唯一方法是在初始化式中手动创建属性包装器:

class KeyValueStore {
    let serviceName: String
    let defaults: UserDefaults
    init(serviceName: String, defaults: UserDefaults) {
        self.serviceName = serviceName
        self.defaults = defaults
        self._username = SecureAppStorage("username", service: serviceName)
        self._password = SecureAppStorage("password", service: serviceName)
        self._isFirstLaunch = AppStorage(
            wrappedValue: false, 
            "isFirstLaunch", 
            defaults: defaults
        )
        self._isLoggedIn = AppStorage(
            wrappedValue: false, 
            "isLoggedIn", 
            defaults: defaults
        )
    }
    @SecureAppStorage
    var username
    @SecureAppStorage
    var password
    @AppStorage
    var isFirstLaunch: Bool
    @AppStorage
    var isLoggedIn: Bool
}

这与属性包装器的精益方法不一致。如果属性包装器可以惰性加载,并且它们的初始化可能发生在属性声明的相同位置,那么向这个现有类型添加新属性就不那么容易了。

4.6. Property wrappers cannot be required in protocols.

在协议声明中,不能指定属性应由特定的属性包装器包装。类似地,您不能指定应该是存储属性还是计算属性。例如,我们不能编写如下协议来强制实现对username属性使用SecureAppStorage属性包装器。

protocol KeyValueStoreProtocol {
    @SecureAppStorage var username { get set }
}

关于在Swift Evolution过程中添加这一特性的讨论正在进行中,因此在未来它可能最终成为可能。我们非常欣赏这个更改,因为它将允许我们确保数据在协议级别安全地存储(如上面的示例),而不是手动地确保遵守这些约束。

4.7. You cannot use different types for getting and setting the wrappedValue.

假设您希望构建一个属性包装器,以便在为属性赋值nil时使用默认值。

@Default([])
var array: [String]?
// Usage:
array = nil
print(array) // result is an Optional<[String]>
             // but we would like it to be [String]

不幸的是,不可能定义一个单独的类型来获取和设置属性。 你可以通过在属性包装器中添加一个set(_:)方法来避免这个问题,而不是直接设置属性,你可以使用_array.set(nil)来调用这个方法。然而,这似乎不是一个非常优雅的解决方案,因为设置和获取值所需的语法将不一致。

4.8. Setting a property wrapper’s wrappedValue cannot result in failure.

由于Swift中的变量赋值不会抛出错误,属性包装器没有统一的接口来处理失败事件。这是不幸的,因为在某些情况下,我们确实可以从错误处理中获益:例如,属性包装器可能在文件中存储对其属性的更改。在这些情况下,我们可能希望在读取或写入文件失败时处理错误。

作为这个问题的解决方案,您可能希望使用Combine publisher或RxSwift可观察对象提供失败事件,具有失败属性,将wrappedValue更改为Result<Value, Error>或再次使用属性包装器上的自定义 **throwing set(_😃**方法。

4.9. Property wrappers might hide heavy computations.

属性包装器的语法非常简洁而强大。如果属性包装器执行一些繁重的计算来获取和/或存储一个值,它可能会导致不可预见的性能问题,在使用包装器的地方不直接可见,因为它看起来只是一个简单的属性赋值。为了避免这个问题,属性包装器可能需要遵循特定的复杂性标准(例如常量访问时间)和/或缓存值,以便在很长一段时间内获得常量get/set时间。

让我们的数据库举例:当更新一个模型对象的一个属性将自动更新数据库的价值,你可能会非常快的就遇到性能问题——特别是当设置这些属性在代码部分,在这里高性能是至关重要的,因为它可能每秒执行多次。

4.10. Protocol types cannot be used for property wrappers with type constraints.

一些属性包装器,如SwiftUI中的@ObservedObject包装器,要求它们包装的值符合特定的协议。如果在Swift中一个泛型类型上有一个类型约束,你不能指定一个协议作为该类型约束,而是必须使用一个具体的类型,即使该协议没有相关的type /Self要求。

示例:如果你要创建一个属性包装器MyPropertyWrapper<ValueType: MyProtocol>,你不能这样使用它:@MyPropertyWrapper var value: MyProtocol。

4.11. Referencing the enclosing self is not possible – yet.

Swift Evolution关于属性包装器的建议中还包含了一节关于在包装器中引用封装自身的内容。尽管给定部分中提到的特性已经实现,但它们还不能对公众开放,只能在Combine框架内部使用。常规开发者将不得不等待该功能上市,可能是在通过Swift Evolution的另一轮反馈之后。

如果属性包装器知道如何使用它的上下文(例如属性的名称或使用它的类型),它当然会有好处。例如,数据库可以使用属性名作为数据库表中的列名,而不需要用户提供自定义的列名(但可能仍然允许)。

我们非常感谢让每个人都能使用这个功能。我们现在只能想象少量的好用的用例,但是对于所有还没有想象到的用例,我们同样感到兴奋。

4.12. Composition is tricky.

特别是在使用映射或断言属性包装器时,您可能希望同时使用多个属性包装器。在下面的代码示例中,我们希望提供一个属性,该属性的值与存储在给定UserDefaults值中的值相反。

@AppStorage("isDarkModeEnabled") @Negated
var usesLightInterface: Bool

这种属性包装器的组合并不容易,因为AppStorage属性包装器的wrappedValue现在将是一个Negated对象。当链接属性包装器时,实际上不是在同一个属性上使用多个包装器,而是使用一个包装器来包装另一个包装器。顺序很重要。

在较小的用例中,您可能希望在另一个属性包装器的实现中使用属性包装器。让我们以上面的例子为例:

@propertyWrapper
struct NegatedAppStorage {
    @AppStorage
    private var storedValue: Bool
    init(wrappedValue: Bool, _ name: String) {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, name)
    }
    var wrappedValue: Bool {
        get {
            return !storedValue
        }
        set {
            storedValue = !newValue
        }
    }
} 

正如您所看到的,这个新的属性包装器将高度特定于这个用例,并且在类似的用例中重用它将需要编写一个新的属性包装器。
因此,属性包装器显然不是为合成而设计的。


5. Alternatives to Property Wrappers

正如我们所看到的,属性包装器在编写可重用代码时非常有用,但也会带来一些您需要考虑的限制。你有什么替代选择,什么时候应该使用它们?

5.1. Property Observers: willSet/didSet

属性包装器可用于确保不变量,无论是通过断言这些条件还是确保它们被保留(如在clamp示例中)。通常,您希望确保属性中的这些不变量,您还希望用另一个属性包装器包装这些不变量。由于组合属性包装器并不简单,而且单独使用属性包装器结构体(即不在其中包装属性,只使用它们的行为)也不容易,所以在这些情况下,使用属性观察器(willSet/didSet)更好。

5.2. Custom Getters & Setters: get/set

虽然编写自定义属性包装器可能不会花费很多时间,但它仍然比为计算属性编写自定义getter和setter所花费的时间要多,特别是当您想要创建某种泛型属性包装器时。使用computed属性,您有更多的控制权,并且更容易调试,因为属性包装器经常在编译器中导致分段错误。当一个属性包装器在整个应用程序中只在一两个地方使用,或者当它依赖于许多相同类型的其他属性时,自定义getter和setter可能是更好的选择。


6. Conclusion

正如我们在本文中看到的,属性包装器可以极大地提高代码的可重用性并降低代码的复杂性。 当与projectedValue和泛型的广泛使用结合使用时,这种效果尤为显著,使得属性包装器非常强大。

在Swift 5.3中,属性包装器的实现仍然有改进的空间。自定义属性包装器的编译有时可能会导致奇怪的错误消息,这个特性仍然有相当多的限制,希望在概念和实现的进一步发展中解决。

在实现自定义属性包装器之前,你应该总是考虑使用willSet/didSet或自定义getter /setter。许多用例不能很容易地被抽象出来,属性包装器对于一般的、经常需要的、即时的属性访问是最有用的。

对于为框架开发人员添加的属性包装器,我们尤其感到兴奋,因为属性包装器的使用可以极大地简化框架的API。