关于 @propertyWrapper 在 Codable 中的应用

回顾 Codable

先来看一个常见的 Decode 场景。

场景一、

Swift 复制代码
struct NormalDecodableStruct: Decodable {
    var title: String = ""
    var isNew: Bool = false
}

func testNormalDecodableStruct() {
    let json = """
    {
        "isNew": 0
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(NormalDecodableStruct.self, from: data)
        XCTAssertEqual(normalStruct.title, "")
        XCTAssertEqual(normalStruct.isNew, false)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Log: keyNotFound(CodingKeys(stringValue: "title", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: "title", intValue: nil) ("title").", underlyingError: nil))

通过 Log 发现首先是报错了缺少 title key 的错误, 尝试改下代码。

改进 +1

Swift 复制代码
struct NormalDecodableStruct2: Decodable {
    var title: String?
    var isNew: Bool?
}

func testNormalDecodableStruct2() {
    let json = """
    {
        "isNew": 0
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(NormalDecodableStruct2.self, from: data)
        XCTAssertEqual(normalStruct.title, nil)
        XCTAssertEqual(normalStruct.isNew, nil)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Log: typeMismatch(Swift.Bool, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "isNew", intValue: nil)], debugDescription: "Expected to decode Bool but found a number instead.", underlyingError: nil))

发现直接报错 isNew, 而没有先报错 title, 说明缺少 key 是可以通过可选值的方式来进行解码的, 但是类型不匹配的问题仍然没有解决。

如果是接口返回的,我们只好和后台同学去约定好类型一定要严格按照文档给出,否则我们这里会产生 Crash 的问题。但是即便是约定好了,发现使用起来会极度的不方便,因为每个字段在使用时都需要解包处理。

改进 +2

Swift 复制代码
struct NormalDecodableStruct3: Decodable {
    var title: String = ""
    var isNew: Bool = false
    
    enum CodingKeys: CodingKey {
        case title
        case isNew
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
        self.isNew = try container.decodeIfPresent(Bool.self, forKey: .isNew) ?? false
    }
}

