要让 Swift Codable 模型兼容较旧的内存表示形式,应将确实可能缺失的值设为可选,并明确实现解码回退逻辑。仅添加声明默认值,并不能确保缺失的编码字段可以成功解码。
本指南适用于正在更新简单桌面布局模型的 Mac 开发者。具体任务是让修订后的模型能够接受较旧的合成表示形式,同时保留已提供的值,并报告不兼容的类型。每个示例都在内存中创建并使用值。
字段变为可选或获得默认值后,会有哪些变化?
Codable 将编码和解码结合起来。如果存储属性支持所需协议,Swift 可以为其合成这些实现。这样可以省去常规代码,但即使代码仍能编译,变更也可能改变模型能够接受的表示形式。Apple 在编码和解码自定义类型中介绍了自动符合协议和自定义实现。
以一个包含必需标题和副标题的布局图块为例。现在你希望副标题可以为空,并希望新增的列数默认设为三。这是两个不同的决定:副标题可以没有值,而解码完成后,列数必须始终有一个可用值。
对于参与合成解码的普通可变属性,Swift 会将可选属性视为允许键缺失或值为 null。声明时的初始化值并不是解码回退值:以非 nil 值初始化的可选属性仍可能被解码为 nil,而带有声明默认值的非可选属性在键缺失时仍可能解码失败。Swift 团队在对 Codable 合成的说明中阐述了这一有意设计的行为。
下表将这些规则转化为字段策略。表格假设字段为整数,并且存在的值可以转换为 Int。最后两行需要自定义解码。
| 字段策略 | 键缺失 | 显式 null | 有效整数 | 不兼容的类型 |
|---|---|---|---|---|
合成解码 var columns: Int |
错误 | 错误 | 使用该值 | 错误 |
合成解码 var columns: Int = 3 |
错误 | 错误 | 使用该值 | 错误 |
合成解码 var columns: Int? |
nil |
nil |
使用该值 | 错误 |
| 对缺失和 null 使用自定义回退 | 3 |
3 |
使用该值 | 错误 |
| 仅对缺失使用自定义回退 | 3 |
错误 | 使用该值 | 错误 |
区别来自容器方法。decode(_:forKey:)要求条目存在,且值非 null 并可转换。decodeIfPresent(_:forKey:)在键缺失或值为 null 时都会返回 nil,但遇到不兼容的类型仍会抛出错误。对后者的结果应用 ?? 3 即可提供回退值。
在内存中实现并检查兼容策略
在 Mac 上的 Swift 执行环境中运行此示例,确保可使用 Foundation,并且支持顶层语句,例如现有的 playground。请一次运行整个代码块。Foundation 的 JSONEncoder会生成 Data 表示形式,而 JSONDecoder会据此创建新的类型化值。此示例不使用字面编码文档或外部输入。
采用的策略明确如下:title仍为必需项;若 subtitle 缺失或为 null,则变为 nil;若 columns 缺失或为 null,则设为三。若列数提供了不兼容的值,仍会报错。自定义初始化器负责解码,而编码仍由模型基于其 CodingKeys 合成。Apple 在编码和解码自定义类型中介绍了这种自定义方式。
以下是完整程序,包含合成测试数据和检查项。研究环境中没有 Swift,因此我们对照文档中的 API 检查了示例,但未实际运行。
import Foundation
struct LegacyTile: Codable {
var title: String
var subtitle: String
}
struct Tile: Codable, Equatable {
static let defaultColumns = 3
var title: String
var subtitle: String?
var columns: Int = Tile.defaultColumns
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
}
extension Tile {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
title = try values.decode(String.self, forKey: .title)
subtitle = try values.decodeIfPresent(String.self, forKey: .subtitle)
columns = try values.decodeIfPresent(Int.self, forKey: .columns)
?? Self.defaultColumns
}
}
struct MinimalTile: Encodable {
var title: String
}
struct NullTile: Encodable {
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode("Circle", forKey: .title)
try values.encodeNil(forKey: .subtitle)
try values.encodeNil(forKey: .columns)
}
}
struct WrongColumnsTile: Encodable {
var title: String
var columns: String
}
let encoder = JSONEncoder()
let decoder = JSONDecoder()
// An older representation keeps its subtitle and gains the fallback.
let legacyBytes = try encoder.encode(
LegacyTile(title: "Circle", subtitle: "Blue")
)
let upgraded = try decoder.decode(Tile.self, from: legacyBytes)
precondition(upgraded.title == "Circle")
precondition(upgraded.subtitle == "Blue")
precondition(upgraded.columns == 3)
// Missing keys and explicit nulls follow the chosen policy.
let minimalBytes = try encoder.encode(MinimalTile(title: "Circle"))
let minimal = try decoder.decode(Tile.self, from: minimalBytes)
precondition(minimal.subtitle == nil)
precondition(minimal.columns == 3)
let nullBytes = try encoder.encode(NullTile())
let explicitNull = try decoder.decode(Tile.self, from: nullBytes)
precondition(explicitNull == minimal)
// A supplied nondefault value survives an encode/decode cycle.
let supplied = Tile(title: "Square", subtitle: "Green", columns: 5)
let suppliedBytes = try encoder.encode(supplied)
let restored = try decoder.decode(Tile.self, from: suppliedBytes)
precondition(restored == supplied)
// Incompatible types must remain visible as decoding failures.
let wrongBytes = try encoder.encode(
WrongColumnsTile(title: "Circle", columns: "wide")
)
do {
_ = try decoder.decode(Tile.self, from: wrongBytes)
preconditionFailure("Expected a type mismatch")
} catch DecodingError.typeMismatch(_, let context) {
precondition(context.codingPath.last?.stringValue == "columns")
}
// A newer representation may no longer satisfy an older reader.
let currentBytes = try encoder.encode(minimal)
do {
_ = try decoder.decode(LegacyTile.self, from: currentBytes)
preconditionFailure("Expected the required subtitle to be missing")
} catch DecodingError.keyNotFound(let key, _) {
precondition(key.stringValue == "subtitle")
}
print("All in-memory compatibility checks passed.")
预期的最终输出是 All in-memory compatibility checks passed.。意外错误会继续向上传播,不会被转换成成功结果。测试数据有意覆盖不同的表示形式:MinimalTile 会省略字段,而 NullTile 使用 encodeNil(forKey:)创建显式 null 条目。
将 init(from:)保留在扩展中,也能让结构体的成员初始化器继续可用,因此示例可以构造 Tile(title:subtitle:columns:)。自定义解码初始化器会为每个存储的实例属性赋值,因此返回前结果已完成初始化。
null 应使用默认值,还是仍然报错?
请根据字段的含义来选择。对于这个示例布局,将缺失和 null 都视为标准列数是合理的策略。但这仍是应用层面的决定:decodeIfPresent有意将这两种状态合并,因此回退值会让它们得到相同结果。其文档所述的返回行为明确说明了这一后果。
如果缺失表示“使用标准布局”,但提供 null 时应当失败,请将程序中 init(from:)内的 columns赋值语句替换为以下代码片段。其他前提和定义都保持不变。
// Replacement fragment inside Tile.init(from:).
if values.contains(.columns) {
columns = try values.decode(Int.self, forKey: .columns)
} else {
columns = Self.defaultColumns
}
contains(_:)会报告条目是否存在,包括值为 null 的条目。因此,显式 null 会进入 decode分支并产生 valueNotFound;只有缺失时才会使用默认值。采用修订后的策略后,原程序对 null 测试数据的检查必须改为预期该错误,而不是与 minimal相等。
除非确实想丢弃所有解码失败,否则不要将赋值改为 (try? values.decode(Int.self, forKey: .columns)) ?? 3。这种表达式也会在提供了不兼容值时以三替代。使用 try decodeIfPresent可以保留“接受字段缺失”和“无法解释该表示形式”之间的区别,正如该解码方法的错误约定所规定的那样。
兼容性是否双向有效?
请分别检查每个读取方。程序展示了修订后的读取方可以接受旧表示形式,也展示了一个边界情况:旧读取方仍要求 subtitle,因此会拒绝省略该字段的新表示形式。将 Swift 新类型中的属性改为可选,不会改变旧类型的要求。
合成编码会通过条件编码省略值为 nil 的可选项。Swift 的 encodeIfPresent(_:forKey:)文档说明,它只编码非 nil 值。也可以用 encodeNil编码显式 null,但这仍无法满足旧的非可选 String 属性对值的要求。
如果旧读取方必须继续可用,请定义一个满足其要求的显式表示形式。在这个合成示例中,适配器可以在编码前构造一个带有选定副标题的 LegacyTile。缺失的副标题是否应转换为某个特定字符串,是另一个产品决策;不要在通用回退逻辑中默默赋予它这种含义。
还要区分值的保留与表示形式的保留。在此示例中,省略副标题和显式 null 最终都会变成相同的 Swift 值 nil。因此,模型无法还原这两种输入中的哪一种。类似地,将缺失的列数解码为三后再重新编码模型,会写入具体的列数。这些都是所选模型和解码策略直接导致的结果。
更改模型前应验证什么?
每当字段发生变化,都应使用小型兼容性矩阵。先从旧表示形式开始,再逐次只改变一个字段。这样可以避免成功的往返编码掩盖只有键缺失时才会出现的错误。
- **旧输入:**确认修订后的读取方可以接受该输入,并保留现有值。
- **字段缺失:**确认结果是预期的可选值、回退值或错误。
- **显式 null:**将其与字段缺失分开测试。
- **提供的值:**使用非默认值,证明解码不会覆盖它。
- **不兼容的类型:**确认没有转换策略时仍会抛出错误。
- **旧读取方:**检查修订后的编码表示形式是否仍满足其要求。
不要仅为了获得回退值而从 CodingKeys 中移除字段。Apple 说明,被省略的属性不会纳入编码和解码表示形式,而且自动解码符合协议时需要默认值。这适用于有意将属性排除在表示形式之外的情况;同时也意味着该属性提供的值不会被读取。请参阅 Apple 关于编码键的指南。
将兼容规则放在字段的解码逻辑旁边:对有意义的缺失情况使用可选类型,对接受的缺失情况使用显式回退,对必需值使用常规的抛错解码。这样,每次模型变更时,都可以通过内存中的测试数据审查预期行为。
来源
- 编码和解码自定义类型 — Apple
- JSONEncoder — Apple
- JSONDecoder — Apple
- JSON 中缺失可选值的解码 — Swift Forums
- decodeIfPresent(_:forKey:) — Apple
- decode(_:forKey:) — Apple
- contains(_:) — Apple
- encodeNil(forKey:) — Apple
- encodeIfPresent(_:forKey:) — Apple