阅读视图

发现新文章,点击刷新页面。

Codable 宏让 Swift 序列化如此简单!

大家好!作为 Swift 开发者,我们每天都在与数据打交道,而 JSON 与模型之间的转换无疑是家常便饭。苹果为我们提供了 Codable 协议,它在很多情况下表现出色,但随着业务逻辑变得复杂,我们常常会陷入编写大量样板代码的泥潭:手动定义 CodingKeys、实现 init(from:)encode(to:)、处理嵌套结构、应对不同的命名风格、解析各种日期格式…… 这些繁琐的任务不仅耗时,还容易出错。

有没有更优雅、更高效的方式来处理 Swift 中的 Codable 呢?

答案是肯定的!随着 Swift 5.9+ 引入的 Swift Macros,代码生成的可能性被大大扩展。今天,我向大家介绍一款基于 Swift Macros 构建的框架 —— ReerCodable

ReerCodable (github.com/reers/ReerC…) 旨在通过声明式的注解,彻底简化 Codable 的使用体验,让你告别繁琐的样板代码,专注于业务逻辑本身。

实际应用示例

让我们通过一个实际的例子来看看 ReerCodable 如何简化开发工作。假设我们有一个复杂的 API 响应:

{
  "code": 0,
  "data": {
    "user_info": {
      "user_name": "phoenix",
      "birth_date": "1990-01-01T00:00:00Z",
      "location": {
        "city": "北京",
        "country": "中国"
      },
      "height_in_meters": 1.85,
      "is_vip": true,
      "tags": ["tech", null, "swift"],
      "last_login": 1731585275944
    }
  }
}

使用 ReerCodable,我们可以这样定义模型:

@Codable
struct ApiResponse {
    var code: Int
    
    @CodingKey("data.user_info")
    var userInfo: UserInfo
}

@Codable
@SnakeCase
struct UserInfo {
    var userName: String
    
    @DateCoding(.iso8601)
    var birthDate: Date
    
    @CodingKey("location.city")
    var city: String
    
    @CodingKey("location.country")
    var country: String
    
    @CustomCoding<Double>(
        decode: { return try $0.value(forKeys: "height_in_meters") * 100.0 },
        encode: { try $0.set($1 / 100.0, forKey: "height_in_meters") }
    )
    var heightInCentimeters: Double
    
    var isVip: Bool
    
    @CompactDecoding
    var tags: [String]
    
    @DateCoding(.millisecondsSince1970)
    var lastLogin: Date
}

// 使用方式
do {
    let response = try ApiResponse.decode(from: jsonString)
    print("用户名: (response.userInfo.userName)")
    print("出生日期: (response.userInfo.birthDate)")
    print("身高(厘米): (response.userInfo.heightInCentimeters)")
} catch {
    print("解析失败: (error)")
}

原生 Codable 的那些“痛”

在我们深入了解 ReerCodable 的魅力之前,先来回顾一下使用原生 Codable 时可能遇到的常见痛点:

  1. 手动 CodingKeys: 当 JSON key 与属性名不一致时,需要手动编写 CodingKeys 枚举,只修改一个, 其他所有属性都要写, 属性少还好,一旦多了,简直是噩梦。
  2. 嵌套 Key: 处理深层嵌套的 JSON 数据,需要定义多个中间结构体或手动编写解码逻辑。
  3. 命名风格转换: 后端返回 snake_case 或 kebab-case,而 Swift 推荐 camelCase,需要手动映射。
  4. 复杂的解码逻辑: 如需自定义解码(类型转换、数据修复等),就得实现 init(from:)
  5. 默认值处理: 非 Optional 属性在 JSON 中缺失时,即使有默认值也会抛出 keyNotFound 异常。Optional 枚举缺失也会导致解码失败。
  6. 忽略属性: 某些属性不需要参与编解码,需要手动在 CodingKeys 或实现中处理。
  7. 日期格式多样: 时间戳、ISO8601、自定义格式…… 需要为 JSONDecoder 配置不同的 dateDecodingStrategy 或手动处理。
  8. 集合中的 null: 数组或字典中包含 null 值时,若对应类型非 Optional,会导致解码失败。
  9. 继承: 父类的属性无法在子类的 Codable 实现中自动处理。
  10. 枚举处理: 关联值枚举或需要匹配多种原始值的枚举,原生 Codable 支持有限。

