mcpField
A
axiom-codable

axiom-codable

github.com/charleswiltgen开发1.2k173 次安装今天更新
代码仓库

关于 axiom-codable

面向 Swift 6.x 开发者的 Codable 参考技能,介绍 JSON 与 PropertyList 的编码和解码。内容涵盖自动合成、键定制、手动转换、日期处理及错误诊断。

主要功能

  1. 01Codable 自动合成与枚举
  2. 02CodingKeys 键定制
  3. 03容器与桥接类型转换
  4. 04日期解码策略
  5. 05解码错误诊断

适用场景

  1. 01映射不同命名的 JSON 键
  2. 02解码嵌套或结构不匹配的数据
  3. 03处理 API 日期与类型变化
  4. 04排查 Codable 解码失败

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-codable

安装后在 Claude Code 中输入/axiom-codable确认已加载。

详细介绍

Swift Codable 模式

Swift 6.x 中 JSON 和 PropertyList 编码/解码的 Codable 协议符合性完整指南。

快速参考

决策树:何时使用各方法
Has your type...
├─ All properties Codable? → Automatic synthesis (just add `: Codable`)
├─ Property names differ from JSON keys? → CodingKeys customization
├─ Needs to exclude properties? → CodingKeys customization
├─ Enum with associated values? → Check enum synthesis patterns
├─ Needs structural transformation? → Manual implementation + bridge types
├─ Needs data not in JSON? → DecodableWithConfiguration (iOS 15+)
└─ Complex nested JSON? → Manual implementation + nested containers
常见触发条件
错误解决方案
"Type 'X' does not conform to protocol 'Decodable'"确保所有存储属性都符合 Codable
"No value associated with key X"检查 CodingKeys 是否与 JSON 键匹配
"Expected to decode X but found Y instead"类型不匹配;检查 JSON 结构或使用桥接类型
"keyNotFound"JSON 缺少预期的键;将属性设为可选或提供默认值
"Date parsing failed"在解码器上配置 dateDecodingStrategy

第 1 部分:自动合成

当所有存储属性都符合 Codable 时,Swift 会自动合成 Codable 符合性。

结构体合成
// ✅ Automatic synthesis
struct User: Codable {
    let id: UUID              // Codable
    var name: String          // Codable
    var membershipPoints: Int // Codable
}

// JSON: {"id":"...", "name":"Alice", "membershipPoints":100}

要求:

  • 所有存储属性都必须符合 Codable
  • 属性使用标准 Swift 类型或其他 Codable 类型
  • 无需自定义初始化逻辑
枚举合成模式
模式 1:原始值枚举
enum Direction: String, Codable {
    case north, south, east, west
}

// Encodes as: "north"

原始值本身会成为 JSON 表示形式。

模式 2:不带关联值的枚举
enum Status: Codable {
    case success
    case failure
    case pending
}

// Encodes as: {"success":{}}

每个枚举项都会成为一个对象,其键为枚举项名称,值为空字典。

模式 3:带关联值的枚举
enum APIResult: Codable {
    case success(data: String, count: Int)
    case error(code: Int, message: String)
}

// success case encodes as:
// {"success":{"data":"example","count":5}}

注意事项:未标记的关联值会生成 _0、_1 键:

enum Command: Codable {
    case store(String, Int)  // ❌ Unlabeled
}

// Encodes as: {"store":{"_0":"value","_1":42}}

修复方法:始终为关联值添加标签,以确保 JSON 结构可预测:

enum Command: Codable {
    case store(key: String, value: Int)  // ✅ Labeled
}

// Encodes as: {"store":{"key":"value","value":42}}
合成失效的情况

以下情况会导致自动合成失败:

  1. 计算属性 - 只会编码存储属性
  2. 不符合 Codable 的属性 - 未符合 Codable 的自定义类型
  3. 属性包装器 - @Published、@State(符合 Codable 的类型搭配 @AppStorage 时除外)
  4. 类继承 - 子类必须手动实现 init(from:)

第 2 部分:自定义 CodingKeys

使用 CodingKeys 枚举自定义编码/解码,无需完整的手动实现。

重命名键
struct Article: Codable {
    let url: URL
    let title: String
    let body: String

