Swift Codable的容错机制:解码不完全匹配的JSON
SSwwiifftt CCooddaabblleeJJSSOONN

Swift Codable的容错机制:解码不完全匹配的JSON

在 Swift 中,Codable 类型在解码 JSON 时具有很强的容错能力。当 JSON 数据的结构和 Codable 定义的模型不完全匹配时,只要关键字段和数据类型能够对上,JSONDecoder 仍然会尽量解析它,忽略多余或不匹配的部分。

参考样例

例如,通过维基百科获取相关日照坐标的相关信息:

@State private var pages = [Page]()
func fetchNearbyPlaces() async {
    let urlString = "https://en.wikipedia.org/w/api.php?ggscoord=35.420503%7C119.527506&action=query&prop=coordinates%7Cpageimages%7Cpageterms&colimit=50&piprop=thumbnail&pithumbsize=500&pilimit=50&wbptterms=description&generator=geosearch&ggsradius=10000&ggslimit=50&format=json"
    do {
        let (data, _) = try await URLSession.shared.data(from: URL(string: urlString)!)
        let items = try JSONDecoder().decode(Result.self, from: data)
        pages = items.query.pages.values.sorted { $0.title < $1.title }
    } catch {
        print("调取失败")
    }
}

当调取成功后,在视图中展示:

ForEach(pages, id: \.pageid) { page in
    Text(page.title)
        .font(.headline)
    + Text(": ") +
    Text("Page description here")
        .italic()
}

JSON结构

JSON文件内容

通过URLSession.shared.data获取的实际JSON文件内容:

{
    "batchcomplete":"","query":{
        "pages":{
            "2887316":{
                "pageid":2887316,"ns":0,"title":"Rizhao","index":-1,
                "coordinates":[
                    {
                    "lat":35.417,"lon":119.527,"primary":"","globe":"earth"
                    }
                ],
                "thumbnail":{
"source":"https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Rizhao_sea-port.jpg/500px-Rizhao_sea-port.jpg","width":500,"height":333
                },
                "terms":{
                    "description":["prefecture-level city in Shandong, China"]
                }
            }
        }
        ...
    }
}
结构分解

1、Result结构:batchcomplete:String,query:Query

2、Query结构:pages:String[Int,PageInfo]

3、PageInfo结构:pageid:Int,ns:Int,title:String,index:Int,coordinates:[Coordinates],thumbnail:Thumbnail,terms:Terms

4、Coordinates结构:lat:Double,lon:Double,primary:String,globe:String

5、Thumbnail结构:source:String,width:Int,height:Int

6、Terms结构:description:[String]

Swift中的结构
struct Result: Codable {
    let query: Query
}

struct Query: Codable {
    let pages: [Int: Page]
}

struct Page: Codable {
    let pageid: Int
    let title: String
    let terms: [String: [String]]?
}

对比发现,JSON文件的结构与Swift中定义的结构并不一致,但仍然可以解码出数据信息。

Codable容错机制

1、忽略JSON 多余字段

JSON 中可能包含模型未定义的字段,这些字段会被解码器自动忽略。例如:

{
    "query": {
        "pages": {
            "12345": {
                "pageid": 12345,
                "title": "Example Title",
                "terms": {
                    "description": ["An example page"]
                },
                "extra_field": "This is ignored"
            }
        }
    }
}

即使 Page 中没有 extra_field 属性,解码器也不会报错,只会忽略 extra_field。

2、可选字段为nil

如果 JSON 数据中没有 terms 字段,解码器会将其赋值为 nil,不会引发错误。例如:

{
    "query": {
        "pages": {
            "12345": {
                "pageid": 12345,
                "title": "Example Title"
            }
        }
    }
}

在这种情况下,terms 被安全地赋值为 nil。

3、字典键类型的动态处理

Query 中 pages 定义为 [Int: Page],但 JSON 中的键是字符串,例如 “12345”。解码器会尝试将键从字符串转换为整数,只要转换成功,就不会报错。如果转换失败,则会抛出错误。例如:

{
    "query": {
        "pages": {
            "invalid_key": {
                "pageid": 12345,
                "title": "Example Title"
            }
        }
    }
}

这里 “invalid_key” 无法转换为 Int,解码会失败。

4、容忍 JSON 结构

Codable 的解析是基于字段匹配的,它不要求 JSON 数据完全按照模型定义。只要 JSON 中包含模型需要的字段,并且字段类型兼容,解码就能成功。

为什么能解码?

综上所述,只要:

1、模型定义的字段存在于 JSON 中,且类型匹配;

2、未定义的字段被忽略;

3、可选字段可以缺失;

解码器就能正确解析 JSON。

总结