社区现状

Swift 社区为了解决 JSON 序列化的问题,涌现出了许多优秀的第三方框架。了解它们的设计哲学和优缺点,能帮助我们更好地理解为什么基于 Swift Macros 的方案是当下更优的选择。

1. 基于自定义协议的框架

ObjectMapper

ObjectMapper 是最早的一批 Swift JSON 解析库之一,它基于自定义的 Mappable 协议:

class User: Mappable {
    var name: String?
    var age: Int?
    
    required init?(map: Map) {}
    
    func mapping(map: Map) {
        name <- map["user_name"]
        age <- map["user_age"]
    }
}

特点:

  • 不依赖 Swift 的原生 Codable
  • 不依赖反射机制
  • 自定义操作符 <- 使映射代码简洁
  • 需要手动编写映射关系
  • 支持嵌套映射和自定义转换

ObjectMapper 的优点是代码相对简洁,且不依赖 Swift 的内部实现细节,但缺点是需要手动编写映射代码,并且与 Swift 的原生序列化机制不兼容。

2. 基于运行时反射的框架

HandyJSON 和 KakaJSON

这两个框架采用了相似的实现原理,都是通过运行时反射获取类型信息:

struct User: HandyJSON {
    var name: String?
    var age: Int?
}

// 使用
let user = User.deserialize(from: jsonString)

特点:

  • 通过底层运行时反射获取类型元数据
  • 直接操作内存进行属性赋值
  • 几乎无需编写额外代码
  • 性能较高

这类框架的主要问题是强依赖 Swift 的内部实现细节和元数据结构(Metadata),随着 Swift 版本升级,容易出现不兼容问题或崩溃。它们实现了"零代码"的理想,但牺牲了稳定性和安全性。

3. 基于属性包装器(Property Wrapper)的框架

ExCodable 和 BetterCodable

这些框架利用 Swift 5.1 引入的属性包装器特性,为 Codable 提供扩展:

struct User: Codable {
    @CustomKey("user_name")
    var name: String
    
    @DefaultValue(33)
    var age: Int
}

特点:

  • 基于 Swift 的原生 Codable
  • 使用属性包装器简化常见编解码任务
  • 无需手动编写 CodingKeys 和 Codable 实现
  • 类型安全,编译时检查

属性包装器方案相比前两类有明显的优势:它既保持了与 Swift 原生 Codable 的兼容性,又简化了代码编写。但 PropertyWrapper 能力有限, 有些复杂的封装设计做不到.

4. 基于宏(Macro)的框架

CodableWrapper、CodableWrappers 和 MetaCodable,以及本文的 ReerCodable

这些框架利用 Swift 5.9 引入的宏特性,在编译时生成 Codable 的实现代码:

@Codable
struct User {
    @CodingKey("user_name")
    var name: String
    
    var age: Int = 33
}

特点:

  • 基于 Swift 的原生 Codable
  • 声明式语法,直观易懂
  • 高度灵活,支持复杂的编解码逻辑
  • 可以在类型级别应用宏

宏方案结合了所有前述方案的优点,同时避免了它们的缺点:它基于原生 Codable,保持类型安全;它支持声明式语法,代码简洁;它在编译时生成代码,没有运行时性能损失;它能够处理复杂场景,适应性强。

为什么 Macro 是最优雅的解决方案?

在所有这些框架中,基于宏的方案(如 ReerCodable)提供了最优雅的解决方案,原因如下:

  1. 与原生 Codable 无缝集成:生成的代码与手写的 Codable 实现完全相同,可以与其他使用 Codable 的 API 完美配合。对于现代三方框架如 Alamofire, GRDB 等都与 Codable 互相兼容..
  2. 声明式语法:通过注解方式声明序列化需求,代码简洁直观,意图明确。
  3. 类型安全:所有操作都在编译时进行类型检查,避免运行时错误。
  4. 高度灵活:可以处理各种复杂场景,如嵌套结构、自定义转换、条件编解码等。
  5. 维护性好:宏生成的代码是可预测的,而且不依赖于 Swift 的内部实现细节,随着 Swift 版本更新不会出现兼容性问题。
  6. 可调试性强:可以查看宏展开后的代码,便于理解和调试。
  7. 可扩展性:可以组合使用不同的宏,构建复杂的编解码逻辑。

