我们决定在项目中使用 JSON 来储存每一个章节的信息,于是就出现了一个需求,JSON 里面的数组里可能会存在多种 Object,要举个栗子的话,就是一台战舰上可能会同时搭载了一些 Mobile Suits 和 Mobile Armor,而且它们还是放在一块儿的,却又有着不同类型的属性。

既然我们在用 Swift,就很自然的想用 Enum 来表示它们:

1
2
3
4
5
6
7
8
9
10
enum Unit: Codable {            // {
case mobileArmor( // "type": "mobile-armor",
numberOfPilots: Int // "number-of-pilots": 2
) // },
// {
case mobileSuit( // "type": "mobile-suit"
numberOfLegs: Int, // "number-of-leg": 2,
isGundam: Bool // "is-gundam": true
) // }
}

对于 Mobile Armor,它可能需要好几个驾驶员负责四肢和头部,但对于 Mobile Suit,上头则更在乎它们有多少只脚,以及是不是高达有没有光环。当我们直接加上 Codable,Compiler 就报错了,显然我们需要手动做一些什么奇怪的事情才行。

易知,Compiler 没有恰当的手段给我们自动生成 CodingKey,对于普通的 Enum,我们可以指定 Enum 的 raw value 的类型来解决问题,但带有 associated values 的 Enum 则不允许我们这么做,所以我们得手动添加 CodingKey

1
2
3
4
5
private enum CodingKeys: String, CodingKey {
case type
case numberOfLegs = "number-of-legs", isGundam = "is-gundam"
case numberOfPilots = "number-of-pilots"
}

然后我们还需要实现 init(from decoder: Decoder)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)

switch type {
case "mobile-suit":
let numberOfLegs = try values.decode(Int.self, forKey: .numberOfLegs)
let isGundam = try values.decode(Bool.self, forKey: .isGundam)
self = .mobileSuit(numberOfLegs: numberOfLegs, isGundam: isGundam)
case "mobile-armor":
let numberOfPilots = try values.decode(Int.self, forKey: .numberOfPilots)
self = .mobileArmor(numberOfPilots: numberOfPilots)
default: throw UnitError.decoding("error")
}
}

这样把不同 case 的 coding keys 都放在一起是比较危险的,即便是新人类或者调整者,也不能保证不会手残,在生成 MS 资料的时候去读取了一个不存在的、属于 MA 的 key。所以要是想再 type safe 一些的话,可以把不同 case 的 coding keys 放在不同的 Enum 里面,防止敲错 key:

1
2
3
4
5
6
7
8
9
10
11
12
private enum MobileArmorCodingKeys: String, CodingKey {
case numberOfPilots = "number-of-pilots"
}

init(from decoder: Decoder) throws {
...
case "mobile-armor":
let values = try decoder.container(keyedBy: MobileArmorCodingKeys.self)
let numberOfPilots = try values.decode(Int.self, forKey: .numberOfPilots)
self = .mobileArmor(numberOfPilots: numberOfPilots)
...
}

Encode 部分也是同理:

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
28
29
30
31
32
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .mobileSuit(numberOfLegs, isGundam):
try container.encode("mobile-suit", forKey: .type)
var values = encoder.container(keyedBy: MobileSuitCodingKeys.self)
try values.encode(isGundam, forKey: .isGundam)
try values.encode(numberOfLegs, forKey: .numberOfLegs)
case .mobileArmor(let numberOfPilots):...
}
}

let json ="""
[
{
"type": "mobile-suit",
"is-gundam": true,
"number-of-legs": 4
},
{
"type": "mobile-armor",
"number-of-pilots": 2
}
]
"""

let decoder = JSONDecoder()
let result = try! decoder.decode([Unit].self, from: json.data(using: .utf8)!)
// [Unit.mobileSuit(numberOfLegs: 4, isGundam: true), Unit.mobileArmor(2)]
let encoder = JSONEncoder()
let data = try! encoder.encode(result)
// [{"type":"mobile-suit","number-of-legs":4,"is-gundam":true},{"type":"mobile-armor","number-of-pilots":2}]

Update

后来我在别的项目中又遇到了这个需求,并决定用 Sourcery 给我自动生成这些代码:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

{% for enum in types.implementing.AutoCodableEnumWithAssociatedValue|enum %}
{% if enum.hasAssociatedValues %}
// MARK: {{ enum.name }} Codable
extension {{ enum.name }}: Codable {
private enum CodingKeys: String, CodingKey {
case type
}

{% for case in enum.cases %}
{% if case.hasAssociatedValue %}
private enum {{case.name | upperFirstLetter}}CodingKeys: String, CodingKey {
{% for value in case.associatedValues %}
case {{ value.localName }} = "{{ value.localName | camelToSnakeCase }}"
{% endfor %}
}
{% endif %}
{% endfor %}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)

switch type {
{% for case in enum.cases %}
{% if not case.hasAssociatedValue %}
case "{{case.name | camelToSnakeCase}}": self = .{{case.name}}
{% endif %}
{% if case.hasAssociatedValue %}
case "{{case.name | camelToSnakeCase}}":
let values = try decoder.container(keyedBy: {{case.name | upperFirstLetter}}CodingKeys.self)
{% for value in case.associatedValues %}
let {{value.localName}} = try values.decode({{value.typeName}}.self, forKey: .{{value.localName}})
{% endfor %}
self = .{{case.name}}(
{% for value in case.associatedValues %}
{{value.localName}}: {{value.localName}}{% if not forloop.last %},{% endif %}
{% endfor %}
)
{% endif %}
{% endfor %}
default: throw EncodingError.invalidValue(type, .init(codingPath: [CodingKeys.type], debugDescription: "\(type) is not found in cases of {{enum.name}}"))
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
{% for case in enum.cases %}
{% if not case.hasAssociatedValue %}
case .{{case.name}}: try container.encode("{{case.name | camelToSnakeCase}}", forKey: .type)
{% endif %}
{% if case.hasAssociatedValue %}
case let .{{case.name}}({% for value in case.associatedValues %}{{value.localName}}{% if not forloop.last %}, {% endif %}{% endfor %}):
try container.encode("{{case.name | camelToSnakeCase}}", forKey: .type)
var values = encoder.container(keyedBy: {{case.name | upperFirstLetter}}CodingKeys.self)
{% for value in case.associatedValues %}
try values.encode({{value.localName}}, forKey: .{{value.localName}})
{% endfor %}
{% endif %}
{% endfor %}
}
}
}
{% endif %}
{% endfor %}