func testNormalDecodableStruct3() {
    let json = """
    {
        "isNew": 0
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(NormalDecodableStruct3.self, from: data)
        XCTAssertEqual(normalStruct.title, "")
        XCTAssertEqual(normalStruct.isNew, false)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Log: typeMismatch(Swift.Bool, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "isNew", intValue: nil)], debugDescription: "Expected to decode Bool but found a number instead.", underlyingError: nil))

发现虽然类型不匹配的问题依然存在, 不过至少在类型匹配时不用写可选 ? 了。。。

改进 +3

Swift 复制代码
struct NormalDecodableStruct4: Decodable {
    var title: String = ""
    var isNew: Bool = false
    
    enum CodingKeys: CodingKey {
        case title
        case isNew
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.title = (try? container.decode(String.self, forKey: .title)) ?? ""
        self.isNew = (try? container.decode(Bool.self, forKey: .isNew)) ?? false
    }
}

func testNormalDecodableStruct4() {
    let json = """
    {
        "isNew": 0
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(NormalDecodableStruct4.self, from: data)
        XCTAssertEqual(normalStruct.title, "")
        XCTAssertEqual(normalStruct.isNew, false)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

这次终于看到了久违的 Test Succeed, 缺少 key 或者 value 类型不匹配的问题得到了解决。

但是冷静下来再看下, 怎么说呢, 一点也不优雅😳 我们想要的是像 NormalDecodableStruct 一样单纯的。

Swift 复制代码
struct NormalDecodableStruct: Decodable {
    var title: String = ""
    var isNew: Bool = false
}

那么有更好的方案吗,有!!!

接下来看下通过 @propertyWrapper 如何来优雅的解决上边遇到的问题。

@propertyWrapper

首先来看下通过 @propertyWrapper 改写后的样子。

示例

Swift 复制代码
struct WrapperDecodableStruct: Decodable {
    @Default<String> var title: String = ""
    @Default<Bool> var isNew: Bool = false
}

func testWrapperDecodableStruct() {
    let json = """
    {
        "isNew": 0
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(WrapperDecodableStruct.self, from: data)
        XCTAssertEqual(normalStruct.title, "")
        XCTAssertEqual(normalStruct.isNew, false)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Test Succeed

这已经非常接近我们所期望的样子了,也不需要为每个 类/结构体 重写 decode 方法了。

那么问题来了,@Default<T> 做了什么呢?

@Default<T>

Swift 复制代码
// MARK: - DefaultValue Protocol

protocol DefaultValue {
    associatedtype Result: Codable
    
    static var defaultValue: Result { get }
}

extension String: DefaultValue {
    static let defaultValue = ""
}

extension Bool: DefaultValue {
    static let defaultValue = false
}

// MARK: - @propertyWrapper

@propertyWrapper
struct Default<T: DefaultValue> {
    var wrappedValue: T.Result
}

extension Default: Codable {
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(T.Result.self)) ?? T.defaultValue
    }
}

extension KeyedDecodingContainer {
    
    func decode<T>(_ type: Default<T>.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Default<T> where T: DefaultValue {
        try decodeIfPresent(type, forKey: key) ?? Default(wrappedValue: T.defaultValue)
    }
}

这里出现了 singleValueContainer(), 并通过拓展对 Default<T> 类型进行了支持。

看下官方对 SingleValueDecodingContainer 的解释:

A container that can support the storage and direct decoding of a single nonkeyed value.

支持存储和直接解码单个非键值的容器。

在断点调试的时候你可能会发现属性从 String -> Default<String>

但是获取的时候却是可以直接取到想要的值的, 原因是什么呢?

原理

propertyWrapper 可以理解成一组特别的 gettersetter , 其本身是一个特殊的盒子,把原来值的类型包装了进去。被 propertyWrapper 声明的属性,实际上在存储时的类型是 propertyWrapper 这个盒子的类型,只不过编译器施了一些魔法,让它对外暴露的类型依然是被包装的原来的类型。

ps: wrappedValue 并不是随意定义的属性名称, 而是必须实现的。

看下官方文档中提供的示例:

less 复制代码
@propertyWrapper
struct TwelveOrLess {
    private var number = 0
    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, 12) }
    }
}

struct SmallRectangle {
    @TwelveOrLess var height: Int
    @TwelveOrLess var width: Int
}

当包装器应用于属性时,编译器会合成为包装器提供存储的代码和通过包装器提供对属性的访问的代码。

我们现在已经通过 @propertyWrapper 解决了主要用于接口数据解析的典型问题, 可能你也已经想到了其实还遗漏了一个枚举类型。接下来看下场景二。

场景二、

Swift 复制代码
struct EnumDecodableStruct: Decodable {
    enum State: String, Codable {
        case prepare, playing
    }
    var state: State = .prepare
}

func testEnumDecodableStruct() {
    let json = """
    {
        "state": "loading"
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(EnumDecodableStruct.self, from: data)
        XCTAssertEqual(normalStruct.state, .prepare)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Log: dataCorrupted(Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "state", intValue: nil)], debugDescription: "Cannot initialize State from invalid String value loading", underlyingError: nil))

我们期望将接收到的 state 字符串直接关联到枚举属性对应的 case 上, 但是接收到的是一个未声明的 case 类型, 这种场景在迭代过程中是比较容易出现的。

通过 Log 发现 state 没有被成功解析。与上边相同的代码改进方式这里就不再展示了, 有兴趣的可以下载工程去做测试。

改进 +1

Swift 复制代码
struct EnumDecodableStruct4: Decodable {
    enum State: String, Codable {
        case prepare, playing

        init(rawValue: String) {
            switch rawValue {
            case "prepare": self = .prepare
            case "playing": self = .playing
            default: self = .prepare
            }
        }
    }
    var state: State = .prepare
}
    
func testEnumDecodableStruct4() {
    let json = """
    {
        "state": "loading"
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(EnumDecodableStruct4.self, from: data)
        XCTAssertEqual(normalStruct.state, .prepare)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Test Succeed

这种方式是完全没有问题的, 但是弊端也很明显, 如果 case 较多, 对应起来还是比较麻烦的。

改进 +2

Swift 复制代码
struct WrapperEnumDecodableStruct: Decodable {
    enum State: String, Codable {
        case prepare, playing
    }
    @Default<State> var state: State = .prepare
}

extension WrapperEnumDecodableStruct.State: DefaultValue {
    static let defaultValue: WrapperEnumDecodableStruct.State = .prepare
}
    
func testWrapperEnumDecodableStruct() {
    let json = """
    {
        "state": "loading"
    }
    """

    guard let data = json.data(using: .utf8) else { return }

    do {
        let decoder = JSONDecoder()
        let normalStruct = try decoder.decode(WrapperEnumDecodableStruct.self, from: data)
        XCTAssertEqual(normalStruct.state, .prepare)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Test Succeed

这里可以看到我们对枚举类型的拓展直接复用了 DefaultValue 协议, 让其像 String 一样实现 defaultValue 即可, 还是比较容易理解的。

还可以做什么

本地 Data 数据解析

当数据模型中添加了新的字段, 但本地数据 Data 存储时并没有该字段, 进行 Decode 时会产生与上边解析时缺少 key 同样的问题, 那么通过 @Default 来进行默认值设置将是一种非常好的选择。

对不支持 Codable 协议的类型做处理

UIImage 是没有遵守 Codable 协议的, 如果我们定义了 UIImage 类型的属性时编译器是会报错的。

可能会大家首先想到的就是转换下改为定义 Data 类型, 如果有需要再定义一个仅支持 getUIImage 属性处理转换。就像下边这样:

Swift 复制代码
var imageData: Data?
var image: UIImage? {
    if let imageData = imageData {
        return UIImage(data: imageData)
    }
    return nil
}

@propertyWrapper 可以帮助我们改进吗? 当然!

Swift 复制代码
func testCodableImage() {
    struct AStruct: Codable {
        @CodableImage var image: UIImage?
    }

    let image = UIImage(named: "test")
    XCTAssertNotNil(image)

    var aStruct = AStruct()
    aStruct.image = image

    do {
        let encoder = JSONEncoder()
        let data = try encoder.encode(aStruct)

        let decoder = JSONDecoder()
        let aDecodableStruct = try decoder.decode(AStruct.self, from: data)
        XCTAssertNotNil(aDecodableStruct.image)
    } catch {
        print(error)
        XCTAssertThrowsError(error)
    }
}

Test Succeed

非常好👍

发现做了类似前面用到的 @Default 方式即达到了想要的效果, 只是 @Default 替换为了@CodableImage。同样的将其马甲脱掉看看你是否还认识 ta

Swift 复制代码
@propertyWrapper
struct CodableImage: Codable {

    var wrappedValue: UIImage?

    init(wrappedValue: UIImage? = nil) {
        self.wrappedValue = wrappedValue
    }

    enum CodingKeys: String, CodingKey {
        case wrappedValue
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let data = try container.decode(Data.self, forKey: CodingKeys.wrappedValue)
        guard let image = UIImage(data: data) else {
            throw DecodingError.dataCorruptedError(forKey: CodingKeys.wrappedValue, in: container, debugDescription: "Decoding image failed!")
        }
        self.wrappedValue = image
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        if let data = wrappedValue?.pngData() {
            try container.encode(data, forKey: CodingKeys.wrappedValue)
        } else if let data = wrappedValue?.jpegData(compressionQuality: 1.0) {
            try container.encode(data, forKey: CodingKeys.wrappedValue)
        } else {
            try container.encodeNil(forKey: CodingKeys.wrappedValue)
        }
    }
}

原来是将转换方法写到了 encode/decode 方法中。

你可能会对构造方法产生好奇, 它也对 UIImage<->Data 转换发挥了哪些作用吗?

答案是并没有😄, 它的作用只是用于初始化赋值的:

Swift 复制代码
struct AStruct: Codable {
    @CodableImage(wrappedValue: UIImage(named: "test")) var image: UIImage?
}

如果你想, 也可以是这样子:

Swift 复制代码
init() {
    self.wrappedValue = nil
}

范围限定

一个有趣的例子, 摘自 Swift Property Wrappers

Swift 复制代码
@propertyWrapper
struct Clamping<Value: Comparable> {
    var value: Value
    let range: ClosedRange<Value>

    init(initialValue value: Value, _ range: ClosedRange<Value>) {
        precondition(range.contains(value))
        self.value = value
        self.range = range
    }

    var wrappedValue: Value {
        get { value }
        set { value = min(max(range.lowerBound, newValue), range.upperBound) }
    }
}
Swift 复制代码
struct Solution {
    @Clamping(0...14) var pH: Double = 7.0
}

let carbonicAcid = Solution(pH: 4.68) // at 1 mM under standard conditions
    
let superDuperAcid = Solution(pH: -1)
superDuperAcid.pH // 0

总结

这片关于 @propertyWrapper 的介绍仅是冰山一角, 我们可能经常在官方 API 或第三方库中看到以 @ 开头的修饰, 不出意外的话都是属性包装器。

希望这篇文章能够成为你了解学习属性包装器路上的基石。

Demo

查看完整版 DefaultValue 或想进行测试, 可以访问我的 GitHub


更多链接:

相关推荐
天天扭码5 小时前
五天SpringCloud计划——DAY2之单体架构和微服务架构的选择和转换原则
java·spring cloud·微服务·架构
余生H6 小时前
transformer.js(三):底层架构及性能优化指南
javascript·深度学习·架构·transformer
凡人的AI工具箱6 小时前
15分钟学 Go 第 60 天 :综合项目展示 - 构建微服务电商平台(完整示例25000字)
开发语言·后端·微服务·架构·golang
运维&陈同学7 小时前
【zookeeper01】消息队列与微服务之zookeeper工作原理
运维·分布式·微服务·zookeeper·云原生·架构·消息队列
哔哥哔特商务网19 小时前
一文探究48V新型电气架构下的汽车连接器
架构·汽车
007php00719 小时前
GoZero 上传文件File到阿里云 OSS 报错及优化方案
服务器·开发语言·数据库·python·阿里云·架构·golang
今天啥也没干19 小时前
使用 Sparkle 实现 macOS 应用自定义更新弹窗
前端·javascript·swift
码上有前21 小时前
解析后端框架学习:从单体应用到微服务架构的进阶之路
学习·微服务·架构