ReerCodable 登场:化繁为简的魔法

ReerCodable 利用 Swift Macros 的强大能力,让你只需在类型或属性前添加简单的注解,就能自动生成高效、健壮的 Codable 实现。核心就是 @Codable 宏,它会与其他 ReerCodable 提供的宏协同工作,生成最终的编解码逻辑。框架接入支持 Cocoapods, SwiftPackageManager。

代码实现上参考了优秀的 winddpan/CodableWrapper、GottaGetSwifty/CodableWrappers 和 MetaCodable,相对它们 ReerCodable 有更丰富的 feature 或更简洁的使用。

让我们来看看 ReerCodable 如何优雅地解决上述痛点:

1. 自定义 CodingKey

通过 @CodingKey 可以为属性指定自定义 key,无需手动编写 CodingKeys 枚举:

ReerCodable Codable
@Codable
struct User {
    @CodingKey("user_name")
    var name: String
    
    @CodingKey("user_age")
    var age: Int
    
    var height: Double
}
struct User: Codable {
    var name: String
    var age: Int
    var height: Double
    
    enum CodingKeys: String, CodingKey {
        case name = "user_name"
        case age = "user_age"
        case height
    }
}

2. 嵌套 CodingKey

支持通过点语法表示嵌套的 key path:

@Codable
struct User {
    @CodingKey("other_info.weight")
    var weight: Double
    
    @CodingKey("location.city")
    var city: String
}

3. 多键解码

可以指定多个 key 用于解码,系统会按顺序尝试解码直到成功:

@Codable
struct User {
    @CodingKey("name", "username", "nick_name")
    var name: String
}

4. 命名转换

支持多种命名风格转换,可以应用在类型或单个属性上:

@Codable
@SnakeCase
struct Person {
    var firstName: String  // 从 "first_name" 解码, 或编码为 "first_name"
    
    @KebabCase
    var lastName: String   // 从 "last-name" 解码, 或编码为 "last-name"
}

5. 自定义编解码容器

使用 @CodingContainer 自定义编解码时的容器路径, 通常用于JSON嵌套较多, 但 model 声明 想直接 match 子层级结构:

ReerCodable JSON
@Codable
@CodingContainer("data.info")
struct UserInfo {
    var name: String
    var age: Int
}
{
    "code": 0,
    "data": {
        "info": {
            "name": "phoenix",
            "age": 33
        }
    }
}

6. 编码专用 key

可以为编码过程指定不同的键名, 由于 @CodingKey 可能有多个参数, 再加上可以使用 @SnakeCase, KebabCase 等, 解码可能使用多个 key, 那编码时会采用第一个 key, 也可以通过 @EncodingKey 来指定 key

@Codable
struct User {
    @CodingKey("user_name")      // 解码使用 "user_name", "name"
    @EncodingKey("name")         // 编码使用 "name"
    var name: String
}

7. 默认值支持

解码失败时可以使用默认值, 原生 Codable 针对非 Optional 属性, 会在没有解析到正确值是抛出异常, 即使已经设置了初始值, 或者即使是 Optional 类型的枚举

@Codable
struct User {
    var age: Int = 33
    var name: String = "phoenix"
    // 若 JSON 中不包含 gender, 原生 Codable 会抛出异常, ReerCodable 不会, 会设置其为 nil
    var gender: Gender?
}

enum Gender {
    case male, female
}

8. 忽略属性

使用 @CodingIgnored 在编解码过程中忽略特定属性. 在解码过程中对于非 Optional 属性要有一个默认值才能满足 Swift 初始化的要求, ReerCodable 对基本数据类型和集合类型会自动生成默认值, 如果是其他自定义类型, 则需用用户提供默认值.

