定制外部Swift类型的教程

151 阅读8分钟

尽管Swift内置的Codable API的所有优点和整体便利性,但它的一个缺点是,它并没有提供任何标准的方法来改变或以其他方式定制一个给定类型应该如何被编码或解码。

虽然我们总是可以为我们自己定义的类型编写完全自定义的编码实现,但当与外部类型(例如作为标准库一部分的类型)一起工作时,有可能会出现特定类型的预期编码方式与应用程序使用的数据格式不匹配的情况。

编码不匹配

举个例子,假设我们正在开发的一个应用程序包含以下User 类型,它有一个timeZone 属性,使用基金会内置的TimeZone 类型。

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var timeZone: TimeZone
}

现在我们说,我们想对上述User 类型的实例进行编码和解码,并从具有以下格式的JSON数据中进行编码。

{
    "id": "10CAAD2C-0942-4353-94AE-0319216296CB",
    "name": "John",
    "timeZone": "Europe/Warsaw"
}

那么,问题出在哪里?尽管TimeZone 已经符合Codable 的要求,但该实现的编写方式假定每个这样的值在编码时总是由一个字典表示 - 而我们的 JSON 数据使用的是一个普通的字符串,这给我们带来了不匹配。

当然,解决这个问题的一个潜在办法是改变我们的JSON数据,以符合TimeZone 所期望的格式(通过将每个时区标识符嵌套在一个字典中),但这并不总是可能的。我们的应用程序可能不是唯一消费上述数据的客户端,或者我们可能从第三方网络API请求我们的JSON,这不是我们直接控制的。

封装器类型

如果我们转而关注客户端的解决方案,我们可以做的一件事是将内置的TimeZone 类型包装成一个自定义的类型,它基本上可以作为一个RawRepresentable 包装器--比如这样:

extension User {
    struct TimeZoneWrapper: RawRepresentable {
        var rawValue: TimeZone
    }
}

RawRepresentable 是一个简单但强大的内置协议,所有原始值支持的枚举都隐含地符合这个协议。

由于我们现在处理的是一个由我们自己控制的类型,我们可以完全定制我们希望每个值被编码和解码的方式。因此,在这种情况下,我们可以先将每个时区标识符解码为String ,然后用它来初始化我们底层TimeZone 原始值的实例。然后,在编码时,我们可以简单地对我们的时区标识符进行原样编码。

extension User.TimeZoneWrapper: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let identifier = try container.decode(String.self)

        guard let timeZone = TimeZone(identifier: identifier) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Unknown time zone '\(identifier)'"
            )
        }

        rawValue = timeZone
    }

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

如果我们现在更新我们之前的User 类型,在其timeZone 属性中使用上述TimeZoneWrapper ,那么我们就解决了我们的问题。然而,我们目前的解决方案确实是有代价的,因为我们现在无论何时想使用我们所封装的实际的TimeZone 实例,都必须访问timeZone.rawValue

现在,如果我们只想使用这些TimeZone 的值来访问某些属性,那么我们可以使用 动态成员查找来 解决这个问题,因为这将使我们能够直接在我们的TimeZoneWrapper 实例上引用任何TimeZone 的属性。

然而,在这种情况下,我们很可能想把我们的TimeZone 值本身传递给各种与日期有关的系统API,如DateFormatter - 所以让我们看看我们是否能想出一个更透明的解决方案。

使用一个属性包装器

这些天来,每当 "包装类型 "这个短语出现时,我都会质疑这样一个类型是否可以更好地实现为一个实际的*属性包装器*。毕竟,封装值正是这种语言特性的作用,所以让我们看看它是否能帮助我们解决本例中的问题。

好消息是,将我们的TimeZoneWrapper 类型转换为一个属性封装器只需要我们用@propertyWrapper 属性来注释它,并给它一个wrappedValue 属性。不过,在这种情况下,我们也要给它一个更具描述性的名字--StringCodedTimeZone ,以更好地表明这个类型的实际用途。但是我们的Codable 实现可以保持完全相同(除了用wrappedValue 替换rawValue )。

@propertyWrapper
struct StringCodedTimeZone {
    var wrappedValue: TimeZone
}

