结合 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:)
。
这样做的一个好处是您的代码朝着可测试的方向迈出了一步。
您可以将catch
与Empty(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
协议的 DataFetcher
和 DefaultHTTPClient
:
数据提取器
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 结合 Publisher targetstruct 来解码数据