@Codable
struct User {
    var name: String
    
    @CodingIgnored
    var ignore: Set<String>
}

9. Base64 编解码

自动处理 base64 字符串与 Data, [UInt8] 类型的转换:

@Codable
struct User {
    @Base64Coding
    var avatar: Data
    
    @Base64Coding
    var voice: [UInt8]
}

10. 集合类型解码优化

使用 @CompactDecoding 在解码数组时自动过滤 null 值, 与 compactMap 是相同的意思:

@Codable
struct User {
    @CompactDecoding
    var tags: [String]  // ["a", null, "b"] 将被解码为 ["a", "b"]
}

同时, DictionarySet 也支持使用 @CompactDecoding 来优化

11. 日期编解码

支持多种日期格式的编解码:

ReerCodable JSON
@Codable
class DateModel {
    @DateCoding(.timeIntervalSince2001)
    var date1: Date
    
    @DateCoding(.timeIntervalSince1970)
    var date2: Date
    
    @DateCoding(.secondsSince1970)
    var date3: Date
    
    @DateCoding(.millisecondsSince1970)
    var date4: Date
    
    @DateCoding(.iso8601)
    var date5: Date
    
    @DateCoding(.formatted(Self.formatter))
    var date6: Date
    
    static let formatter: DateFormatter = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS"
        dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
        return dateFormatter
    }()
}
{
    "date1": 1431585275,
    "date2": 1731585275.944,
    "date3": 1731585275,
    "date4": 1731585275944,
    "date5": "2024-12-10T00:00:00Z",
    "date6": "2024-12-10T00:00:00.000"
}

12. 自定义编解码逻辑

通过 @CustomCoding 实现自定义的编解码逻辑. 自定义编解码有两种方式:

  • 通过闭包, 以 decoder: Decoder, encoder: Encoder 为参数来实现自定义逻辑:
@Codable
struct User {
    @CustomCoding<Double>(
        decode: { return try $0.value(forKeys: "height_in_meters") * 100.0 },
        encode: { try $0.set($1 / 100.0, forKey: "height_in_meters") }
    )
    var heightInCentimeters: Double
}
  • 通过一个实现 CodingCustomizable 协议的自定义类型来实现自定义逻辑:
// 1st 2nd 3rd 4th 5th  -> 1 2 3 4 5
struct RankTransformer: CodingCustomizable {
    
    typealias Value = UInt
    
    static func decode(by decoder: any Decoder, keys: [String]) throws -> UInt {
        var temp: String = try decoder.value(forKeys: keys)
        temp.removeLast(2)
        return UInt(temp) ?? 0
    }
    
    static func encode(by encoder: Encoder, key: String, value: Value) throws {
        try encoder.set(value, forKey: key)
    }
}

@Codable
struct HundredMeterRace {
    @CustomCoding(RankTransformer.self)
    var rank: UInt
}

自定义实现过程中, 框架提供的方法也可以使编解码更加方便:

public extension Decoder {
    func value<Value: Decodable>(forKeys keys: String...) throws -> Value {
        let container = try container(keyedBy: AnyCodingKey.self)
        return try container.decode(type: Value.self, keys: keys)
    }
}

public extension Encoder {
    func set<Value: Encodable>(_ value: Value, forKey key: String, treatDotAsNested: Bool = true) throws {
        var container = container(keyedBy: AnyCodingKey.self)
        try container.encode(value: value, key: key, treatDotAsNested: treatDotAsNested)
    }
}

13. 继承支持

使用 @InheritedCodable 更好地支持子类的编解码. 原生 Codable 无法解析子类属性, 即使 JSON 中存在该值, 需要手动实现 init(from decoder: Decoder) throws

@Codable
class Animal {
    var name: String
}

@InheritedCodable
class Cat: Animal {
    var color: String
}

14. 枚举支持

为枚举提供丰富的编解码能力:

  • 对基本枚举类型, 以及 RawValue 枚举支持
@Codable
struct User {
    let gender: Gender
    let rawInt: RawInt
    let rawDouble: RawDouble
    let rawDouble2: RawDouble2
    let rawString: RawString
}

