mcpField
C
codable-patterns

codable-patterns

github.com/dpearson2699开发1.2k252 次安装13 周前更新
代码仓库

关于 codable-patterns

面向 Swift 开发者的 Codable 模式指南,介绍使用 JSONEncoder 和 JSONDecoder 在类型与数据之间编码和解码。涵盖自定义结构、容错数组、日期策略,以及与常见存储和网络 API 的配合。

主要功能

  1. 01合成及自定义 Codable 实现
  2. 02映射嵌套与异构 JSON
  3. 03配置日期、数据和键策略
  4. 04跳过数组中的无效元素
  5. 05与 URLSession、SwiftData 和 UserDefaults 配合

适用场景

  1. 01解码具有不同日期格式的 API 响应
  2. 02将蛇形命名 JSON 映射到 Swift 属性
  3. 03读取含有无效条目的数据数组
  4. 04在 SwiftData 或 UserDefaults 中存储 Codable 值

安装

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

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill codable-patterns

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

详细介绍

Codable 模式

使用 Codable(Encodable & Decodable)以及 JSONEncoder、JSONDecoder 和相关 API 编码与解码 Swift 类型。目标平台为 Swift 6.2 / iOS 26+。

目录

基本一致性

当所有存储属性本身都遵循 Codable 时,编译器会自动合成一致性:

struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let isVerified: Bool
}

let user = try JSONDecoder().decode(User.self, from: jsonData)
let encoded = try JSONEncoder().encode(user)

对于只读 API 响应,优先使用 Decodable;对于只写数据,优先使用 Encodable。 只有在需要双向处理时才使用 Codable。

自定义 CodingKeys

声明 CodingKeys 枚举即可重命名 JSON 键,无需编写自定义解码器:

struct Product: Codable {
    let id: Int
    let displayName: String
    let imageURL: URL
    let priceInCents: Int

    enum CodingKeys: String, CodingKey {
        case id
        case displayName = "display_name"
        case imageURL = "image_url"
        case priceInCents = "price_in_cents"
    }
}

每个存储属性都必须出现在枚举中。从 CodingKeys 中省略属性会将其排除在编码和解码之外——请为其提供默认值,或单独计算该属性。

自定义解码与编码

对于合成的一致性无法处理的转换,可重写 init(from:) 和 encode(to:):

struct Event: Codable {
    let name: String
    let timestamp: Date
    let tags: [String]

    enum CodingKeys: String, CodingKey {
        case name, timestamp, tags
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        // Decode Unix timestamp as Double, convert to Date
        let epoch = try container.decode(Double.self, forKey: .timestamp)
        timestamp = Date(timeIntervalSince1970: epoch)
        // Default to empty array when key is missing
        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(timestamp.timeIntervalSince1970, forKey: .timestamp)
        try container.encode(tags, forKey: .tags)
    }
}

嵌套与扁平化容器

使用 nestedContainer(keyedBy:forKey:) 遍历并扁平化嵌套 JSON:

// JSON: { "id": 1, "location": { "lat": 37.7749, "lng": -122.4194 } }
struct Place: Decodable {
    let id: Int
    let latitude: Double
    let longitude: Double

    enum CodingKeys: String, CodingKey { case id, location }
    enum LocationKeys: String, CodingKey { case lat, lng }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        let location = try container.nestedContainer(
            keyedBy: LocationKeys.self, forKey: .location)
        latitude = try location.decode(Double.self, forKey: .lat)
        longitude = try location.decode(Double.self, forKey: .lng)
    }
}

串联多次 nestedContainer 调用,可扁平化深层嵌套结构。 嵌套数组也可以使用 nestedUnkeyedContainer(forKey:)。

异构数组

使用判别字段解码包含混合类型的数组:

// JSON: [{"type":"text","content":"Hello"},{"type":"image","url":"pic.jpg"}]
enum ContentBlock: Decodable {
    case text(String)
    case image(URL)