Swift Codable容错机制,有助于更好的解析JSON的字段,当想要存储指定字段时,可以在Swift结构中调整对应的字段。

需要注意的是,Swift 的 Decodable 在解析 JSON 时默认要求每个键都必须存在。如果某个键缺失(例如 thumbnail),解码就会失败,抛出此错误。

因此,应该对可能缺失的字段设置为可选类型:

struct PageInfo: Codable {
    let pageid: Int
    let ns: Int
    let title: String
    let index: Int
    let coordinates: [Coordinates]
    let thumbnail: Thumbnail?
    let terms: Terms?
}

也可以考虑在构造器中配置默认值

struct Page: Codable {
    let pageid: Int
    let title: String
    let thumbnail: Thumbnail

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        pageid = try container.decode(Int.self, forKey: .pageid)
        title = try container.decode(String.self, forKey: .title)
        // Provide a default value if thumbnail is missing
        thumbnail = (try? container.decode(Thumbnail.self, forKey: .thumbnail)) ?? Thumbnail.default
    }

    private enum CodingKeys: String, CodingKey {
        case pageid, title, thumbnail
    }
}

如果解码的过程中,存在无法捕获的问题,可以使用DecodingError捕获问题字段

附录

JSON数据结构的字典类型

前文中的JSON文件的pages属性中, “2887316”数字并没有属性名称:

{
    "batchcomplete":"","query":{
        "pages":{
            "2887316":{
                ...
            }
        }
    }
}

这是因为,在 JSON 数据结构中,”pages” 是一个字典(或对象),其键是动态的字符串(在这里是 2887316),而不是固定的键名。这种结构在某些情况下非常常见,尤其是在需要按唯一标识符(如 id)组织数据的场景。

解析这种结构的特点

1、动态键值对

在 JSON 中,键并不需要是固定的名字。这里的 2887316 作为键,代表了某种唯一标识符,例如页面 ID。

 2、键作为动态标识符

由于不同的数据条目可能具有不同的 ID,JSON 使用动态的键来表示这些条目。这样可以直接通过键来索引特定的数据,而不是在每个条目中显式存储一个 id 字段。

3、字典类型的用途

这种结构实际上是一种字典(在 Swift 中对应 Dictionary<String, Value>)。动态的键名可以高效地存储和查找数据,尤其适用于数据量大且需要频繁查找的情况。

在 Swift 中,JSON 里的动态键可以用 Dictionary 类型来解析。例如,在示例代码中:

struct Query: Codable {
    let pages: [Int: Page]
}

这里的 pages 是 [Int: Page] 类型,Swift 会尝试将 JSON 的键转换为 Int 类型(因为这里的键是 “2887316”,可以被解析为整数)。这是 Codable 的一个强大功能,它能自动处理 JSON 中的动态键,只要键的类型和 JSON 的实际键兼容。

优点

节省冗余字段:不需要在每个条目中重复 id 字段,键本身就充当了唯一标识符。

快速查找:如果在解析后的数据中需要查找特定 ID 的条目,可以直接通过键访问,而不需要遍历整个数组。

Result.swift代码

struct Result: Codable {
    let query: Query
}

struct Query: Codable {
    let pages: [Int: Page]
}

struct Page: Codable {
    let pageid: Int
    let title: String
    let terms: [String: [String]]?
}

EditView.swift代码

import Foundation
import SwiftUI

struct EditView: View {
    @Environment(\.dismiss) var dismiss
    var location: Location
    var onSave: (Location) -> Void
    @State private var name: String
    @State private var description: String
    @State private var loadingState = LoadingState.loading
    @State private var pages = [Page]()
    
    init(location: Location,onSave: @escaping (Location) -> Void) {
        self.location = location
        self.onSave = onSave
        _name = State(initialValue: location.name)
        _description = State(initialValue: location.description)
    }
    func fetchNearbyPlaces() async {
        let urlString = "https://en.wikipedia.org/w/api.php?ggscoord=\(location.latitude)%7C\(location.longitude)&action=query&prop=coordinates%7Cpageimages%7Cpageterms&colimit=50&piprop=thumbnail&pithumbsize=500&pilimit=50&wbptterms=description&generator=geosearch&ggsradius=10000&ggslimit=50&format=json"

        guard let url = URL(string: urlString) else {
            print("Bad URL: \(urlString)")
            return
        }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)

            // we got some data back!
            let items = try JSONDecoder().decode(Result.self, from: data)

