涉及知识点

  1. 如何使用Codable编码、解码对象;
  2. 如何使用JSONEncoderCodable对象变成Data对象;
  3. 如何将Data对象保存到文件系统中;
  4. 如何从URL(文件路径或网络地址)加载Codable对象;
  5. 四个编译器自动生成代码的protocol

文章内容

这篇文章以Shape对象为例子,说明怎么讲对象保存成JSONShape对象定义如下:

struct Shape {
    var color: ShapeColor
    var type: ShapeType
    var isPicked: Bool
}

enum ShapeColor {
    case red, green, blue, none
}

enum ShapeType {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)
    case square(edgeLength: Double)
}

Shape对象是虚构的,并不来自某段程序,但你可以把它想成某个画板工具的数据模型。

CodableDecodableEncodable协议

要想让Shape变成JSON,以及想从JSON中构建Shape,我们需要先让Shape对象遵从Codable协议。该协议的定义如下:

typealias Codable = Decodable & Encodable

我们可以看看EncodableDecodale分别是怎么定义的:

/// A type that can encode itself to an external representation.
public protocol Encodable {

    /// Encodes this value into the given encoder.
    ///
    /// If the value fails to encode anything, `encoder` will encode an empty
    /// keyed container in its place.
    ///
    /// This function throws an error if any values are invalid for the given
    /// encoder's format.
    ///
    /// - Parameter encoder: The encoder to write data to.
    func encode(to encoder: Encoder) throws
}

/// A type that can decode itself from an external representation.
public protocol Decodable {

    /// Creates a new instance by decoding from the given decoder.
    ///
    /// This initializer throws an error if reading from the decoder fails, or
    /// if the data read is corrupted or otherwise invalid.
    ///
    /// - Parameter decoder: The decoder to read data from.
    init(from decoder: Decoder) throws
}

只要对象中所有的属性都遵守Codable协议,那么Swift编译器就会自动帮你生成这两个函数,所以除非你有特殊要求,我们并不需要编写这两个函数。Swift中所有常见的类型,如IntStringURL、数组、字典都遵从Codable协议。对于enum,只要所有枚举值参数都是Codable,或者压根就没有参数,那么编译器就会帮你生成。我们的ShapeColorShapeType都满足这样的条件,所以我们直接加上Codable协议即可:

struct Shape: Codable {
    var color: ShapeColor
    var type: ShapeType
    var isPicked: Bool
}

enum ShapeColor: Codable {
    case red, green, blue, none
}

enum ShapeType: Codable {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)
    case square(edgeLength: Double)
}

顺便说说,在Swift中有四个特殊的协议,如果满足条件,那么Swift编译器就会自动生成默认实现,用户无需自己定义。

  1. Encodable:自动生成func encode(to encoder: Encoder) throws
  2. Decodable:自动生成init(from decoder: Decoder) throws
  3. Equatable:自动生成static func ==(a: Type, b: Type),其中Type是对象类型;
  4. Hashable: 自动生成var hashValue: Int { get }

还记得typealias Codable = Encodable & Decodable吗?使用它,就等于使用EncodableDocodable两个协议。

你可以从这里看到更详细的规则:https://github.com/apple/swift-evolution/blob/main/proposals/0185-synthesize-equatable-hashable.md

关于怎么使用Codable,可以阅读这篇文章:https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types

控制输出JSON的内容

通过自己编写func encode(to encoder: Encoder) throwsinit(from decoder: Decoder),我们就能控制输出JSON的内容和格式。比如,我们规定Shape输出成JSON时,需要满足以下几点要求:

  1. Shape.isPicked不应该出现在JSON中;
  2. 如果Shape.color = none,那么color字段也不应该出现在JSON中。

那么,我可以通过自己定义encode(to encoder:)init(from decoder:),满足上面的要求。

struct Shape: Codable {
    var color: ShapeColor
    var type: ShapeType
    var isPicked: Bool
    
    enum CodingKeys: String, CodingKey {
        case color, type
    }
    
    init(color: ShapeColor, type: ShapeType, isPicked: Bool = false) {
        self.color = color
        self.type = type
        self.isPicked = isPicked
    }
    
