Property Wrapper - 属性包装器

Property wrappers in Swift

Posted by Quincy-QC on 2022-06-27

前言

在Swift 5.1中新增了Property Wrapper这个新的特性,能够将一些行为和逻辑直接附加到我们的属性上,可能极大提高我们代码的重用性,所以我们本次就来介绍这个Property Wrapper,同时分享针对一些场景的高效用法。

简单使用

顾名思义,属性包装器本质上是一种对定值提供额外逻辑包装的类型,通过@propertyWrapper来标识结构体或类实现。Property Wrapper包含一个名为wrappedValue的存储属性,该属性告诉Swift正在包装哪个底层值。

举个例子,我们希望创建一个Property Wrapper,自动对标识属性做String大写,可以如下实现:

1
2
3
4
5
6
7
8
9
10
@propertyWrapper
struct Capitalized {
var wrappedValue: String {
didSet { wrappedValue = wrappedValue.capitalized }
}

init(wrappedValue: String) {
self.wrappedValue = wrappedValue.capitalized
}
}

声明结构体Capitalized使用@propertyWrapper标识后,我们就可以对于任意String属性进行@Capitalized就行修饰,同时可以使用正常字符串的形式一样处理对应属性,如下:

注意我们对于wrappedValue类型的声明,这将限制使用@Capitalized修饰属性的类型。

1
2
3
4
5
6
7
8
9
10
struct User {
@Capitalized var firstName: String
@Capitalized var lastName: String
}

var user = User(firstName: "qiu", lastName: "chong")
print(user.firstName, user.lastName) // Qiu Chong

user.lastName = "qian"
print(user.lastName) // Qian

同时只要Property Wrapper定义了初始化方法,我们就可以为包装的属性分配默认值:

1
2
3
4
5
struct Document {
@Capitalized var name = "untitled document"
}

print(Document().name) // Untitled Document

以上就是Property Wrapper的简单使用,能够透明包装和修改任意存储的属性。

属性包装器的属性

Property Wrapper可以拥有自己的属性,甚至可以依赖注入。
举个例子,当我们使用UserDefaultsAPI存储轻量数据时,通常我们会写比较多的重复代码,但是我们使用属性包装器里面的属性实现这种逻辑:

1
2
3
4
5
6
7
8
9
10
@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结构体将自动拥有初始化方法,拥有默认值的属性可无需再次初始化,所以我们只需要指定key的值:

1
2
3
4
5
6
7
struct SettingsViewModel {
@UserDefaultsBacked<String>(key: "UserName")
var userName

@UserDefaultsBacked(key: "IsVip")
var isVip: Bool?
}

UserDefaultsBacked使用了类型推断,所以上述两种写法都可以
上述的两个属性在读取的时候其实都是可选值,即使在声明的时候声明为非可选属性,但是它的实际值仍然是可选的,因为UserDefaultsBacked的内部类型指定了Value?作为wrappedValue属性的类型,所以我们可以对这个属性包装器进行优化,为其提供一个默认值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@propertyWrapper
struct UserDefaultsBacked<Value> {
let defaultValue: Value
let key: String
var storage: UserDefaults = .standard

var wrappedValue: Value {
get { storage.value(forKey: key) as? Value ?? defaultValue }
set { storage.setValue(newValue, forKey: key) }
}

init(wrappedValue defaultValue: Value,
key: String,
storage: UserDefaults = .standard) {
self.defaultValue = defaultValue
self.key = key
self.storage = storage
}
}

struct SettingsViewModel {
@UserDefaultsBacked(key: "UserName")
var userName: String = "Q"

@UserDefaultsBacked(key: "IsVip")
var isVip: Bool = false
}

但实际开发过程中,我们的UserDefaults存储的值可能是可选的,所以我们不能将nil作为这些属性的默认值,所以我们可以再次优化,每当Value类型符合ExpressibleByNilLiteral协议时,我们将自动插入nil作为默认值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension UserDefaultsBacked where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.defaultValue = nil
self.key = key
self.storage = storage
}
}