    enum CodingKeys: String, CodingKey { case type, content, url }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(String.self, forKey: .type)
        switch type {
        case "text":
            let content = try container.decode(String.self, forKey: .content)
            self = .text(content)
        case "image":
            let url = try container.decode(URL.self, forKey: .url)
            self = .image(url)
        default:
            throw DecodingError.dataCorruptedError(
                forKey: .type, in: container,
                debugDescription: "Unknown type: \(type)")
        }
    }
}

let blocks = try JSONDecoder().decode([ContentBlock].self, from: jsonData)

日期解码策略

配置 JSONDecoder.dateDecodingStrategy,使其与 API 相匹配:

let decoder = JSONDecoder()

// ISO 8601 (e.g., "2024-03-15T10:30:00Z")
decoder.dateDecodingStrategy = .iso8601

// Unix timestamp in seconds (e.g., 1710499800)
decoder.dateDecodingStrategy = .secondsSince1970

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

// Custom closure for multiple formats
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let string = try container.decode(String.self)
    if let date = ISO8601DateFormatter().date(from: string) { return date }
    throw DecodingError.dataCorruptedError(
        in: container, debugDescription: "Cannot decode date: \(string)")
}

在 JSONEncoder 上设置匹配的策略: encoder.dateEncodingStrategy = .iso8601

Data 与键策略

let decoder = JSONDecoder()
decoder.dataDecodingStrategy = .base64           // Base64-encoded Data fields
decoder.keyDecodingStrategy = .convertFromSnakeCase  // snake_case -> camelCase
// {"user_name": "Alice"} maps to `var userName: String` -- no CodingKeys needed

let encoder = JSONEncoder()
encoder.dataEncodingStrategy = .base64
encoder.keyEncodingStrategy = .convertToSnakeCase

有损数组解码

默认情况下,一个无效元素就会导致整个数组解码失败。使用包装类型跳过无效元素:

struct LossyArray<Element: Decodable>: Decodable {
    let elements: [Element]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var elements: [Element] = []
        while !container.isAtEnd {
            if let element = try? container.decode(Element.self) {
                elements.append(element)
            } else {
                _ = try? container.decode(AnyCodableValue.self) // advance past bad element
            }
        }
        self.elements = elements
    }
}
private struct AnyCodableValue: Decodable {}

单值容器

使用 singleValueContainer() 包装基本类型,以确保类型安全:

struct UserID: Codable, Hashable {
    let rawValue: String

    init(_ rawValue: String) { self.rawValue = rawValue }

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

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(rawValue)
    }
}
// JSON: "usr_abc123" decodes directly to UserID

缺失键的默认值

使用 decodeIfPresent 配合 nil 合并运算符提供默认值:

struct Settings: Decodable {
    let theme: String
    let fontSize: Int
    let notificationsEnabled: Bool

    enum CodingKeys: String, CodingKey {
        case theme, fontSize = "font_size"
        case notificationsEnabled = "notifications_enabled"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        theme = try container.decodeIfPresent(String.self, forKey: .theme) ?? "system"
        fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 16
        notificationsEnabled = try container.decodeIfPresent(
            Bool.self, forKey: .notificationsEnabled) ?? true
    }
}

编码器与解码器配置

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]

