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

时间:2019-12-27 19:07:40

标签: swift swiftui combine

我正在尝试学习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)
    }
}

2 个答案:

答案 0 :(得分:3)

根据您更新的解决方案,以下是一些改进建议(不适合在评论中显示)。

PhotosViewModel改进建议

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

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

您可以将replaceErrorcatch一起使用,而不是使用某些默认值的Empty(completeImmediately: <TRUE/FALSE>)。因为总是有可能提出任何相关的默认值?也许在这种情况下?也许是“空照片”?如果是这样,则可以使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状态代码。

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

DataFetcher

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
}

答案 1 :(得分:0)

这最终导致我的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)
    }