【问题标题】:How does a view obtain data using a view model and Network API视图如何使用视图模型和网络 API 获取数据
【发布时间】:2021-02-24 11:27:20
【问题描述】:

我正在尝试使用此帮助文件获取一些数据: https://gist.github.com/jbfbell/e011c5e4c3869584723d79927b7c4b68

这是重要代码的sn-p:

/// Base class for requests to the Alpha Vantage Stock Data API.  Intended to be subclasssed, but can
/// be used directly if library does not support a new api.
class AlphaVantageRequest : ApiRequest {

    private static let alphaApi = AlphaVantageRestApi()
    let method = "GET"
    let path = ""
    let queryStringParameters : Array<URLQueryItem>
    let api : RestApi = AlphaVantageRequest.alphaApi

    var responseJSON : [String : Any]? {
        didSet {
            if let results = responseJSON {
            print(results)
        }
    }
  }
}

扩展 ApiRequest

    /// Makes asynchronous call to fetch response from server, stores response on self
    ///
    /// - Returns: self to allow for chained method calls
    public func callApi() -> ApiRequest {
        guard let apiRequest = createRequest() else {
            print("No Request to make")
            return self
        }
        let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
        let dataTask = session.dataTask(with: apiRequest) {(data, response, error) in
            guard error == nil else {
            print("Error Reaching API, \(String(describing: apiRequest.url))")
            return
            }
            self.receiveResponse(data)
        }
        dataTask.resume()
        return self
    }
我的目标是在加载 url 请求的数据后从 responseJSON 中获取数据。

我的 ViewModel 目前看起来像这样:

class CompanyViewModel: ObservableObject {
    
    var companyOverviewRequest: ApiRequest? {
        didSet {
            if let response = companyOverviewRequest?.responseJSON {
                print(response)
            }
        }
    }
    
    private var searchEndpoint: SearchEndpoint
    
    init(companyOverviewRequest: AlphaVantageRequest? = nil,
         searchEndpoint: SearchEndpoint) {
        
        self.companyOverviewRequest = CompanyOverviewRequest(symbol: searchEndpoint.symbol)
    }
    
    
    func fetchCompanyOverview() {
        
        guard let request = self.companyOverviewRequest?.callApi() else { return }
        self.companyOverviewRequest = request

    }
    
}

所以在我的 ViewModel 中,didSet 被调用一次,但不是在它应该存储数据的时候。 AlphaVantageRequest 的结果总是能正确打印出来,但不是在我的 ViewModel 中。我怎样才能在我的 ViewModel 中也有加载的数据?

【问题讨论】:

  • 我强烈建议使用您自己的“API”层来使用 Combine,它只是 URLSession 的一个薄包装器。你会发现很多例子在 5 行代码中实现了获取、解码和错误处理。无需使用这种复杂的 API 层,它也有几个严重的缺陷。
  • 一个更好的问题标题是“视图如何使用视图模型和网络 API 获取数据,同时添加标签“组合”。

标签: api swiftui protocols viewmodel combine


【解决方案1】:

当您使用作为 ObservableObject 的视图模型时,您的视图想要观察 已发布 属性,通常是 viewState(MVVM 术语):

class CompanyViewModel: ObservableObject {
    enum ViewState {
        case undefined
        case value(Company)
    }
    @Published var viewState: ViewState = .undefined

viewState 完全描述了您的视图将如何呈现。请注意,它可以是 undefined - 您的视图应该能够处理它。 添加loading(Company?) 案例也是一个好主意。然后,您的视图可以呈现加载指示器。请注意,加载还提供可选的公司值。然后你可以渲染一个“刷新”,在这种情况下你已经有了一个公司值,同时还绘制了一个加载指示器。

为了从端点获取一些数据,您可以使用以下抽象:

public protocol HTTPClient: class {
    func publisher(for request: URLRequest) -> AnyPublisher<HTTPResponse, Swift.Error>
}

这可以通过使用 5 行代码的 URLSession 的简单包装器来实现。然而,符合标准的类型可以做更多事情:它可以处理身份验证、授权、它可以重试请求、刷新访问令牌或在用户需要身份验证的地方提供用户界面等。这个简单的协议足以完成所有这些。

那么,你的 ViewModel 是如何获取数据的呢?

引入另一个抽象是有意义的:“UseCase”执行此任务,而不是让视图模型直接使用 HTTP 客户端。 “用例”只是一个执行任务、接受输入并产生输出或错误的对象。您可以随意命名它,“DataProvider”、“ContentProvider”或类似的名称。不过,“用例”是一个众所周知的术语。 从概念上讲,它具有与 HTTP 客户端类似的 API,但在语义上它位于更高级别:

public protocol UseCase {
    associatedtype Input: Encodable
    associatedtype Output: Decodable
    associatedtype Error

    func callAsFunction(with input: Input) -> AnyPublisher<Output, Error>
}

让我们创建一个“GetCompany”用例:

struct Company: Codable {
    var name: String
    var id: Int
}

struct GetCompanyUseCase: UseCase {
    typealias Input = Int
    typealias Output = Company
    typealias Error = Swift.Error