// Non-conforming floats (NaN, Infinity are not valid JSON)
encoder.nonConformingFloatEncodingStrategy = .convertToString(
    positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN")
decoder.nonConformingFloatDecodingStrategy = .convertFromString(
    positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN")
PropertyListEncoder / PropertyListDecoder
let plistEncoder = PropertyListEncoder()
plistEncoder.outputFormat = .xml  // or .binary
let data = try plistEncoder.encode(settings)
let decoded = try PropertyListDecoder().decode(Settings.self, from: data)

Codable 与 URLSession

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let http = response as? HTTPURLResponse,
          (200...299).contains(http.statusCode) else {
        throw APIError.invalidResponse
    }
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    decoder.dateDecodingStrategy = .iso8601
    return try decoder.decode(User.self, from: data)
}

// Generic API envelope for wrapped responses
struct APIResponse<T: Decodable>: Decodable {
    let data: T
    let meta: Meta?
    struct Meta: Decodable { let page: Int; let totalPages: Int }
}
let users = try decoder.decode(APIResponse<[User]>.self, from: data).data

Codable 与 SwiftData

Codable 结构体可作为 SwiftData 模型中的复合属性。在 iOS 18+ 中, SwiftData 原生支持此类属性,无需显式使用 @Attribute(.transformable):

struct Address: Codable {
    var street: String
    var city: String
    var zipCode: String
}

@Model class Contact {
    var name: String
    var address: Address?  // Codable struct stored as composite attribute
    init(name: String, address: Address? = nil) {
        self.name = name; self.address = address
    }
}

Codable 与 UserDefaults

通过 RawRepresentable 存储 Codable 值,以供 @AppStorage 使用:

struct UserPreferences: Codable {
    var showOnboarding: Bool = true
    var accentColor: String = "blue"
}

extension UserPreferences: RawRepresentable {
    init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let decoded = try? JSONDecoder().decode(Self.self, from: data)
        else { return nil }
        self = decoded
    }
    var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let string = String(data: data, encoding: .utf8)
        else { return "{}" }
        return string
    }
}

struct SettingsView: View {
    @AppStorage("userPrefs") private var prefs = UserPreferences()
    var body: some View {
        Toggle("Show Onboarding", isOn: $prefs.showOnboarding)
    }
}

常见错误

1. 未处理缺失的可选键:

// DON'T -- crashes if key is absent
let value = try container.decode(String.self, forKey: .bio)
// DO -- returns nil for missing keys
let value = try container.decodeIfPresent(String.self, forKey: .bio) ?? ""

2. 一个元素无效就导致整个数组解码失败:

// DON'T -- one bad element kills the whole decode
let items = try container.decode([Item].self, forKey: .items)
// DO -- use LossyArray or decode elements individually
let items = try container.decode(LossyArray<Item>.self, forKey: .items).elements

3. 日期策略不匹配:

// DON'T -- default strategy expects Double, but API sends ISO string
let decoder = JSONDecoder()  // dateDecodingStrategy defaults to .deferredToDate
// DO -- set strategy to match your API format
decoder.dateDecodingStrategy = .iso8601

4. 对解码后的可选值强制解包:

// DON'T
let user = try? decoder.decode(User.self, from: data)
print(user!.name)
// DO
guard let user = try? decoder.decode(User.self, from: data) else { return }

5. 只需要 Decodable 时却使用 Codable:

// DON'T -- unnecessarily constrains the type to also be Encodable
struct APIResponse: Codable { let id: Int; let message: String }
// DO -- use Decodable for read-only API responses
struct APIResponse: Decodable { let id: Int; let message: String }

6. 为简单的 snake_case API 手动编写 CodingKeys:

// DON'T -- verbose boilerplate for every model
enum CodingKeys: String, CodingKey {
    case userName = "user_name"
    case avatarUrl = "avatar_url"
}
// DO -- configure once on the decoder
decoder.keyDecodingStrategy = .convertFromSnakeCase

检查清单

  • 仅在不需要编码时,类型才符合 Decodable
  • 对可选键或缺失键使用带默认值的 decodeIfPresent
  • 对简单的 snake_case API,使用 keyDecodingStrategy = .convertFromSnakeCase,而不是手动编写 CodingKeys
  • dateDecodingStrategy 与 API 的日期格式匹配
  • 对包含不可靠数据的数组使用有损解码,以跳过无效元素
  • 自定义 init(from:) 用于验证和转换数据,而不是在解码后修正数据
  • JSONEncoder.outputFormatting 包含 .sortedKeys,以确保测试输出具有确定性
  • 包装类型(UserID 等)使用 singleValueContainer,以生成简洁的 JSON
  • 使用通用的 APIResponse<T> 包装类型,以统一处理 API 信封
  • 不对解码后的值使用强制解包
  • @AppStorage Codable 类型符合 RawRepresentable
  • SwiftData 复合属性使用 Codable 结构体

参考资料

评论

支持 Markdown