本文介绍了一个使用 Swift Codable 解码时难以设置默认值问题,并利用 Property Wrapper 给出了一种相对优雅的解决方式,来在 key 不存在时或者解码失败时,为某个属性设置默认值。这为编解码系统提供了更好的稳定性和可扩展性。最后,对 enum 类型在某些情况下是否胜任进行了简单讨论。
Codable 类型中可选值的窘 (囧?) 境
基础类型可选值
Codable 的引入极大简化了 JSON 和 Swift 中的类型之间相互转换的难度。当我们将 Swift 类型中的一个值设定为可选值 (Optional) 时,意味着即使 JSON 中这个值缺失了,我们也可以将 JSON 成功解码。比如 Video
类型代表了一段视频直播,其中 up 主可以设定是否接受评论:
1
2
3
4
5
struct Video: Decodable {
let id: Int
let title: String
let commentEnabled: Bool?
}
下面的情况:
1
{"id": 12345, "title": "My First Video"}
将解码得到:
Video(id: 12345, title: “My First Video”, commentEnabled: nil)
引入可选的 commentEnabled
,会导致使用起来相当麻烦。很可能我们不得不在 view controller 层级上去写这样的代码:
1
2
3
if video.commentEnabled ?? false {
// 在这里显示 comment UI
}
这让代码变得很丑,而且会散落在使用到 commentEnabled
的各个地方。如果我们想要的是,当 "commentEnabled"
key 不存在时,将对应的属性设为 false
,应该要怎么做呢?
Swift 的 Decodable
并不支持在声明存储属性时为它指定默认值。如果强制进行赋值,你将会收获一个警告,JSON 值也无法被正确解析:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误的代码
struct Video: Decodable {
let id: Int
let title: String
let commentEnabled: Bool = false
// Warning: Immutable property will not be
// decoded because it is declared with an
// initial value which cannot be overwritten
}
// {"id": 12345, "title": "My First Video", "commentEnabled": true}
// => Video(id: 12345, title: "My First Video", commentEnabled: false)
显然这是不对的。
一个稍微好一些的做法是,为 commentEnabled: Bool?
设定一个特殊的 getter,来统一在一个地方返回不存在时的默认值:
1
2
3
4
5
6
7
struct Video: Decodable {
// ...
private let commentEnabled: Bool?
var resolvedCommentEnabled: Bool {
commentEnabled ?? false
}
}
相信你和我一样,会非常头疼 resolvedCommentEnabled
的名字到底应该怎么决定,这也带来了某种意义上的重复。
最不偷懒的解决方式,当然是为整个 Video
重写解码所需要的 init(from:)
方法,来在 commentEnabled
key 不存在时直接设定默认值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Video: Decodable {
let id: Int
let title: String
let commentEnabled: Bool
enum CodingKeys: String, CodingKey {
case id, title, commentEnabled
}
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
}
}
// {"id": 12345, "title": "My First Video"}
// Video(id: 12345, title: "My First Video", commentEnabled: false)
问题在于,可能会有其他类型也有类似的需求。就算预先设置了模板,但去为每个类型添加这么一坨 CodingKeys
和 init(from:)
,想象一下就觉得是很恶心的事情。就算在这里我们为 commentEnabled
这个 Bool
值添加了默认的解析,对于其他类型中类似需求的 Bool
属性,还是需要再来一次。我们有没有更好的方法来对应“给 Decodable 的属性添加默认值”这件事情呢?
更大的陷阱:自定义类型的可选值
在开始尝试解决问题之前,先来看一个更致命的情况。对于上面的 Bool?
,最多只是让我们的代码麻烦一些,还不至于出现大问题。但是如果我们希望的是对一个更复杂的类型进行解码,情况就会迅速恶化。考虑下面的代码:
1
2
3
4
5
6
7
8
9
10
struct Video: Decodable {
enum State: String, Decodable {
case streaming // 正在直播
case archived // 已完成
}
// ...
let state: State
}
这里添加了 Video.State
,我们将它声明为 String
enum,且满足 Decodable
。对于这样的 enum 类型,我们不需要额外进行实现,编译器就会帮助我们补完解码代码,这会把 "streaming"
和 "archived"
分别解码到对应的 case 中去:
1
2
3
4
5
6
7
8
{"id": 12345, "title": "My First Video", "state": "archived"}
// Video(
// id: 12345,
// title: "My First Video",
// commentEnabled: nil,
// state: Video.State.archived
// )
看起来很美好,但是考虑一下,如果将来服务器新增了一个状态,比如 up 主“提前预约”了一次直播时,服务器将返回 "reserved"
。毫无疑问,我们上面的代码无法将 "reserved"
解析为 State
中的任何一个值,于是整个 Video
类型的解析就都挂掉了:
1
2
3
4
{"id": 12345, "title": "My First Video", "state": "reserved"}
// error: Swift.DecodingError.dataCorrupted
// Cannot initialize State from invalid String value reserved
根据你想要实现的效果,在 client app 中,这可能是一个非常严重的问题。更麻烦的是,即使你将 state
声明为可选值的 State?
,依然还是解决不了这个情况:可选值的解码所表达的是“如果不存在,则置为 nil”,而不是“如果解码失败,则置为 nil”。所以下面的改变不会对问题有任何帮助:
1
2
3
4
5
6
7
8
struct Video: Decodable {
// ...
let state: State?
}
// {"id": 12345, "title": "My First Video", "state": "reserved"}
// error: Swift.DecodingError.dataCorrupted
// Cannot initialize State from invalid String value reserved
只要 “state” key 在 JSON 中存在,那么解码就会发生;只要等待解码的数据无法初始化一个 State
,那么整个值的解码就会失败。
当然,我们可以参照上面处理 Bool
的方法,在 Video
中把 state
声明为 String
, 然后用一个 getter 设定默认值,比如:
1
2
3
4
5
6
7
8
9
10
enum State: String, Decodable {
case streaming
case archived
case unknown
}
private let state: String
var resolvedState: State {
State(rawValue: state) ?? .unknown
}
或者直接为 Video
重写 init(from:)
。不过无论怎么做,都不是很理想。
有没有更好的方式来处理上面这两个问题呢?答案是使用 property wrapper。
Default property wrapper 的设计
最正确且通用的解决方式当然是为每个需要默认值的类型重写 init(from:)
。不过 Codable 系统最便利的地方就在于可以自动为满足 Codable 的类型和每个属性生成代码。我们例子中的矛盾在于,编译器为某个类型 (比如 Bool
或者 Video.State
) 生成的解码代码不能满足要求。想要对某个 property “做手脚”,property wrapper 当然是首选。
如果你对 property wrapper 还不熟悉,可以先把它理解成一组特别的 getter 和 setter:它提供一个特殊的盒子,把原来值的类型包装进去。被 property wrapper 声明的属性,实际上在存储时的类型是 property wrapper 这个“盒子”的类型,只不过编译器施了一些魔法,让它对外暴露的类型依然是被包装的原来的类型。
已经有很多关于 property wrapper 使用的详细解释了:官方文档 或者 NSHipster 上都有很优秀的阅读资料。
首次尝试
对 Bool
或者 Video.State
来说,设置 Default property wrapper 最理想的情况,是类似下面这样的声明方式:
1
2
3
4
5
@Default(value: true)
var commentEnabled: Bool
@Default(value: .unknown)
var state: State
这需要 Default
这个 property wrapper 具有这样的声明:
1
2
3
4
5
6
7
8
@propertyWrapper
struct Default<T: Decodable> {
let value: T
var wrappedValue: T {
get { fatalError("未实现") }
}
}
wrappedValue
的 getter 我们还没想好要怎么写,所以先 fatalError
留空。为了能让 Default
被直接解码,让它满足 Decodable
:
1
2
extension Default: Decodable {
}
很“幸运”,因为泛型类型 T
也满足了 Decodable
,所以我们不需要任何实现就可以让 Default
满足 Decodable
了。但这真的是我们想要的东西吗?
实际上,Default
property wrapper 修饰的变量的类型,就是一个具体的 Default
类型:
1
@Default(value: true) var commentEnabled: Bool
commentEnabled
真正的类型并不是 Bool
,而是 Default<Bool>
。而 Default<Bool>
中只有 let value: Bool
这一个存储属性。所以它所规定的默认解码方式是寻找 "value"
这个 key 对应的布尔值。也就是说,在这个情况下,我们所期望的 JSON 形式其实是:
1
2
3
4
5
6
7
{
"id": 12345,
"title": "My First Video",
"commentEnabled": {
"value": true
}
}
很显然,这不是我们想要的东西。我们需要从一个 singleValueContainer
中去解码单个值,而不是将它作为 object 的一部分。所以需要实现自定义的用来解码的 init
:
1
2
3
4
5
6
7
8
9
10
11
12
// 错误代码
extension Default: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let v = (try? container.decode(T.self)) ?? value
// 在 init 方法中,value 还不可用,怎么办??
self.wrappedValue = v
// 如何把解码后的值交给 wrappedValue??
}
}
在这种情况下,产生了矛盾:我们不能用 Default
的 init(value:)
来为 property wrapper 指定一个默认值。这么做将导致我们无法从 decoder 中利用这个默认值进行解码。
此路不通,需要另寻他法:我们需要一种不涉及具体的值,而是通过类型系统来传递值的方式。
使用类型约束传值
SwiftUI 中有很多使用类型来传递值的例子,在我的前一篇文章中,也介绍了这种方式的另外一个用例。既然不能使用实例属性,那么我们不妨通过定义和类型绑定的 static 属性来设置默认值。
首先添加一个 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
9
10
11
12
13
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: Default<Swift.Bool>(wrappedValue: false)
// )
虽然我们解码得到的是一个 Default<Bool>
的值,但是在使用时,property wrapper 是完全透明的。
1
2
3
if video.commentEnabled {
// 在这里显示 comment UI
}
可能你已经注意到了,在这样的
Video
类型中,我们所使用的commentEnabled
只是一个Bool
类型的计算属性。在背后,编译器为我们生成了_commentEnabled
这个存储属性。也就是说,如果我们手动为Video
加一个_commentEnabled
的话,会导致编译错误。虽然很多其他语言有这样的习惯,但在 Swift 中,并不建议使用下杠
_
作为变量的首字母。这可以帮助我们避免与编译器自动生成的代码产生冲突。
我们已经可以解码 "commentEnabled": 123
这类的意外输入了,但是现在,当 JSON 中 "commentEnabled"
key 缺失时,解码依然会发生错误。这是因为我们所使用的解码器默认生成的代码是要求 key 存在的。想要改变这一行为,我们可以为 container 重写对于 Default
类型解码的实现:
1
2
3
4
5
6
7
8
extension KeyedDecodingContainer {
func decode<T>(
_ type: Default<T>.Type,
forKey key: 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
3
{"id": 12345, "title": "My First Video"}
// Video(id: 12345, title: "My First Video", commentEnabled: false)
相比对于每个类型编写单独的默认值解码代码,这套方式具有很好的扩展性。比如,如果想要为 Video.State
也添加默认行为,只需要让它满足 DefaultValue
即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
extension Video.State: DefaultValue {
static let defaultValue = Video.State.unknown
}
struct Video: Decodable {
// ...
@Default<State> var state: State
}
// {"id": 12345, "title": "My First Video", "state": "reserved"}
// Video(
// id: 12345,
// title: "My First Video",
// _commentEnabled: Default<Swift.Bool>(wrappedValue: false),
// _state: Default<Video.State>(wrappedValue: Video.State.unknown)
// )
整理 Default 类型
上面的方法还存在一个问题:像 Default<Bool>
这样的修饰,只能将默认值解码到 false
。但有时候针对不同情况,我们需要设置不同的默认值。
DefaultValue
协议其实并没有对类型作出太多规定:只要所提供的默认值 defaultValue
满足 Decodable
协议就行。因此,我们可以让别的类型,甚至是新创建的类型,满足 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
针对 Video.State
,也可以做同样的整理,就留作给各位读者的练习啦!本文完整的示例代码可以在这里找到。
关于 API 设计的一点补充说明
虽然本文着重于 Codable 的小技巧,而非整体的 API 设计,但在例子中我们使用了 Video.State
这个 enum 来表示视频的状态,这其实是不太妥当的。
处理类似这种状态时,很多 server 会返回特定的字符串,比如 "streaming"
、"archived"
。看起来这很像一个“状态枚举”的行为,而且 Swift 中的 enum 实在是很好用,所以大家可能会偏向于直接用 enum 来表征。但如果客户端和服务器之间没有协定未来的情况的话,十分有可能出现像例子中 "reserved"
这样的新值追加,进而导致问题
相比于 enum,其实这里用一个带有 raw value 的 struct 来表示会更好:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Video: Decodable {
struct State: RawRepresentable, Decodable {
static let streaming = State(rawValue: "streaming")
static let archived = State(rawValue: "archived")
let rawValue: String
}
// ...
let state: State
}
// {"id": 12345, "title": "My First Video", "state": "archived"}
// Video(
// id: 12345,
// title: "My First Video",
// state: Video.State(rawValue: "archived"))
print(value.state == .archived) // true
print(value.state == .streaming) // false
这样一来,就算今后为 state
添加了新的字符串,现有的实现也不会被破坏。相比起原来的 enum Video.State
,这个设计更加稳定。
和它很类似的,而且经常被用错的例子还有 HTTP method。不少地方把它设计成了类似这样的枚举:
1
2
3
4
5
6
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
确实,这几个 method 几乎能覆盖所有 (“增删查改”) 的情况,但是 HTTP 标准中还定义了很多其他 method,比如 HEAD
或者 OPTIONS
也不算鲜见。而且只要服务端和客户端协商好,method 甚至是可以随意扩展的。在这种情况下,其实 enum 是不太理想的。类似上面的例子,使用 RawRepresentable
的 struct,则可以提供更好的扩展性。