    enum CodingKeys: String, CodingKey {
        case url = "source_link"      // JSON uses "source_link"
        case title = "content_name"   // JSON uses "content_name"
        case body                     // Matches JSON key
    }
}

// JSON: {"source_link":"...", "content_name":"...", "body":"..."}
排除属性

从 CodingKeys 中省略属性,即可将其排除在编码/解码之外:

struct NoteCollection: Codable {
    let name: String
    let notes: [Note]
    var localDrafts: [Note] = []  // ✅ Must have default value

    enum CodingKeys: CodingKey {
        case name
        case notes
        // localDrafts omitted - not encoded/decoded
    }
}

规则:被排除的属性必须有默认值,否则必须手动实现 init(from:)。

蛇形命名转换

如需一致地将 snake_case 转换为 camelCase:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

// JSON: {"first_name":"Alice", "last_name":"Smith"}
// Decodes to: User(firstName: "Alice", lastName: "Smith")
枚举关联值键

使用 {CaseName}CodingKeys 自定义枚举关联值的键:

enum Command: Codable {
    case store(key: String, value: Int)
    case delete(key: String)

    enum StoreCodingKeys: String, CodingKey {
        case key = "identifier"  // Renames "key" to "identifier"
        case value = "data"      // Renames "value" to "data"
    }

    enum DeleteCodingKeys: String, CodingKey {
        case key = "identifier"
    }
}

// store case encodes as: {"store":{"identifier":"x","data":42}}

模式:{CaseName}CodingKeys 中的枚举项名称首字母大写。


第 3 部分:手动实现

当 JSON 与 Swift 模型的结构不同时,实现 init(from:) 和 encode(to:)。

容器类型
容器适用场景
Keyed带字符串键的类字典数据
Unkeyed类似数组的顺序数据
Single-value编码为单个值的包装类型
Nested分层 JSON 结构
嵌套容器示例

展平分层 JSON:

// JSON:
// {
//   "latitude": 37.7749,
//   "longitude": -122.4194,
//   "additionalInfo": {
//     "elevation": 52
//   }
// }

struct Coordinate {
    var latitude: Double
    var longitude: Double
    var elevation: Double  // Nested in JSON, flat in Swift

    enum CodingKeys: String, CodingKey {
        case latitude, longitude, additionalInfo
    }

    enum AdditionalInfoKeys: String, CodingKey {
        case elevation
    }
}

extension Coordinate: Decodable {
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        latitude = try values.decode(Double.self, forKey: .latitude)
        longitude = try values.decode(Double.self, forKey: .longitude)

        let additionalInfo = try values.nestedContainer(
            keyedBy: AdditionalInfoKeys.self,
            forKey: .additionalInfo
        )
        elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
    }
}

extension Coordinate: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(latitude, forKey: .latitude)
        try container.encode(longitude, forKey: .longitude)

        var additionalInfo = container.nestedContainer(
            keyedBy: AdditionalInfoKeys.self,
            forKey: .additionalInfo
        )
        try additionalInfo.encode(elevation, forKey: .elevation)
    }
}
用于结构不匹配的桥接类型

当 JSON 结构与 Swift 模型存在根本差异时:

// JSON: {"USD": 1.0, "EUR": 0.85, "GBP": 0.73}
// Want: [ExchangeRate]

struct ExchangeRate {
    let currency: String
    let rate: Double
}

// Bridge type for decoding
private extension ExchangeRate {
    struct List: Decodable {
        let values: [ExchangeRate]

        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            let dictionary = try container.decode([String: Double].self)
            values = dictionary.map { ExchangeRate(currency: $0, rate: $1) }
        }
    }
}

// Public interface
extension ExchangeRate {
    static func decode(from data: Data) throws -> [ExchangeRate] {
        let list = try JSONDecoder().decode(List.self, from: data)
        return list.values
    }
}

第 4 部分:日期处理

内置策略
let decoder = JSONDecoder()

// 1. ISO 8601 (recommended)
decoder.dateDecodingStrategy = .iso8601
// Expects: "2024-02-15T17:00:00+01:00"

// 2. Unix timestamp (seconds)
decoder.dateDecodingStrategy = .secondsSince1970
// Expects: 1708012800

// 3. Unix timestamp (milliseconds)
decoder.dateDecodingStrategy = .millisecondsSince1970
// Expects: 1708012800000