@Codable
enum Gender {
    case male, female
}

@Codable
enum RawInt: Int {
    case one = 1, two, three, other = 100
}

@Codable
enum RawDouble: Double {
    case one, two, three, other = 100.0
}

@Codable
enum RawDouble2: Double {
    case one = 1.1, two = 2.2, three = 3.3, other = 4.4
}

@Codable
enum RawString: String {
    case one, two, three, other = "helloworld"
}
  • 支持使用 CodingCase(match: ....) 来匹配多个值或 range
@Codable
enum Phone: Codable {
    @CodingCase(match: .bool(true), .int(10), .string("iphone"), .intRange(22...30))
    case iPhone
    
    @CodingCase(match: .int(12), .string("MI"), .string("xiaomi"), .doubleRange(50...60))
    case xiaomi
    
    @CodingCase(match: .bool(false), .string("oppo"), .stringRange("o"..."q"))
    case oppo
}
  • 对于有关联值的枚举, 支持通用 CaseValue 来匹配关联值, 使用 .label() 来声明有标签的关联值的匹配逻辑, 使用 .index() 来声明没有标签的的关联值的匹配逻辑. ReerCodable 支持两种JSON 格式的枚举匹配
    • 第一种是也是原生 Codable 支持的, 即枚举值和其关联值是父子级的结构:
    @Codable
    enum Video: Codable {
        /// {
        ///     "YOUTUBE": {
        ///         "id": "ujOc3a7Hav0",
        ///         "_1": 44.5
        ///     }
        /// }
        @CodingCase(match: .string("youtube"), .string("YOUTUBE"))
        case youTube
        
        /// {
        ///     "vimeo": {
        ///         "ID": "234961067",
        ///         "minutes": 999999
        ///     }
        /// }
        @CodingCase(
            match: .string("vimeo"),
            values: [.label("id", keys: "ID", "Id"), .index(2, keys: "minutes")]
        )
        case vimeo(id: String, duration: TimeInterval = 33, Int)
        
        /// {
        ///     "tiktok": {
        ///         "url": "https://example.com/video.mp4",
        ///         "tag": "Art"
        ///     }
        /// }
        @CodingCase(
            match: .string("tiktok"),
            values: [.label("url", keys: "url")]
        )
        case tiktok(url: URL, tag: String?)
    }
    
    • 第二种是枚举值和其关联值同级或自定义匹配的结构, 使用 .pathValue() 进行自定义路径值的匹配
    @Codable
    enum Video1: Codable {
        /// {
        ///     "type": {
        ///         "middle": "youtube"
        ///     }
        /// }
        @CodingCase(match: .pathValue("type.middle.youtube"))
        case youTube
        
        /// {
        ///     "type": "vimeo",
        ///     "ID": "234961067",
        ///     "minutes": 999999
        /// }
        @CodingCase(
            match: .pathValue("type.vimeo"),
            values: [.label("id", keys: "ID", "Id"), .index(2, keys: "minutes")]
        )
        case vimeo(id: String, duration: TimeInterval = 33, Int)
        
        /// {
        ///     "type": "tiktok",
        ///     "media": "https://example.com/video.mp4",
        ///     "tag": "Art"
        /// }
        @CodingCase(
            match: .pathValue("type.tiktok"),
            values: [.label("url", keys: "media")]
        )
        case tiktok(url: URL, tag: String?)
    }
    

15. 生命周期回调

支持编解码的生命周期回调:

@Codable
class User {
    var age: Int
    
    func didDecode(from decoder: any Decoder) throws {
        if age < 0 {
            throw ReerCodableError(text: "Invalid age")
        }
    }
    
    func willEncode(to encoder: any Encoder) throws {
        // 在编码前进行处理
    }
}

@Codable
struct Child: Equatable {
    var name: String
    
    mutating func didDecode(from decoder: any Decoder) throws {
        name = "reer"
    }
    
    func willEncode(to encoder: any Encoder) throws {
        print(name)
    }
}

16. JSON 扩展支持

提供便捷的 JSON 字符串和字典转换方法:

let jsonString = "{\"name\": \"Tom\"}"
let user = try User.decode(from: jsonString)

