web-dev-qa-db-ja.com

SwiftUIリモートフェッチデータの結合-ObjectBindingはビューを更新しません

私は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)
    }
}
4
christinam

これは、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)
    }
1
christinam

更新されたソリューションに基づいて、ここにいくつかの改善提案があります(コメントには収まりません)。

PhotosViewModel改善の提案

load関数をVoidを返す(つまり何も返さない)から、_AnyPublisher<Photos, Never>_を返し、最後のステップ.assign(to:on:)をスキップするように変更することをお勧めします。 。

これの利点の1つは、コードがテスト可能になるための一歩を踏み出すことです。

replaceErrorの代わりにデフォルト値を使用すると、catchEmpty(completeImmediately: <TRUE/FALSE>)と一緒に使用できます。関連するデフォルト値を思い付くことが常に可能であるためですか?たぶんこの場合?たぶん「空の写真」?その場合は、PhotosExpressibleByArrayLiteralに準拠させて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
Sajjon