            // success – convert the array values to our pages array
            pages = items.query.pages.values.sorted { $0.title < $1.title }
            loadingState = .loaded
        } catch {
            // if we're still here it means the request failed somehow
            loadingState = .failed
        }
    }
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("Place name", text: $name)
                    TextField("Description", text: $description)
                }
                Section("Nearby…") {
                    switch loadingState {
                    case .loaded:
                        ForEach(pages, id: \.pageid) { page in
                            Text(page.title)
                                .font(.headline)
                            + Text(": ") +
                            Text("Page description here")
                                .italic()
                        }
                    case .loading:
                        Text("Loading…")
                    case .failed:
                        Text("Please try again later.")
                    }
                }
            }
            .navigationTitle("Place details")
            .toolbar {
                Button("Save") {
                    var newLocation = location
                    newLocation.id = UUID()
                    newLocation.name = name
                    newLocation.description = description

                    onSave(newLocation)
                    dismiss()
                }
            }
        }
        .task {
            await fetchNearbyPlaces()
        }
    }
}

#Preview {
    EditView(location: .example) { _ in }
}
enum LoadingState {
    case loading, loaded, failed
}

Result.swift代码(完整结构)

struct Result: Codable {
    let batchcomplete: String
    let query: Query
}
struct Query: Codable {
    let pages: [Int:PageInfo]
}
struct PageInfo: Codable {
    let pageid: Int
    let ns: Int
    let title: String
    let index: Int
    let coordinates: [Coordinates]
    let thumbnail: Thumbnail?
    let terms: Terms?
}
struct Coordinates: Codable {
    let lat: Double
    let lon: Double
    let primary: String
    let globe: String
}
struct Thumbnail: Codable {
    let source: String
    let width: Int
    let height: Int
}
struct Terms: Codable {
    let description: [String]
}

EditView.swift代码(完整结构)

import Foundation
import SwiftUI

struct EditView: View {
    @Environment(\.dismiss) var dismiss
    var location: Location
    var onSave: (Location) -> Void
    @State private var name: String
    @State private var description: String
    @State private var loadingState = LoadingState.loading
    @State private var pages = [PageInfo]()
    
    init(location: Location,onSave: @escaping (Location) -> Void) {
        self.location = location
        self.onSave = onSave
        _name = State(initialValue: location.name)
        _description = State(initialValue: location.description)
    }
    func fetchNearbyPlaces() async {
        let urlString = "https://en.wikipedia.org/w/api.php?ggscoord=\(location.latitude)%7C\(location.longitude)&action=query&prop=coordinates%7Cpageimages%7Cpageterms&colimit=50&piprop=thumbnail&pithumbsize=500&pilimit=50&wbptterms=description&generator=geosearch&ggsradius=10000&ggslimit=50&format=json"

        guard let url = URL(string: urlString) else {
            print("Bad URL: \(urlString)")
            return
        }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)

            // we got some data back!
            let items = try JSONDecoder().decode(Result.self, from: data)

            // success – convert the array values to our pages array
            pages = items.query.pages.values.sorted { $0.title < $1.title }
            loadingState = .loaded
        } catch DecodingError.keyNotFound(let key, let context) {
            print("---1---")
            print("Key '\(key)' not found:", context.debugDescription)
            print("codingPath:", context.codingPath)
            loadingState = .failed
        } catch DecodingError.typeMismatch(let type, let context) {
            print("---2---")
            print("Type '\(type)' mismatch:", context.debugDescription)
            print("codingPath:", context.codingPath)
            loadingState = .failed
        } catch DecodingError.valueNotFound(let type, let context) {
            print("---3---")
            print("Value '\(type)' not found:", context.debugDescription)
            print("codingPath:", context.codingPath)
            loadingState = .failed
        } catch DecodingError.dataCorrupted(let context) {
            print("---4---")
            print("Data corrupted:", context.debugDescription)
            print("codingPath:", context.codingPath)
            loadingState = .failed
        } catch {
            print("Unexpected error: \(error)")
            loadingState = .failed
        }
    }
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("Place name", text: $name)
                    TextField("Description", text: $description)
                }
                Section("Nearby…") {
                    switch loadingState {
                    case .loaded:
                        ForEach(pages, id: \.pageid) { page in
                            Text(page.title)
                                .font(.headline)
                            + Text(": ") +
                            Text("Page description here")
                                .italic()
                        }
                    case .loading:
                        Text("Loading…")
                    case .failed:
                        Text("Please try again later.")
                    }
                }
            }
            .navigationTitle("Place details")
            .toolbar {
                Button("Save") {
                    var newLocation = location
                    newLocation.id = UUID()
                    newLocation.name = name
                    newLocation.description = description

                    onSave(newLocation)
                    dismiss()
                }
            }
        }
        .task {
            await fetchNearbyPlaces()
        }
    }
}

#Preview {
    EditView(location: .example) { _ in }
}
enum LoadingState {
    case loading, loaded, failed
}

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注