    private let httpClient: HTTPClient

    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }

    func callAsFunction(with id: Int) -> AnyPublisher<Company, Swift.Error> {
        let request = composeURLRequest(input: id)
        return httpClient.publisher(for: request)
            .tryMap { httpResponse in
                switch httpResponse {
                case .success(_, let data):
                    return data
                default:
                    throw "invalid status code"
                }
            }
            .decode(type: Company.self, decoder: JSONDecoder())
            .map { $0 } // no-op, usually you receive a "DTO.Company" value and transform it into your Company type.
            .eraseToAnyPublisher()
    }

    private func composeURLRequest(input: Int) -> URLRequest {
        let url = URL(string: "https://api.my.com/companies?id=\(input)")!
        return URLRequest(url: url)
    }
}

所以,这个用例清楚地访问了我们的 HTTP 客户端。我们可以实现这个访问 CoreData,或者从文件中读取,或者使用一个 mock 等等。API 总是一样的,视图模型并不关心。这里的美妙之处在于,您可以将其切换并换入另一个,视图模型仍然有效,您的视图也是如此。 (为了让它真的很酷,你可以创建一个AnyUseCase 泛型类型,这很容易,在这里你可以进行依赖注入)。

现在让我们看看视图模型的外观以及它如何使用用例:

class CompanyViewModel: ObservableObject {
    enum ViewState {
        case undefined
        case value(Company)
    }
    @Published var viewState: ViewState = .undefined

    let getCompany: GetCompanyUseCase
    var getCompanyCancellable: AnyCancellable?

    init(getCompany: GetCompanyUseCase) {
        self.getCompany = getCompany
    }

    func load() {
        self.getCompanyCancellable =
        self.getCompany(with: 1)
            .sink { (completion) in
                print(completion)
            } receiveValue: { (company) in
                self.viewState = .value(company)
                print("company set to: \(company)")
            }
    }
}

load函数触发用例,调用底层http客户端加载公司数据。

当 UseCase 返回一个公司时,它将被分配视图状态。观察者(视图或 ViewController)将收到有关更改的通知并可以执行更新。

您可以在 Playground 中试验代码。以下是缺少的平安:

import Foundation
import Combine

extension String: Swift.Error {}

public enum HTTPResponse {
    case information(response: HTTPURLResponse, data: Data)
    case success(response: HTTPURLResponse, data: Data)
    case redirect(response: HTTPURLResponse, data: Data)
    case clientError(response: HTTPURLResponse, data: Data)
    case serverError(response: HTTPURLResponse, data: Data)
    case custom(response: HTTPURLResponse, data: Data)
}

class MockHTTPClient: HTTPClient {
    func publisher(for request: URLRequest) -> AnyPublisher<HTTPResponse, Swift.Error> {
        let json = #"{"id": 1, "name": "Some Corporation"}"#.data(using: .utf8)!
        let url = URL(string: "https://api.my.com/companies")!
        let httpUrlResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
        let response: HTTPResponse = .success(response: httpUrlResponse, data: json)
        return Just(response)
            .mapError { _ in "no error" }
            .eraseToAnyPublisher()
    }
}

组装:

let httpClient = MockHTTPClient()
let getCompany = GetCompany(httpClient: httpClient)
let viewModel = CompanyViewModel(getCompany: getCompany)
viewModel.load()

【讨论】:

  • 非常感谢您的详细解答!我现在完成了获取数据。 viewState 的实现与我之前在 SwiftUI 中所做的完全不同。但这是一种以不同方式呈现视图的好方法。
  • SwiftUI 视图实际上总是观察它们的视图状态。上面只是将 SwiftUI 视图和视图模型进行了更清晰的分离。它更有条理,旨在为您如何构建应用程序定义一个约定。您也可以在 UIKit 中使用这种方法:在 ViewController 中导入 Combine 并观察视图状态,或者 - 如果您不想在 ViewControllers 中使用 Combine,请使用将观察视图状态转换为“ Presenter”,它将视图状态“推送”到视图,例如 presenter.view.updateModel(viewState)
  • 是否可以有两个带有数据的视图状态?我提出了一个链式请求,总共有 4 个请求。其中两个请求需要很长时间(其股票市场数据的每日点数超过 20 年)。为了用户体验,如果我可以刷新视图会更好,当前两个请求完成后,将它们存储在 viewState 中,然后等待接下来的两个请求。我想有两个不同的 ViewModel 并将结果组合到一个顶级 ViewModel 中。
  • 是的,这可以通过多种方式实现:UseCase 返回一个“Publisher”,这基本上意味着,它可以产生多个值,并且该值可能会增量更新。请注意,发布者(每个设计)可以随时产生值,即使没有用户操作。或者,您在视图模型中使用多个“@published”值,每个值负责定义用于呈现视图的某个“子视图”的状态。提示:在第一次尝试中,尽可能简单:视图模型等到内容数据完成,然后更新视图状态。
猜你喜欢
  • 1970-01-01
  • 2012-08-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-04-04
  • 2014-08-21
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多