结合 SwiftUI 远程获取数据 - ObjectBinding 不更新视图

Posted

技术标签:

【中文标题】结合 SwiftUI 远程获取数据 - ObjectBinding 不更新视图【英文标题】:Combine SwiftUI Remote Fetch Data - ObjectBinding doesn't update view 【发布时间】:2019-12-27 19:07:40 【问题描述】:

我正在尝试学习 Combine,它对我来说是一个 PITA。我从来没有学过 RX Swift,所以这对我来说是全新的。我确信我错过了一些简单的东西,但希望能得到一些帮助。

我正在尝试从 API 获取一些 JSON 并将其加载到列表视图中。我有一个符合 ObservableObject 并更新 @Published 属性的视图模型,该属性是一个数组。我使用该 VM 来加载我的列表,但看起来视图在此 API 返回之前加载方式(列表显示为空白)。我希望这些属性包装器能做我认为他们应该做的事情,并在对象发生变化时重新渲染视图。

就像我说的,我确信我错过了一些简单的东西。如果你能发现它,我会很乐意提供帮助。谢谢!

class PhotosViewModel: ObservableObject 

    var cancellable: AnyCancellable?

    @Published var photos = Photos()

    func load(user collection: String) 
        guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else 
            return
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map  $0.data 
            .decode(type: Photos.self, decoder: JSONDecoder())
            .replaceError(with: defaultPhotosObject)
            .receive(on: RunLoop.main)
            .assign(to: \.photos, on: self)
    


struct PhotoListView: View 
    @EnvironmentObject var photosViewModel: PhotosViewModel
    var body: some View 
        NavigationView 
            List(photosViewModel.photos)  photo in
                NavigationLink(destination: PhotoDetailView(photo)) 
                    PhotoRow(photo)
                
            .navigationBarTitle("Photos")
        
    

struct PhotoRow: View 
    var photo: Photo
    init(_ photo: Photo) 
        self.photo = photo
    
    var body: some View 
        HStack 
            ThumbnailImageLoadingView(photo.coverPhoto.urls.thumb)
            VStack(alignment: .leading) 
                Text(photo.title)
                    .font(.headline)
                Text(photo.user.firstName)
                    .font(.body)
            
            .padding(.leading, 5)
        
        .padding(5)
    

【问题讨论】:

我没有看到提供的代码有什么问题,所以不存在问题。两个问题:1)你在哪里实例化PhotosViewModel? 2)你在哪里打电话load? ...虽然我不确定这个.replaceError(with: []) - 你测试过 API 是否返回了正确的数据吗? 谢谢@Asperi。所以,因为 API 返回的速度不够快,所以我在 SceneDelegate 中实例化 VM 并在那里调用 load()。起初,我在列表视图中同时进行这两项操作。将在 replaceError 上回复您 好电话,我使用 QuickType 编写模型,看起来那里出了点问题。我将不得不为此做一些手动工作。我会在可以修复的时候更新帖子! Photos 类是什么? photos 属性可能是引用,当您在网络发布者中创建 assign 时可能不会更改(它指向的值会更改,但这不会触发重绘)。试着让它只是一个照片数组,看看会发生什么,而不是一个发布的对象。 【参考方案1】:

根据您更新的解决方案,这里有一些改进建议(不适合评论)。

PhotosViewModel改进建议

我是否可以建议将您的 load 函数从返回 Void(即不返回任何内容)更改为返回 AnyPublisher<Photos, Never> 并跳过最后一步 .assign(to:on:)

这样做的一个好处是您的代码朝着可测试的方向迈出了一步。

您可以将catchEmpty(completeImmediately: <TRUE/FALSE>) 一起使用,而不是带有一些默认值的replaceError。因为总是可以提出任何相关的默认值?也许在这种情况下?也许是“空照片”?如果是这样,那么您可以使Photos 符合ExpressibleByArrayLiteral 并使用replaceError(with: []),或者您可以创建一个名为empty 的静态变量,允许replaceError(with: .empty)

用一个代码块总结我的建议:

public class PhotosViewModel: ObservableObject 

    @Published var photos = Photos()

    // var cancellable: AnyCancellable? -> change to Set<AnyCancellable>
    private var cancellables = Set<AnyCancellable>()
    private let urlSession: URLSession

    public init(urlSession: URLSession = .init()) 
        self.urlSession = urlSession
    