// 4. Custom formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")  // ✅ Always set
formatter.timeZone = TimeZone(secondsFromGMT: 0)      // ✅ Always set
decoder.dateDecodingStrategy = .formatted(formatter)

// 5. Custom closure
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)

    if let date = ISO8601DateFormatter().date(from: dateString) {
        return date
    }

    throw DecodingError.dataCorruptedError(
        in: container,
        debugDescription: "Cannot decode date string \(dateString)"
    )
}
ISO 8601 的细节

默认格式:2024-02-15T17:00:00+01:00 必须包含时区:如果没有时区偏移,跨区域解码可能会失败

// ❌ No timezone - parsing depends on device locale
"2024-02-15T17:00:00"

// ✅ With timezone - unambiguous
"2024-02-15T17:00:00+01:00"
性能注意事项

自定义闭包会为每个日期运行 - 优化开销较大的操作:

// ❌ Creates new formatter for every date
decoder.dateDecodingStrategy = .custom { decoder in
    let formatter = DateFormatter()  // Expensive!
    // ...
}

// ✅ Reuse formatter
let sharedFormatter = DateFormatter()
sharedFormatter.dateFormat = "yyyy-MM-dd"

decoder.dateDecodingStrategy = .custom { decoder in
    // Use sharedFormatter
}

第 5 部分:类型转换

StringBacked 包装器

处理将数字编码为字符串的 API:

protocol StringRepresentable: CustomStringConvertible {
    init?(_ string: String)
}

extension Int: StringRepresentable {}
extension Double: StringRepresentable {}

struct StringBacked<Value: StringRepresentable>: Codable {
    var value: Value

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)

        guard let value = Value(string) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Cannot convert '\(string)' to \(Value.self)"
            )
        }

        self.value = value
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(value.description)
    }
}

// Usage
struct Product: Codable {
    let name: String
    private let _price: StringBacked<Double>

    var price: Double {
        get { _price.value }
        set { _price = StringBacked(value: newValue) }
    }

    enum CodingKeys: String, CodingKey {
        case name
        case _price = "price"
    }
}

// JSON: {"name":"Widget","price":"19.99"}
// Decodes to: Product(name: "Widget", price: 19.99)
类型强制转换

适用于可能返回不同类型的弱类型 API:

struct FlexibleValue: Codable {
    let stringValue: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        if let string = try? container.decode(String.self) {
            stringValue = string
        } else if let int = try? container.decode(Int.self) {
            stringValue = String(int)
        } else if let double = try? container.decode(Double.self) {
            stringValue = String(double)
        } else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Cannot decode value to String, Int, or Double"
            )
        }
    }
}

警告:除非 API 的返回类型确实无法预测,否则请避免使用此模式。优先使用严格类型。


第 6 部分:高级模式

DecodableWithConfiguration(iOS 15+)

适用于需要使用 JSON 中没有的数据的类型:

struct User: Encodable, DecodableWithConfiguration {
    let id: UUID
    var name: String
    var favorites: Favorites  // Not in JSON, injected via configuration

    enum CodingKeys: CodingKey {
        case id, name
    }

    init(from decoder: Decoder, configuration: Favorites) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(UUID.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        favorites = configuration  // Injected
    }
}

// Usage (iOS 17+)
let favorites = try await fetchFavorites()
let user = try JSONDecoder().decode(
    User.self,
    from: data,
    configuration: favorites
)
userInfo 变通方案(iOS 15-16)
extension JSONDecoder {
    private struct ConfigurationDecodingWrapper<T: DecodableWithConfiguration>: Decodable {
        var wrapped: T

        init(from decoder: Decoder) throws {
            let config = decoder.userInfo[configurationUserInfoKey] as! T.DecodingConfiguration
            wrapped = try T(from: decoder, configuration: config)
        }
    }

    func decode<T: DecodableWithConfiguration>(
        _ type: T.Type,
        from data: Data,
        configuration: T.DecodingConfiguration
    ) throws -> T {
        let decoder = JSONDecoder()
        decoder.userInfo[Self.configurationUserInfoKey] = configuration
        let wrapper = try decoder.decode(ConfigurationDecodingWrapper<T>.self, from: data)
        return wrapper.wrapped
    }
}

