在 SwiftUI 中显示异步 Api 调用的状态

Displaying State of an Async Api call in SwiftUI

此问题基于 。基本上,当按下某个按钮时,我会对 Google Books Api 进行异步调用。虽然当它是 View 的方法时我得到了调用,但是我想在它加载时覆盖 activity 指示器。因此,我尝试制作一个 ObservableObject 来代替调用,但我不确定该怎么做。

这是我目前的情况:

class GoogleBooksApi: ObservableObject {
    
    enum LoadingState<Value> {
        case loading(Double)
        case loaded(Value)
    }
    
    @Published var state: LoadingState<GoogleBook> = .loading(0.0)
    
    enum URLError : Error {
        case badURL
    }

    func fetchBook(id identifier: String) async throws {
        var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
        components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
        guard let url = components?.url else { throw URLError.badURL }
        self.state = .loading(0.25)
        
        let (data, _) = try await URLSession.shared.data(from: url)
        self.state = .loading(0.75)
        self.state = .loaded(try JSONDecoder().decode(GoogleBook.self, from: data))
    }
}


struct ContentView: View {
    @State var name: String = ""
    @State var author: String = ""
    @State var total: String = ""
    
    @State var code = "ISBN"
    
    @ObservedObject var api: GoogleBooksApi
    
    var body: some View {
        VStack {
            Text("Name: \(name)")
            Text("Author: \(author)")
            Text("total: \(total)")

            Button(action: {
                code = "978-0441013593"
                Task {
                    do {
                        try await api.fetchBook(id: code)
                        let fetchedBooks = api.state
                        let book = fetchedBooks.items[0].volumeInfo
                        name = book.title
                        author = book.authors?[0] ?? ""
                        total = String(book.pageCount!)
                    } catch {
                        print(error)
                    }
                }
            }, label: {
                Rectangle()
                    .frame(width: 200, height: 100)
                    .foregroundColor(.blue)
            })
        }
    }
}

// MARK: - GoogleBook
struct GoogleBook: Codable {
    let kind: String
    let totalItems: Int
    let items: [Item]
}

// MARK: - Item
struct Item: Codable {
    let id, etag: String
    let selfLink: String
    let volumeInfo: VolumeInfo
}

// MARK: - VolumeInfo
struct VolumeInfo: Codable {
    let title: String
    let authors: [String]?
    let pageCount: Int?
    let categories: [String]?

    enum CodingKeys: String, CodingKey {
        case title, authors
        case pageCount, categories
    }
}

这是没有加载状态的工作方式:

struct ContentView: View {
    @State var name: String = ""
    @State var author: String = ""
    @State var total: String = ""
    
    @State var code = "ISBN"
    
    enum URLError : Error {
        case badURL
    }

    private func fetchBook(id identifier: String) async throws -> GoogleBook {
        guard let encodedString = "https://www.googleapis.com/books/v1/volumes?q={\(identifier)}"
                                  .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: encodedString) else { throw URLError.badURL}
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(GoogleBook.self, from: data)
    }
    
    var body: some View {
        VStack {
            Text("Name: \(name)")
            Text("Author: \(author)")
            Text("total: \(total)")

            Button(action: {
                code = "978-0441013593"
                Task {
                    do {
                        let fetchedBooks = try await fetchBook(id: code)
                        let book = fetchedBooks.items[0].volumeInfo
                        name = book.title
                        author = book.authors?[0] ?? ""
                        total = String(book.pageCount!)
                    } catch {
                        print(error)
                    }
                }
            }, label: {
                Rectangle()
                    .frame(width: 200, height: 100)
                    .foregroundColor(.blue)
            })
        }
    }
}

// MARK: - GoogleBook
struct GoogleBook: Codable {
    let kind: String
    let totalItems: Int
    let items: [Item]
}

// MARK: - Item
struct Item: Codable {
    let id, etag: String
    let selfLink: String
    let volumeInfo: VolumeInfo
}

// MARK: - VolumeInfo
struct VolumeInfo: Codable {
    let title: String
    let authors: [String]?
    let pageCount: Int?
    let categories: [String]?

    enum CodingKeys: String, CodingKey {
        case title, authors
        case pageCount, categories
    }
}

您似乎没有初始化 GoogleBooksApi

@ObservedObject var api: GoogleBooksApi

没有任何可以修改的初始化。

除此之外 - 我建议使用 @StateObject(前提是您的部署目标是最低 iOS 14.0)。使用 ObservableObject 可能会导致 GoogleBooksApi 的多次初始化(而您只需要一次)

You should use @StateObject for any observable properties that you initialize in the view that uses it. If the ObservableObject instance is created externally and passed to the view that uses it mark your property with @ObservedObject.

我会更进一步,添加 idlefailed 状态。

然后不抛出错误而是将状态更改为 failed 并传递错误描述。我从 loading 状态中删除了 Double 值以仅显示旋转的 ProgressView

@MainActor
class GoogleBooksApi: ObservableObject {
    
    enum LoadingState {
        case idle
        case loading
        case loaded(GoogleBook)
        case failed(Error)
    }
    
    @Published var state: LoadingState = .idle
    
    func fetchBook(id identifier: String) async {
        var components = URLComponents(string: "https://www.googleapis.com/books/v1/volumes")
        components?.queryItems = [URLQueryItem(name: "q", value: "isbn=\(identifier)")]
        guard let url = components?.url else { state = .failed(URLError(.badURL)); return }
        self.state = .loading
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = try JSONDecoder().decode(GoogleBook.self, from: data)
            self.state = .loaded(response)
        } catch {
            state = .failed(error)
        }
    }
}

在视图中您必须 switchstate 上显示不同的视图。 而且——非常重要——你必须将可观察对象声明为 @StateObject。这是一个非常简单的实现

struct ContentView: View {
      @State var code = "ISBN"
      
      @StateObject var api = GoogleBooksApi()
      
      var body: some View {
          VStack {
              switch api.state {
                  case .idle: EmptyView()
                  case .loading: ProgressView()
                  case .loaded(let books):
                      if let info = books.items.first?.volumeInfo {
                          Text("Name: \(info.title)")
                          Text("Author: \(info.authors?.joined(separator: ", ") ?? "")")
                          Text("total: \(books.totalItems)")
                      }
                  case .failed(let error): 
                      if error is DecodingError {
                          Text(error.description)
                      } else {
                          Text(error.localizedDescription)
                      }
              }

              Button(action: {
                  code = "978-0441013593"
                  Task {
                    await api.fetchBook(id: code)
                  }
              }, label: {
                  Rectangle()
                      .frame(width: 200, height: 100)
                      .foregroundColor(.blue)
              })
          }
      }
}