let dict: [String: Any] = ["name": "Tom"]
let user2 = try User.decode(from: dict)

17. 基本类型转换

支持基本数据类型之间的自动转换:

@Codable
struct User {
    @CodingKey("is_vip")
    var isVIP: Bool    // "1" 或 1 都可以被解码为 true
    
    @CodingKey("score")
    var score: Double  // "100" 或 100 都可以被解码为 100.0
}

18. AnyCodable 支持

通过 AnyCodable 实现对 Any 类型的编解码:

@Codable
struct Response {
    var data: AnyCodable  // 可以存储任意类型的数据
    var metadata: [String: AnyCodable]  // 相当于[String: Any]类型
}

19. 生成默认实例

@Codable
@DefaultInstance
struct ImageModel {
    var url: URL
}

@Codable
@DefaultInstance
struct User5 {
    let name: String
    var age: Int = 22
    var uInt: UInt = 3
    var data: Data
    var date: Date
    var decimal: Decimal = 8
    var uuid: UUID
    var avatar: ImageModel
    var optional: String? = "123"
    var optional2: String?
}

会生成以下实例

static let `default` = User5(
    name: "",
    age: 22,
    uInt: 3,
    data: Data(),
    date: Date(),
    decimal: 8,
    uuid: UUID(),
    avatar: ImageModel.default,
    optional: "123",
    optional2: nil
)

⚠️注意: 泛型类型的属性不支持使用 @DefaultInstance

@Codable
struct NetResponse<Element: Codable> {
    let data: Element?
    let msg: String
    private(set) var code: Int = 0
}

20. 生成 copy 方法

使用 Copyable 为模型生成 copy 方法

@Codable
@Copyable
public struct Model6 {
    var name: String
    let id: Int
    var desc: String?
}

@Codable
@Copyable
class Model7<Element: Codable> {
    var name: String
    let id: Int
    var desc: String?
    var data: Element?
}

生成如下 copy 方法, 可以看到, 除了默认 copy, 还可以对部分属性进行更新

public func copy(
    name: String? = nil,
    id: Int? = nil,
    desc: String? = nil
) -> Model6 {
    return .init(
        name: name ?? self.name,
        id: id ?? self.id,
        desc: desc ?? self.desc
    )
}

func copy(
    name: String? = nil,
    id: Int? = nil,
    desc: String? = nil,
    data: Element? = nil
) -> Model7 {
    return .init(
        name: name ?? self.name,
        id: id ?? self.id,
        desc: desc ?? self.desc,
        data: data ?? self.data
    )
}

以上示例展示了 ReerCodable 的主要特性,这些特性可以帮助开发者大大简化编解码过程,提高代码的可读性和可维护性。

ReerCodable 通过一系列精心设计的 Swift Macros,极大地简化了 Codable 的使用,显著减少了样板代码,提高了开发效率和代码可读性。它不仅涵盖了原生 Codable 的大部分场景,还提供了更强大、更灵活的功能,如多 key 解码、命名转换、自定义容器、健壮的默认值处理、强大的枚举支持以及便捷的辅助工具等。

如果你还在为 Codable 的繁琐实现而烦恼,不妨试试 ReerCodable,相信它会给你带来惊喜!

GitHub 地址: github.com/reers/ReerC…

欢迎大家试用、Star、提 Issue 或 PR!让我们一起用更现代、更优雅的方式来编写 Swift 代码!

文章主要由 AI 生成, 具体以 github readme 为准

用 Swift 构建 WASM 应用

随着 Swift 6.1 版本的正式发布,SwiftWasm 也迎来了重大升级。这一里程碑式的更新标志着 SwiftWasm 首次实现了完全基于官方 Swift 开源工具链的构建——告别了自定义补丁的时代,不仅显著简化了开发者的安装流程,大幅节省了系统存储空间,更为重要的是,这种纯正构建方式极大降低了平台的维护成本,为 Swift 生态系统注入了新的活力。在本文中,我们将探索如何利用 Swift 构建 WebAssembly 应用,带你领略 Swift 跨平台开发的无限可能。

❌