extension StringCodedTimeZone: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let identifier = try container.decode(String.self)

        guard let timeZone = TimeZone(identifier: identifier) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Unknown time zone '\(identifier)'"
            )
        }

        wrappedValue = timeZone
    }

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

有了上面的改变,我们现在可以让我们的timeZone 属性保持一个合适的TimeZone 实例--我们所要做的就是用我们新的包装器的属性来注释这个属性,我们将再次能够对我们的JSON数据进行编码和解码,而不需要以任何方式改变其格式。

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    @StringCodedTimeZone var timeZone: TimeZone
}

很整洁啊如果上述基于属性包装器的解决方案看起来很熟悉,那可能是因为我在其他几篇文章中也使用过--比如"使用Codable时忽略无效的JSON元素",以及"用默认解码值来注释属性"。毫无疑问,当涉及到Codable的定制时,属性包装器是我的首选解决方案之一。虽然它们确实需要一些模板,但它们让我们在不改变我们属性的实际类型的情况下定制这些类型的行为,这一事实令人难以置信地强大。

一个更通用的抽象

现在,如果我们只需要在整个代码库中解决一次上述问题,那么我们可以在这里停止。一般来说,没有必要发明新的抽象来解决一次性的问题,但假设我们想提出一个更通用的解决方案,我们可以在我们的代码库中的多个地方使用。

目前,如果我们要编写我们的Codable 自定义解决方案的多个实例,我们最终会有相当多的模板,因为我们每次都需要从头开始编写相同的(相当冗长的!)编码和解码的代码。所以让我们通过引入一个协议来解决这个问题,这个协议可以让我们表达一个类型是可以通过转换来编码的

protocol CodableByTransform: Codable {
    associatedtype CodingValue: Codable
    static func transformDecodedValue(_ value: CodingValue) throws -> Self?
    static func transformValueForEncoding(_ value: Self) throws -> CodingValue
}

有趣的是:上述协议可以被看作是Unbox(我之前的Codable JSON解码器)的UnboxableByTransform 协议的 "精神继承者"。

注意我们是如何使我们的新协议扩展到Codable 本身的。这是一种经常被称为"协议特殊化 "的技术它本质上让我们通过继承所有协议的要求、扩展和能力来创建一个更具体的、定制的版本。

这种模式真正酷的地方在于,它可以让我们为基础协议的要求编写默认的实现。在这种情况下,这将让我们使用我们新的转换方法来自动为任何符合要求的类型提供一个默认的Codable 实现--就像这样:

extension CodableByTransform {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decoded = try container.decode(CodingValue.self)

        guard let value = try Self.transformDecodedValue(decoded) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: """
                Decoding transformation failed for '\(decoded)'
                """
            )
        }

        self = value
    }

    func encode(to encoder: Encoder) throws {
        let encodable = try Self.transformValueForEncoding(self)
        var container = encoder.singleValueContainer()
        try container.encode(encodable)
    }
}

有了上述内容,我们现在可以回到我们的StringCodedTimeZone 属性包装器,并使其变得更加简单。我们不再需要为它或其他类似的包装器编写任何具体的Codable 实现,而是可以专注于执行实际的转换,以实现其编码表示:

@propertyWrapper
struct StringCodedTimeZone: CodableByTransform {
    static func transformDecodedValue(_ value: String) throws -> Self? {
        TimeZone(identifier: value).map(Self.init)
    }

    static func transformValueForEncoding(_ value: Self) throws -> String {
        value.wrappedValue.identifier
    }

    var wrappedValue: TimeZone
}

我们现在有了一个通用的抽象,可以轻松实现任何类型的Codable 转换--无论是对我们自己的类型,还是对我们想要定制的外部类型。

总结

虽然我当然希望Codable 包括更多轻量级的自定义选项,但我们可以使用属性包装器(和其他语言功能)来编写小型的编码/解码插件,这一事实真的很有用。这样做可能需要一些设置代码,但一旦完成,我们应该能够以我们喜欢的方式调整任何类型的编码过程。

所以,当你下次遇到一个外部类型的编码或解码方式不尽如人意时,我希望这篇文章中的某个技术能派上用场。

谢谢你的阅读!