struct SettingsViewModel {
@UserDefaultsBacked(key: "UserName")
var userName: String = "Q"

@UserDefaultsBacked(key: "IsVip")
var isVip: Bool = false

@UserDefaultsBacked(key: "ID")
var id: String?
}

因为我们可以将nil设置到UserDefaultsBacked中,为了避免UserDefaults存储崩溃,所以我们可以再升级我们的UserDefaultsBacked,判断分配的值是否为nil,如果不是则存储,是的话就删除存储的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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)
}
}
}
...
}

然后,我们就可以很容易的在项目中进行轻量数据的存储,不用写过多的代码,只需要在所需要的属性前加上@UserDefaultsBacked修饰符就可以实现,让我们的代码变得非常整洁,同时也能充分利用到Swift强大的类型系统。

属性包装器的预测值

Property Wrapper的好处就是我们可以在不影响我们调用的属性的情况下给属性添加逻辑和方法,使用方式和没有Property Wrapper的属性一样读取和写入,但有时候我们想要获取Property Wrapper本身的一些属性或状态,而不是包装只有的属性值,这个时候我们就要用到预测值Projected Value

举个例子,我们对Capitalized进行改造,可以获取到String属性的原值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@propertyWrapper
struct Capitalized {
var projectedValue: String

var wrappedValue: String {
didSet {
projectedValue = wrappedValue
wrappedValue = wrappedValue.capitalized
}
}

init(wrappedValue: String) {
self.projectedValue = wrappedValue
self.wrappedValue = wrappedValue.capitalized
}
}

我们在Property Wrapper中声明了ProjectedValue属性,这样我们就可以通过$获取到Property Wrapper中内置的属性或状态了:

1
2
3
4
5
var user = User(firstName: "qiu", lastName: "chong")
print(user.firstName, user.lastName, user.$firstName, user.$lastName) // Qiu Chong qiu chong

user.lastName = "qian"
print(user.lastName, user.$lastName) // Qian qian

Codable解码

我们在swift中使用Codable解码时会经常遇到一个问题,就是设置默认值。通常情况下,我们可以将值设为可选值或在每个数据结构中重写init:decoder:方法,但是两者或多或少都会有点复杂,一个是可选值在使用过程中需要解包,另一个重写init:decoder:方法会导致代码臃肿,如下问题:

问题一

使用可选值解决解包时key不存在的问题:

1
2
3
4
5
struct Video: Decodable {
let id: Int
let title: String
let commentEnabled: Bool?
}

对于数据:

1
{ "id": 12345, "title": "My First Video" }

将解码得到:

1
// Video(id: 12345, title: "My First Video", commentEnabled: nil)

可选值会让代码变得很丑,同时使用起来变得麻烦,并不是很优雅。

问题二

重写init:decoder:方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum State: String, Decodable {
case streaming
case archived
case unknown
}

struct Video: Decodable {
let id: Int
let title: String
let commentEnabled: Bool
let state: State

enum CodingKeys: String, CodingKey {
case id, title, commentEnabled, state
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
commentEnabled = try container.decodeIfPresent(Bool.self, forKey: .commentEnabled) ?? false
state = try container.decodeIfPresent(State.self, forKey: .state) ?? .unknown
}
}

这个确实能解决可选值遇到的问题,但是需要对于每个模型都写一套很完整的代码,工作量不小,这也是我们项目中目前用到的,比较粗糙。

Property Wrapper

所以我们可以利用Property Wrapper给出中比较优雅的方式来解决,在key不存在或解码失败时,为某个属性设置默认值。

我们期望对于属性的设置可以:

1
2
3
@Default(value: true) var commentEnabled: Bool

@Default(value: .unknown) var state: State

所以对于Default有如下声明:

