使用URLSession为SwiftUI视图加载JSON数据的最佳实践

从iOS 13.4开始,使用URLSession为SwiftUI视图加载JSON数据的最佳实践是什么?

为了使讨论继续进行,这是我在上一个项目中提出的内容-我提取了一个简化的示例。非常感谢您对可能的改进提出的反馈意见/我很感兴趣:您有何不同之处?

  • Represent the loading process as a ObservableObject model class
  • Use URLSession.dataTaskPublisher for loading
  • Using Codable and JSONDecoder to decode the response to Swift types using the Combine support for decoding
  • Keep track of the state in the model as a @Published property so that the view can show loading/error states.
  • Keep track of the loaded results as a @Published property in a separate property for easy usage in SwiftUI (you could also use View#onReceive to subscribe to the publisher directly in SwiftUI but keeping the publisher encapsulated in the model class seemed more clean overall)
  • Use the SwiftUI .onAppear modifier to trigger the loading if not loaded yet.
  • Using the .overlay modifier is convenient to show a Progress/Error view depending on the state

Example code for that approach (also available in my 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() {}
    }

}