关于 @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


更多链接:

相关推荐
小蜗牛慢慢爬行3 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
Swift社区4 小时前
Excel 列名称转换问题 Swift 解答
开发语言·excel·swift
小扳6 小时前
微服务篇-深入了解 MinIO 文件服务器(你还在使用阿里云 0SS 对象存储图片服务?教你使用 MinIO 文件服务器:实现从部署到具体使用)
java·服务器·分布式·微服务·云原生·架构
盛派网络小助手13 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#
快乐非自愿18 小时前
分布式系统架构2:服务发现
架构·服务发现
2401_8543910818 小时前
SSM 架构中 JAVA 网络直播带货查询系统设计与 JSP 有效实现方法
java·开发语言·架构
264玫瑰资源库18 小时前
从零开始C++棋牌游戏开发之第二篇:初识 C++ 游戏开发的基本架构
开发语言·c++·架构
神一样的老师18 小时前
面向高精度网络的时间同步安全管理架构
网络·安全·架构
2401_8570262318 小时前
基于 SSM 架构的 JAVA 网络直播带货查询系统设计与 JSP 实践成果
java·开发语言·架构
9527华安18 小时前
FPGA实现MIPI转FPD-Link车载同轴视频传输方案,基于IMX327+FPD953架构,提供工程源码和技术支持
fpga开发·架构·mipi·imx327·fpd-link·fpd953