1
2
3
4
5
6
7
8
@propertyWrapper
struct Default<T: Decodable> {
let value: T

var wrappedValue: T {
get { fatalError("未实现") }
}
}

但是如果按照上述方式修复属性,属性的类型就不是单纯的BoolState类型,而是Default<T>类型,这时候所期望的数据格式是:

1
{ "id": 12345, "title": "My First Video", "commentEnabled": { "value": true } }

所以很显然,这还不是我们想要的东西。

那么我们就换种思路,使用类型约束传值,定义一个protocol来规定默认值:

1
2
3
4
protocol DefaultValue {
associatedtype Value: Decodable
static var defaultValue: Value { get }
}

然后让Bool满足这个默认值:

1
2
3
extension Bool: DefaultValue {
static let defaultValue = false
}

在这里,DefaultValue.Value的类型会根据defaultValue的类型自动被推断为Bool
接下来,重新定义Default这个Property Wrapper,以及用于解码的初始化方法:

1
2
3
4
5
6
7
8
9
10
11
@propertyWrapper
struct Default<T: DefaultValue> {
var wrappedValue: T.Value
}

extension Default: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
wrappedValue = (try? container.decode(T.Value.self)) ?? T.defaultValue
}
}

这样我们就可以用这个新的Default修饰commentEnabled,并对应解码失败的情况了:

1
2
3
4
5
6
7
8
struct Video: Decodable {
let id: Int
let title: String
@Default<Bool> var commentEnabled: Bool
}

// { "id": 12345, "title": "My First Video", "commentEnabled": 123 }
// Video(id: 12345, title: "My First Video", _commentEnabled: __lldb_expr_31.Default<Swift.Bool>(wrappedValue: false))

现在我们已经可以解码类型不同的这类意外输入了,但是如果JSON中commentEnabled``key缺失时,解码依然会发生错误。因为我们所使用的解码器默认生成的代码是要求key存在,所以我们需要为container重写对于Default类型解码的实现:

1
2
3
4
5
extension KeyedDecodingContainer {
func decode<T>(_ type: Default<T>.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Default<T> where T: DefaultValue {
try decodeIfPresent(type, forKey: key) ?? Default(wrappedValue: T.defaultValue)
}
}

在键值编码的container中遇到要解码为Default的情况时,如果key不存在,则返回Default(wrappedValue: T.defaultValue)这个默认值。
所以,现在对于JSON中commentEnabled缺失的情况,也可以正确解码了:

1
2
{ "id": 12345, "title": "My First Video" }
// Video(id: 12345, title: "My First Video", _commentEnabled: __lldb_expr_36.Default<Swift.Bool>(wrappedValue: false))

最后,对于类似Default<Bool>这样的修饰,只能将默认值解码到false,但有时候需要针对不同的情况设置不同的默认值。
DefaultValue协议其实并没有对类型做出太多规定:只要所提供的默认值DefaultValue

1
2
3
4
5
6
7
8
extension Bool {
enum False: DefaultValue {
static let defaultValue = false
}
enum True: DefaultValue {
static let defaultValue = true
}
}

这样,我们就可以用这样的类型来定义不同的默认解码值了:

1
2
@Default<Bool.False> var commentEnabled: Bool
@Default<Bool.True> var publicVideo: Bool

或者为了可读性,更进一步,使用typealias给它们一些更好的名字:

1
2
3
4
5
6
7
extension Default {
typealias True = Default<Bool.True>
typealias False = Default<Bool.False>
}

@Default.False var commentEnabled: Bool
@Default.True var publicVideo: Bool

总结

Property Wrapper绝对是Swift中比较强大的功能之一,他为代码重用和可定制性提供了更多的可能性。当然,Property Wrapper的透明度既是一种优势,也是一种负担,一方面,它使我们能够与未包装属性完全相同的方式访问和分配包装属性,但另一方面,我们可能在相当不明显的抽象背后隐藏太多的功能。
总之,Property Wrapper是非常值得我们学习的,共勉。