视图如何使用视图模型和网络获取数据API

How does a view obtain data using a view model and Network API

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

这是重要代码的片段:

Class

/// 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 中也加载数据?

当您使用作为 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 { [=13=] } // 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,或从文件读取,或使用模拟等。 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客户端加载公司数据。

当UseCasereturns一个公司时,它会被分配视图状态。观察者(视图,或 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()
    }
}

Assemble:

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