private let configurationUserInfoKey = CodingUserInfoKey(rawValue: "configuration")!
部分解码

仅解码所需字段:

struct ArticlePreview: Decodable {
    let id: UUID
    let title: String
    // Omit body, comments, etc.
}

// JSON has many more fields, but we only decode id and title

第 7 部分:调试

DecodingError 情况
do {
    let user = try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
    print("Missing key '\(key)' at path: \(context.codingPath)")
} catch DecodingError.typeMismatch(let type, let context) {
    print("Type mismatch for \(type) at path: \(context.codingPath)")
} catch DecodingError.valueNotFound(let type, let context) {
    print("Value not found for \(type) at path: \(context.codingPath)")
} catch DecodingError.dataCorrupted(let context) {
    print("Data corrupted at path: \(context.codingPath)")
} catch {
    print("Other error: \(error)")
}
调试技巧

1. 美化打印 JSON

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(user)
print(String(data: jsonData, encoding: .utf8)!)

2. 检查编码路径

// In custom init(from:)
print("Decoding at path: \(decoder.codingPath)")

3. 验证 JSON 结构

// Quick check: Can it decode as Any?
let json = try JSONSerialization.jsonObject(with: data)
print(json)  // See actual structure

反模式

反模式代价更好的做法
手动构建 JSON 字符串注入漏洞、转义错误、没有类型安全使用 JSONEncoder
用 try? 吞掉 DecodingError静默失败、调试噩梦、数据丢失处理特定错误情况
为了避免解码错误而使用可选属性运行时崩溃、到处都要检查 nil、掩盖结构问题修复 JSON/模型不匹配问题,或使用 DecodableWithConfiguration
重复定义部分模型每次变更需花费 2-5 小时维护、同步问题、脆弱使用桥接类型或配置
忽略日期时区不同地区出现间歇性错误、数据损坏始终使用带时区的 ISO8601,或显式指定 UTC
对 Codable 类型使用 JSONSerialization多出 3 倍样板代码、手动类型转换、容易出错使用 JSONDecoder/JSONEncoder
DateFormatter 未设置区域设置在非美国区域设置下解析失败设置 locale = Locale(identifier: "en_US_POSIX")
为什么 try? 很危险
// ❌ Silent failure - production bug waiting to happen
let user = try? JSONDecoder().decode(User.self, from: data)
// If this fails, user is nil - why? No idea.

// ✅ Explicit error handling
do {
    let user = try JSONDecoder().decode(User.self, from: data)
} catch {
    logger.error("Failed to decode user: \(error)")
    // Now you know WHY it failed
}

压力场景

场景 1:“直接用 try? 让它编译通过”

背景:API 集成截止日期是明天,解码器在某个边缘情况下失败。

压力:“我们之后再调试,现在先让它能用。”

你可能会找的借口:

  • “只有 1% 的请求会失败”
  • “之后可以再加日志”
  • “客户不会注意到”

实际会发生的情况:

  • 那 1% 的数据会悄无声息地丢失
  • 没有日志,所以无法在生产环境中调试
  • 3 个月后才收到客户投诉
  • 到那时你已经忘了当时的背景

应对之道:

“在这里使用 try? 意味着我们会悄无声息地丢失数据。让我花 5 分钟处理这个特定错误情况。如果它确实很少发生,我会记录日志,以便我们修复根本原因。”

5 分钟修复方案:

do {
    return try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
    logger.error("Missing key '\(key)' in API response", metadata: [
        "path": .string(context.codingPath.description),
        "rawJSON": .string(String(data: data, encoding: .utf8) ?? "")
    ])
    throw APIError.invalidResponse(reason: "Missing key: \(key)")
} catch {
    logger.error("Failed to decode User", error: error)
    throw APIError.decodingFailed(error)
}

结果:你发现 API 有时会省略已删除用户的 email 字段。修复方法:只在这种情况下将 email 设为可选,而不是对所有用户都如此。


场景 2:“日期解析偶尔失败,肯定是服务器的问题”

背景:在你的时区日期解析正常,但欧洲 QA 团队解析失败。

压力:“我这里运行正常,肯定是 QA 做错了什么。”

你可能会找的借口:

  • “我的测试在本地都能通过”
  • “服务器可能发送了错误数据”
  • “是他们的设备设置有问题”