private extension PhotosViewModel 
    func populatePhotoCollection(named nameOfPhotoCollection: String) 
        fetchPhotoCollection(named: nameOfPhotoCollection)
            .assign(to: \.photos, on: self)
            .store(in: &cancellables)
    

    func fetchPhotoCollection(named nameOfPhotoCollection: String) -> AnyPublisher<Photos, Never> 
        func emptyPublisher(completeImmediately: Bool = true) -> AnyPublisher<Photos, Never> 
            Empty<Photos, Never>(completeImmediately: completeImmediately).eraseToAnyPublisher()
        

        // This really ought to be moved to some APIClient
        guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else 
            return emptyPublisher()
        

        return urlSession.dataTaskPublisher(for: url)
            .map  $0.data 
            .decode(type: Photos.self, decoder: JSONDecoder())
            .catch  error -> AnyPublisher<Photos, Never> in
                print("☣️ error decoding: \(error)")
                return emptyPublisher()
            
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    

*Client建议

您可能想要编写某种 HTTPClient/APIClient/RESTClient 并查看 HTTP 状态代码。

这是一个高度模块化(有人可能会争辩 - 过度设计)的解决方案,使用符合 HTTPClient 协议的 DataFetcherDefaultHTTPClient

数据提取器

public final class DataFetcher 

    private let dataFromRequest:  (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>
    public init(dataFromRequest: @escaping  (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>) 
        self.dataFromRequest = dataFromRequest
    


public extension DataFetcher 
    func fetchData(request: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> 
        dataFromRequest(request)
    


// MARK: Convenience init
public extension DataFetcher 

    static func urlResponse(
        errorMessageFromDataMapper: ErrorMessageFromDataMapper,
        headerInterceptor: (([AnyHashable: Any]) -> Void)?,
        badStatusCodeInterceptor: ((UInt) -> Void)?,
        _ dataAndUrlResponsePublisher: @escaping (URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError>
    ) -> DataFetcher 

        DataFetcher  request in
            dataAndUrlResponsePublisher(request)
                .mapError  HTTPError.NetworkingError.urlError($0) 
                .tryMap  data, response -> Data in
                    guard let httpResponse = response as? HTTPURLResponse else 
                        throw HTTPError.NetworkingError.invalidServerResponse(response)
                    

                    headerInterceptor?(httpResponse.allHeaderFields)

                    guard case 200...299 = httpResponse.statusCode else 

                        badStatusCodeInterceptor?(UInt(httpResponse.statusCode))

                        let dataAsErrorMessage = errorMessageFromDataMapper.errorMessage(from: data) ?? "Failed to decode error from data"
                        print("⚠️ bad status code, error message: <\(dataAsErrorMessage)>, httpResponse: `\(httpResponse.debugDescription)`")
                        throw HTTPError.NetworkingError.invalidServerStatusCode(httpResponse.statusCode)
                    
                    return data
            
            .mapError  castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) 
            .eraseToAnyPublisher()

        
    

    // MARK: From URLSession
    static func usingURLSession(
        errorMessageFromDataMapper: ErrorMessageFromDataMapper,
        headerInterceptor: (([AnyHashable: Any]) -> Void)?,
        badStatusCodeInterceptor: ((UInt) -> Void)?,
        urlSession: URLSession = .shared
    ) -> DataFetcher 

        .urlResponse(
            errorMessageFromDataMapper: errorMessageFromDataMapper,
            headerInterceptor: headerInterceptor,
            badStatusCodeInterceptor: badStatusCodeInterceptor
        )  urlSession.dataTaskPublisher(for: $0).eraseToAnyPublisher() 
    


HTTPClient

public final class DefaultHTTPClient 
    public typealias Error = HTTPError

    public let baseUrl: URL

    private let jsonDecoder: JSONDecoder
    private let dataFetcher: DataFetcher

    private var cancellables = Set<AnyCancellable>()

    public init(
        baseURL: URL,
        dataFetcher: DataFetcher,
        jsonDecoder: JSONDecoder = .init()
    ) 
        self.baseUrl = baseURL
        self.dataFetcher = dataFetcher
        self.jsonDecoder = jsonDecoder
    


// MARK: HTTPClient
public extension DefaultHTTPClient 

    func perform(absoluteUrlRequest urlRequest: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> 
        return Combine.Deferred 
            return Future<Data, HTTPError.NetworkingError>  [weak self] promise in

                guard let self = self else 
                    promise(.failure(.clientWasDeinitialized))
                    return
                

                self.dataFetcher.fetchData(request: urlRequest)

                    .sink(
                        receiveCompletion:  completion in
                            guard case .failure(let error) = completion else  return 
                            promise(.failure(error))
                    ,
                        receiveValue:  data in
                            promise(.success(data))
                    
                ).store(in: &self.cancellables)
            
        .eraseToAnyPublisher()
    

    func performRequest(pathRelativeToBase path: String) -> AnyPublisher<Data, HTTPError.NetworkingError> 
        let url = URL(string: path, relativeTo: baseUrl)!
        let urlRequest = URLRequest(url: url)
        return perform(absoluteUrlRequest: urlRequest)
    

    func fetch<D>(urlRequest: URLRequest, decodeAs: D.Type) -> AnyPublisher<D, HTTPError> where D: Decodable 
        return perform(absoluteUrlRequest: urlRequest)
            .mapError  print("☢️ got networking error: \($0)"); return castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) 
            .mapError  HTTPError.networkingError($0) 
            .decode(type: D.self, decoder: self.jsonDecoder)
            .mapError  print("☢️ ? got decoding error: \($0)"); return castOrKill(instance: $0, toType: DecodingError.self) 
            .mapError  Error.serializationError(.decodingError($0)) 
            .eraseToAnyPublisher()
    



助手

public protocol ErrorMessageFromDataMapper 
    func errorMessage(from data: Data) -> String?



public enum HTTPError: Swift.Error 
    case failedToCreateRequest(String)
    case networkingError(NetworkingError)
    case serializationError(SerializationError)


public extension HTTPError 
    enum NetworkingError: Swift.Error 
        case urlError(URLError)
        case invalidServerResponse(URLResponse)
        case invalidServerStatusCode(Int)
        case clientWasDeinitialized
    

    enum SerializationError: Swift.Error 
        case decodingError(DecodingError)
        case inputDataNilOrZeroLength
        case stringSerializationFailed(encoding: String.Encoding)
    


internal func castOrKill<T>(
    instance anyInstance: Any,
    toType expectedType: T.Type,
    _ file: String = #file,
    _ line: Int = #line
) -> T 

    guard let instance = anyInstance as? T else 
        let incorrectTypeString = String(describing: Mirror(reflecting: anyInstance).subjectType)
        fatalError("Expected variable '\(anyInstance)' (type: '\(incorrectTypeString)') to be of type `\(expectedType)`, file: \(file), line:\(line)")
    
    return instance


【讨论】:

谢谢@Sajjon!我喜欢这两个建议,并希望在未来使用它。 @christinam 想给我一个赞成和/或接受者的答案吗? :)【参考方案2】:

这最终成为我的 Codable 结构未正确设置的问题。一旦我在 .replaceError 方法中添加了一个默认对象而不是一个空白数组(感谢@Asperi),我就能够看到解码错误并修复它。现在像魅力一样工作!

原文:

    func load(user collection: String) 
        guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else 
            return
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map  $0.data 
            .decode(type: Photos.self, decoder: JSONDecoder())
            .replaceError(with: [])
            .receive(on: RunLoop.main)
            .assign(to: \.photos, on: self)
    

更新:

    func load(user collection: String) 
        guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else 
            return
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map  $0.data 
            .decode(type: Photos.self, decoder: JSONDecoder())
            .replaceError(with: defaultPhotosObject)
            .receive(on: RunLoop.main)
            .assign(to: \.photos, on: self)
    

【讨论】:

以上是关于结合 SwiftUI 远程获取数据 - ObjectBinding 不更新视图的主要内容,如果未能解决你的问题,请参考以下文章

swiftui combine 无法获取数据 [关闭]

无法使用结合 SwiftUI 从 URL 获取响应

select2 - 将获取远程数据与多个选择和预数据相结合

SwiftUI 结合 Publisher targetstruct 来解码数据

在 SwiftUI 视图中结合 onChange 和 onAppear 事件?

将 MVC 之类的设计模式与 SwiftUI 结合使用