    init(from decoder: Decoder) throws {
        var container = try decoder.container(keyedBy: CodingKeys.self)
        
        let color = try container.decodeIfPresent(ShapeColor.self, forKey: .color)
        self.color = color ?? .none
        try self.type = container.decode(ShapeType.self, forKey: .type)
        
        self.isPicked = false
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        if case .none = color {} else {
            try container.encode(color, forKey: .color)
        }
        
        try container.encode(type, forKey: .type)
    }

}

Codable对象变成JSON文件

要将Codable对象变成变成JSON文件,需要用到JSONEncoder。顾名思义,它就是用来将Encodable对象编码成JSON的。它的用法很简单:创建一个JSONEncoder对象,调用其write方法,然后就可以得到一个包含JSON数据的Data对象了。注意在使用前需要import Foundation

import Foundation

let shape = Shape(color: .red, type: .circle(radius: 5))
let data = try JSONEncoder().encode(shape)

Data对象

我们可以将Data对象变成字符串打印出来,这样我们就得到Shape对象编码成JSON的样子。

let jsonString = String(data: data, encoding: .utf8)
pirnt(jsonString)

// in the console: {"type":{"circle":{"radius":5}}}

当然,我们也可以将Data存到文件系统中,要做到这一步,只需一步:

try data.write(theURLWhereTheFileIsStored)

获取保存的文件路径

通常我们会把文件存放到特定的目录下,比如说“documentDirectory”,在iOS下它是“文件”App的本地根目录,在macOS下是用户的“文稿”目录。下面函数可以用来获取到“documentDirectory”的URL:

func getDocumentDirectoryURL() -> URL? {
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
}

然后我们可以使用URL.appending方法,把文件名filename加上去,以获得完整的保存路径:

let theURLWhereTheFileIsStored = getDocumentDirectoryURL()?.appending(path: filename)

有了完整的保存路径,我们只需要调用data.write,就成功将Shape对象存到文件系统中了。我们可以整理整理上边的代码,然后在Shape对象上添加一个公开的对象方法。调用它,就能保存Shape对象。

struct Shape: Codable {
    // ...

    func saveToFile(filename: String) throws {
        guard let documentDirectory = getDocumentDirectoryURL() else { return }
        let theURLWhereTheFileIsStored = documentDirectory.appending(path: filename)
        let data = try JSONEncoder().encode(self)
        try data.write(to: theURLWhereTheFileIsStored)
    }

    private func getDocumentDirectoryURL() -> URL? {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    }
}

从文件中读取Shape

我们可以写一个构造器,让Shape从一个URL中读取初始化数据。这样我们就能够从文件中读取并初始化Shape了。(不仅仅如此,由于输入的是URL,所以我们也能够从网络下载数据,并使用这些数据初始化Shape!)

struct Shape {
    // ...

    init(fromURL url: URL) throws {
        let data = try Data(contentsOf: url)
        let shape = try JSONDecoder().decode(Shape.self, from: data)
    }
}

完整例子

import Foundation

struct Shape: Codable {
    var color: ShapeColor
    var type: ShapeType
    var isPicked: Bool
    
    enum CodingKeys: String, CodingKey {
        case color, type
    }
    
    init(color: ShapeColor, type: ShapeType, isPicked: Bool = false) {
        self.color = color
        self.type = type
        self.isPicked = isPicked
    }
    
    init(from decoder: Decoder) throws {
        var container = try decoder.container(keyedBy: CodingKeys.self)
        
        let color = try container.decodeIfPresent(ShapeColor.self, forKey: .color)
        self.color = color ?? .none
        try self.type = container.decode(ShapeType.self, forKey: .type)
        
        self.isPicked = false
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        if case .none = color {} else {
            try container.encode(color, forKey: .color)
        }
        
        try container.encode(type, forKey: .type)
    }

    init(fromURL url: URL) throws {
        let data = try Data(contentsOf: url)
        let shape = try JSONDecoder().decode(Shape.self, from: data)
    }

    func saveToFile(filename: String) throws {
        guard let documentDirectory = getDocumentDirectoryURL() else { return }
        let theURLWhereTheFileIsStored = documentDirectory.appending(path: filename)
        let data = try JSONEncoder().encode(self)
        try data.write(to: theURLWhereTheFileIsStored)
    }

    private func getDocumentDirectoryURL() -> URL? {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    }
}

enum ShapeColor: Codable {
    case red, green, blue, none
}

enum ShapeType: Codable {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)
    case square(edgeLength: Double)
}