实际会发生的情况:

  • 服务器发送的日期没有时区:"2024-12-14T10:00:00"
  • 你的设备(PST)将其解析为 10:00 PST
  • QA 设备(CET)将其解析为 10:00 CET
  • 绝对时间不同,导致间歇性错误

应对之道:

“间歇性的日期解析失败几乎总是时区问题。让我检查一下我们是否在使用带时区偏移的 ISO8601。”

检查:

// ❌ Current (fails across timezones)
decoder.dateDecodingStrategy = .iso8601

// Server sends: "2024-12-14T10:00:00" (no timezone)
// PST device: Dec 14, 10:00 PST
// CET device: Dec 14, 10:00 CET
// Bug: Different times!

// ✅ Fix: Require server to send timezone
// "2024-12-14T10:00:00+00:00"
// OR: Explicitly parse as UTC
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)

    let formatter = ISO8601DateFormatter()
    formatter.timeZone = TimeZone(secondsFromGMT: 0)  // Force UTC

    guard let date = formatter.date(from: dateString) else {
        throw DecodingError.dataCorruptedError(
            in: container,
            debugDescription: "Invalid ISO8601 date: \(dateString)"
        )
    }

    return date
}

结果:问题修复,服务器为 API 添加时区(或者你显式地按 UTC 解析)。不再出现间歇性失败。

场景 3:“让它变成可选项就行”

背景:新增的 API 字段导致解码失败。产品经理要求 1 小时内修复。

压力:“你不能直接把这个字段设为可选吗?我们得赶紧发布。”

你会为自己找借口:

  • “这比修复 API 快”
  • “之后可以再把它改回必选”
  • “用户不会注意到”

实际会发生的事:

  • 这个字段实际上是该功能必需的
  • 你到处添加 user.email ?? ""
  • 3 个月后:由于 email 为 nil,生产环境发生崩溃
  • 现在你已经想不起来当初为什么把它设为可选了

严谨应对:

“把它设为可选会掩盖真正的问题。我来确认是 API 有误,还是我们的模型有误。这只需要 10 分钟。”

调查:

// Step 1: Print raw JSON
do {
    let json = try JSONSerialization.jsonObject(with: data)
    print(json)
} catch {
    print("Invalid JSON: \(error)")
}

// Step 2: Check if key exists but value is null
// {"email": null} vs key missing entirely

// Step 3: Check API docs - is email actually required?

常见结果:

  1. API 有误:字段本应存在 → 提交缺陷报告,获取热修复
  2. 模型有误:某些流程中该字段是可选的 → 按正确的可选性处理,并编写清晰的文档
  3. 结构不匹配:字段嵌套在其他字段中 → 使用嵌套容器

结果:你发现,在新版 API 中,email 嵌套在 user.contact.email 中。使用嵌套容器修复,而不是将其设为可选。

// ✅ Correct fix
struct User: Decodable {
    let id: UUID
    let email: String  // Still required

    enum CodingKeys: CodingKey {
        case id, contact
    }

    enum ContactKeys: CodingKey {
        case email
    }

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

        let contact = try container.nestedContainer(
            keyedBy: ContactKeys.self,
            forKey: .contact
        )
        email = try contact.decode(String.self, forKey: .email)
    }
}

相关技能

  • swift-concurrency — 跨越 Actor 边界的 Codable 类型必须是 Sendable
  • swiftdata — @Model 类型使用 Codable 进行 CloudKit 同步
  • networking — Coder 协议为 Network.framework 封装 Codable
  • app-intents-ref — AppEnum 参数使用 Codable 序列化

关键要点

  1. 优先使用自动合成 — 结构与 JSON 匹配时添加 : Codable
  2. 简单的不匹配使用 CodingKeys — 无需手动编写代码即可重命名或排除字段
  3. 结构存在差异时手动实现 — 使用嵌套容器、桥接类型
  4. 始终设置区域设置和时区 — DateFormatter 需要 en_US_POSIX 和显式时区
  5. 绝不要用 try? 吞掉错误 — 明确处理 DecodingError 情况
  6. Codable + Sendable — 值类型(struct/enum)非常适合异步网络通信

核心原则:Codable 是 Swift 通用的序列化协议。掌握一次,处处可用。

评论

支持 Markdown