要让 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 关于编码键的指南。

将兼容规则放在字段的解码逻辑旁边:对有意义的缺失情况使用可选类型,对接受的缺失情况使用显式回退,对必需值使用常规的抛错解码。这样,每次模型变更时,都可以通过内存中的测试数据审查预期行为。

来源