Swift 与 Objective-C

[Swift 中级 #6] Swift Codable 深入解析,四大陷阱处方

从 Codable 自动合成究竟生成什么,到实际开发中必然遇到的键名不一致、日期格式、嵌套结构、部分失败四种情况的标准处理方案,以及 DecodingError 调试,本文一并整理。

7 分钟阅读
[Swift 中级 #6] Swift Codable 深入解析,四大陷阱处方 封面图

Codable 初看像魔法。给 struct 加上: Codable五个字母,JSON 转换就免费获得了。但接入真实 API 后,魔法就消失了:服务器使用 snake_case,而 Swift 使用 camelCase;日期格式因 API 而异;列表中只要有一项异常,整个解码就会失败,页面也会完全空白。

中级系列第 6 篇深入介绍 Codable:从自动合成实际生成的内容,到实际开发中必然遇到的四种情况(键名、日期、嵌套、部分失败)的标准处理方案。

魔法的真相 — 编译器替你编写的代码

Codable 是Encodable & Decodable的 typealias,分别要求类型具备“如何写入编码器”和“如何从解码器创建自身”的协议。看起来像魔法,其实来自编译器的自动合成(synthesis)。只要所有存储属性都符合 Codable,编译器就会替你生成两项内容。

第一是 CodingKeys enum:使用属性名作为 case 的键列表。第二是通过这些键逐一读写属性的init(from:)encode(to:)实现。也就是说,Codable 自定义就是决定手动替换这两个产物中的哪一个。只有键名不同,就写 CodingKeys;结构本身不同,则连 init(from:) 也要实现。掌握这个框架后,后面的内容都可以归结为“要手写到什么程度”。

还有一个重要事实:Codable 并非 JSON 专用。由于编码器和解码器可以替换,你可以用 PropertyListEncoder 处理 plist,也可以通过第三方库处理 XML 或 YAML。类型只知道“如何表示自己”,格式由编码器决定,这就是关注点分离。

情况 1. 键名不同 — CodingKeys 与键策略

如果服务器提供user_name,但你希望属性名为 userName,处理方案分为两步。

如果全局规则一致,只需设置解码器的一行配置:decoder.keyDecodingStrategy = .convertFromSnakeCase。所有键都会从 snake_case 自动转换为 camelCase。如果整个 API 都遵循该约定,这就是正确答案。

如果规则不一致,或者想直接重命名,就手动编写 CodingKeys。

struct User: Codable {
    let userName: String
    let signupDate: Date

    enum CodingKeys: String, CodingKey {
        case userName = "user_nm"     // 服务器的旧键名
        case signupDate = "created"
    }
}

还要记住一个副作用:从 CodingKeys 中移除某个 case,就会将该属性排除在编码和解码之外。需要在响应模型中放入缓存标记等本地专用状态时,可以使用这一技巧;但被排除的属性必须有默认值。

标注 snake case、日期、嵌套、部分失败四个区域的 JSON 管道示意图
实际开发中 Codable 的四大陷阱:键名、日期、嵌套、部分失败

情况 2. 日期 — 格式的雷区

Date 是 Codable 实际开发中最常见的陷阱。JSON 没有统一的日期标准,因此服务器可能分别使用 Unix 时间戳(1720000000)、ISO 8601(“2026-07-15T09:30:00Z”)或自定义格式(“2026-07-15 09:30”)。

处理方案是让解码器的 dateDecodingStrategy 匹配服务器格式:.secondsSince1970.iso8601,自定义格式则使用.formatted(formatter)。使用自定义 DateFormatter 有两个陷阱。如果不将 locale 固定为en_US_POSIX,解析可能因用户的 12/24 小时设置而失败。另外,服务器发送带毫秒的 ISO 8601 时,默认的.iso8601会失败;在 ISO8601DateFormatter 中开启毫秒选项即可解决。“只有部分用户日期解析失败”这类神秘 bug,大多就是由这两个陷阱造成的。

如果同一个 API 中各字段的格式都不同,实际做法是只将该字段作为 String 接收,再通过计算属性转换,或者使用.custom策略进行分支处理。

情况 3. 嵌套与结构不一致 — 服务器的形状 vs 我的形状

这是服务器 JSON 被多层包装的情况,例如{"data": {"user": {...}}}。最简单的方案是原样创建包装类型:使用struct Envelope: Codable { let data: DataBox }镜像服务器结构,并在调用处通过envelope.data.user取出内容。它明确且容易调试,因此更适合作为实际开发中的默认方案。

如果希望模型形状不同于服务器,例如使用扁平的 User,就手动实现 init(from:),通过 nestedContainer 深入层级。代码会增加,但模型会更干净、更以领域为中心。判断标准是模型的使用范围:整个应用都使用的核心模型值得手写 init(from:);只用于一个页面的响应,镜像包装结构更经济。

情况 4. 部分失败 — 因为一项数据导致全部失败

这是实际开发中最痛苦的情况。100 个商品的列表中,只要一个必填字段为 null,整个数组解码就会抛错,页面也会变空。正如错误处理篇所述,抛出的错误会继续传播。

第一道防线是可选类型。服务器可能遗漏的字段,应诚实地声明为let thumbnail: URL?。将“可能不存在”记录在类型中的可选类型原则,同样适用于模型设计。

结构上的防御是容错包装器。创建一个将元素解码失败转换为 nil 的泛型包装器,是常见的标准模式。

struct FailableItem<T: Decodable>: Decodable {
    let value: T?
    init(from decoder: Decoder) throws {
        value = try? T(from: decoder)   // 失败时变为 nil nil
    }
}

let items = try decoder.decode([FailableItem<Product>].self, from: data)
    .compactMap(\.value)   // 只保留成功的项目

这就是 try? 和 compactMap 这两个前文工具的组合。它用一个类型表达“丢弃一项坏数据,保留其余数据”的策略。但要注意,这种模式会静默吞掉失败。因此在生产环境中要记录失败数量,避免服务器数据问题被掩盖。

只将一项坏数据筛入 nil 容器、让其余数据通过的分拣设备插图
丢弃一项坏数据并保留其余数据,但要记录被丢弃的内容

调试 — DecodingError 已经知道答案

最后介绍解码失败时的排查方法。catch try decoder.decode(...)可以捕获 DecodingError,而这个错误比想象中更有帮助。四种情况(keyNotFound、typeMismatch、valueNotFound、dataCorrupted)都会包含哪个键出错、位于哪条 codingPath、期待什么以及实际收到什么。

解码失败时,要养成检查print(error)而不是print(error as? DecodingError)的习惯;至少在开发期间,应在 catch 块中按情况打印 codingPath。这能大幅缩短调试时间。“JSON 无法解析”的大多数问题,答案其实已经写在错误里。

总结

  • Codable 的魔法就是编译器合成。它替你编写 CodingKeys enum 和 init(from:)/encode(to:);自定义的关键只是决定手动替换其中多少内容。
  • 键名:规则一致的 snake_case 使用一行 keyDecodingStrategy;不规则时手动声明 CodingKeys。移除 case 会将该字段排除在转换之外。
  • 日期:明确指定匹配服务器格式的 dateDecodingStrategy,并注意 en_US_POSIX locale 与带毫秒 ISO 8601 的陷阱。
  • 嵌套:默认镜像包装结构;只有核心模型才用 init(from:) + nestedContainer 展平。
  • 部分失败:诚实声明可选类型 + FailableItem 模式(try? + compactMap),并通过日志让被吞掉的失败可见。
  • 调试:答案就在 DecodingError 的 codingPath 中。

下一篇介绍 @State、@Published 等 at 符号语法的原理,以及属性包装器。我们将亲手实现属性篇预告的“包装存储属性的语法”。

延伸阅读