使用 URLSession 为 SwiftUI 视图加载 JSON 数据

Ral*_*ert 7 ios urlsession swiftui

构建 SwiftUI 应用程序的网络层的行之有效的方法是什么?具体来说,您如何使用 URLSession 来构建要在 SwiftUI 视图中显示的 JSON 数据并处理从 iOS 13.4 开始可以正确发生的所有不同状态?

Ral*_*ert 7

这是我在上一个项目中想到的:

  • 将加载过程表示为ObservableObject模型类
  • 使用URLSession.dataTaskPublisher进行加载
  • 使用CodableJSONDecoder使用对解码组合支持解码对 Swift 类型的响应
  • 跟踪模型中的状态作为@Published 属性,以便视图可以显示加载/错误状态。
  • 将加载的结果作为单独属性中的 @Published 属性进行跟踪,以便在 SwiftUI 中轻松使用(您也可以使用View#onReceive直接在 SwiftUI 中订阅发布者,但将发布者封装在模型类中总体上看起来更干净)
  • .onAppear如果尚未加载,请使用 SwiftUI修饰符触发加载。
  • 使用.overlay修饰符可以方便地根据状态显示进度/错误视图
  • 为重复发生的任务提取可重用的组件(这里是一个例子:EndpointModel

该方法的独立示例代码(也可在我的SwiftUIPlayground 中找到):

// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/

import Combine
import SwiftUI

struct TypiTodo: Codable, Identifiable {
    var id: Int
    var title: String
}

class TodosModel: ObservableObject {

    @Published var todos = [TypiTodo]()
    @Published var state = State.ready

    enum State {
        case ready
        case loading(Cancellable)
        case loaded
        case error(Error)
    }

    let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
    let urlSession = URLSession.shared

    var dataTask: AnyPublisher<[TypiTodo], Error> {
        self.urlSession
            .dataTaskPublisher(for: self.url)
            .map { $0.data }
            .decode(type: [TypiTodo].self, decoder: JSONDecoder())
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }

    func load() {
        assert(Thread.isMainThread)
        self.state = .loading(self.dataTask.sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case let .failure(error):
                    self.state = .error(error)
                }
            },
            receiveValue: { value in
                self.state = .loaded
                self.todos = value
            }
        ))
    }

    func loadIfNeeded() {
        assert(Thread.isMainThread)
        guard case .ready = self.state else { return }
        self.load()
    }
}

struct TodosURLSessionExampleView: View {

    @ObservedObject var model = TodosModel()

    var body: some View {
        List(model.todos) { todo in
            Text(todo.title)
        }
        .overlay(StatusOverlay(model: model))
        .onAppear { self.model.loadIfNeeded() }
    }
}

struct StatusOverlay: View {

    @ObservedObject var model: TodosModel

    var body: some View {
        switch model.state {
        case .ready:
            return AnyView(EmptyView())
        case .loading:
            return AnyView(ActivityIndicatorView(isAnimating: .constant(true), style: .large))
        case .loaded:
            return AnyView(EmptyView())
        case let .error(error):
            return AnyView(
                VStack(spacing: 10) {
                    Text(error.localizedDescription)
                        .frame(maxWidth: 300)
                    Button("Retry") {
                        self.model.load()
                    }
                }
                .padding()
                .background(Color.yellow)
            )
        }
    }

}

struct TodosURLSessionExampleView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            TodosURLSessionExampleView(model: TodosModel())
            TodosURLSessionExampleView(model: self.exampleLoadedModel)
            TodosURLSessionExampleView(model: self.exampleLoadingModel)
            TodosURLSessionExampleView(model: self.exampleErrorModel)
        }
    }

    static var exampleLoadedModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.todos = [TypiTodo(id: 1, title: "Drink water"), TypiTodo(id: 2, title: "Enjoy the sun")]
        todosModel.state = .loaded
        return todosModel
    }

    static var exampleLoadingModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.state = .loading(ExampleCancellable())
        return todosModel
    }

    static var exampleErrorModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.state = .error(ExampleError.exampleError)
        return todosModel
    }

    enum ExampleError: Error {
        case exampleError
    }

    struct ExampleCancellable: Cancellable {
        func cancel() {}
    }

}
Run Code Online (Sandbox Code Playgroud)