swift - 将 SwiftUI 与自定义发布者结合使用 - 使用 .assign 订阅者时出现意外行为
问题描述
我创建了一个自定义 Publisher 用于与 Realm 数据库一起使用,该数据库似乎在隔离时按预期运行,但不想与 SwiftUI 很好地配合使用。
我已将问题隔离到视图模型和 SwiftUI 之间的接口。根据我为寻找错误而投入的各种属性观察器和 .print() 语句的结果,视图模型的行为似乎符合预期,但超出了视图模型的范围,即视图模型的数据存储库(由'state' 属性)报告为空,因此 UI 为空白。
有趣的是,如果我用 Realm Results 查询的直接数组转换替换我的 Combine 代码,UI 会按预期显示(尽管我没有为添加/删除项目时的动态更新实现通知令牌等)。
我怀疑我看不到所有树木的木材,因此非常感谢外部视角和指导:-)
下面的代码库 - 我大部分都省略了 Apple 生成的样板。
场景委托:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let patientService = MockPatientService()
let viewModel = AnyViewModel(PatientListViewModel(patientService: patientService))
print("#(function) viewModel contains \(viewModel.state.patients.count) patients")
let contentView = PatientListView()
.environmentObject(viewModel)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
病人.swift
import Foundation
import RealmSwift
@objcMembers final class Patient: Object, Identifiable {
dynamic let id: String = UUID().uuidString
dynamic var name: String = ""
required init() {
super.init()
}
init(name: String) {
self.name = name
}
}
病人服务
import Foundation
import RealmSwift
@objcMembers final class Patient: Object, Identifiable {
dynamic let id: String = UUID().uuidString
dynamic var name: String = ""
required init() {
super.init()
}
init(name: String) {
self.name = name
}
}
视图模型
import Foundation
import Combine
protocol ViewModel: ObservableObject where ObjectWillChangePublisher.Output == Void {
associatedtype State // the type of the state of a given scene
associatedtype Input // inputs to the view model that are transformed by the trigger method
var state: State { get }
func trigger(_ input: Input)
}
final class AnyViewModel<State, Input>: ObservableObject { // wrapper enables "effective" (not true) type erasure of the view model
private let wrappedObjectWillChange: () -> AnyPublisher<Void, Never>
private let wrappedState: () -> State
private let wrappedTrigger: (Input) -> Void
var objectWillChange: some Publisher {
wrappedObjectWillChange()
}
var state: State {
wrappedState()
}
func trigger(_ input: Input) {
wrappedTrigger(input)
}
init<V: ViewModel>(_ viewModel: V) where V.State == State, V.Input == Input {
self.wrappedObjectWillChange = { viewModel.objectWillChange.eraseToAnyPublisher() }
self.wrappedState = { viewModel.state }
self.wrappedTrigger = viewModel.trigger
}
}
extension AnyViewModel: Identifiable where State: Identifiable {
var id: State.ID {
state.id
}
}
RealmCollectionPublisher
import Foundation
import Combine
import RealmSwift
// MARK: Custom publisher - produces a stream of Object arrays in response to change notifcations on a given Realm collection
extension Publishers {
struct Realm<Collection: RealmCollection>: Publisher {
typealias Output = Array<Collection.Element>
typealias Failure = Never // TODO: Not true but deal with this later
let collection: Collection
init(collection: Collection) {
self.collection = collection
}
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = RealmSubscription(subscriber: subscriber, collection: collection)
subscriber.receive(subscription: subscription)
}
}
}
// MARK: Convenience accessor function to the custom publisher
extension Publishers {
static func realm<Collection: RealmCollection>(collection: Collection) -> Publishers.Realm<Collection> {
return Publishers.Realm(collection: collection)
}
}
// MARK: Custom subscription
private final class RealmSubscription<S: Subscriber, Collection: RealmCollection>: Subscription where S.Input == Array<Collection.Element> {
private var subscriber: S?
private let collection: Collection
private var notificationToken: NotificationToken?
init(subscriber: S, collection: Collection) {
self.subscriber = subscriber
self.collection = collection
self.notificationToken = collection.observe { (changes: RealmCollectionChange) in
switch changes {
case .initial:
// Results are now populated and can be accessed without blocking the UI
print("Initial")
subscriber.receive(Array(collection.elements))
case .update(_, let deletions, let insertions, let modifications):
print("Updated")
subscriber.receive(Array(collection.elements))
case .error(let error):
fatalError("\(error)")
#warning("Impl error handling - do we want to fail or log and recover?")
}
}
}
func request(_ demand: Subscribers.Demand) {
// no impl as RealmSubscriber is effectively just a sink
}
func cancel() {
print("Cancel called on RealnSubscription")
subscriber = nil
notificationToken = nil
}
deinit {
print("RealmSubscription de-initialised")
}
}
患者列表视图模型
class PatientListViewModel: ViewModel {
@Published var state: PatientListState = PatientListState(patients: [AnyViewModel<PatientDetailState, Never>]()) {
willSet {
print("Current PatientListState : \(newValue)")
}
}
private let patientService: PatientService
private var cancellables = Set<AnyCancellable>()
init(patientService: PatientService) {
self.patientService = patientService
// Scenario 1 - This code sets state which is correctly shown in UI (although not dynamically updated)
let viewModels = patientService.allPatientsAsArray()
.map { AnyViewModel(PatientDetailViewModel(patient: $0, patientService: patientService)) }
self.state = PatientListState(patients: viewModels)
// Scenario 2 (BUGGED) - This publisher's downstream emissions update dynamically, downstream outputs are correct and the willSet observer suggests .assign is working
// but the UI does not reflect the changes (if the above declarative code is removed, the number of patients is always zero)
let publishedState = Publishers.realm(collection: patientService.allPatientsAsResults())
.print()
.map { results in
results.map { AnyViewModel(PatientDetailViewModel(patient: $0, patientService: patientService)) } }
.map { PatientListState(patients: $0) }
.eraseToAnyPublisher()
.assign(to: \.state, on: self)
.store(in: &cancellables)
}
func trigger(_ input: PatientListInput) {
switch(input) {
case .delete(let indexSet):
let patient = state.patients[indexSet.first!].state.patient
patientService.deletePatient(patient)
print("Deleting item at index \(indexSet.first!) - patient is \(patient)")
#warning("Know which patient to remove but need to ensure the state is updated")
}
}
deinit {
print("Viewmodel being deinitialised")
}
}
患者列表视图
struct PatientListState {
var patients: [AnyViewModel<PatientDetailState, Never>]
}
enum PatientListInput {
case delete(IndexSet)
}
struct PatientListView: View {
@EnvironmentObject var viewModel: AnyViewModel<PatientListState, PatientListInput>
var body: some View {
NavigationView {
VStack {
Text("Patients: \(viewModel.state.patients.count)")
List {
ForEach(viewModel.state.patients) { viewModel in
PatientCell(patient: viewModel.state.patient)
}
.onDelete(perform: deletePatient)
}
.navigationBarTitle("Patients")
}
}
}
private func deletePatient(at offset: IndexSet) {
viewModel.trigger(.delete(offset))
}
}
患者详情视图模型
class PatientDetailViewModel: ViewModel {
@Published private(set) var state: PatientDetailState
private let patientService: PatientService
private let patient: Patient
init(patient: Patient, patientService: PatientService) {
self.patient = patient
self.patientService = patientService
self.state = PatientDetailState(patient: patient)
}
func trigger(_ input: Never) {
// TODO: Implementation
}
}
患者详情视图
struct PatientDetailState {
let patient: Patient
var name: String {
patient.name
}
}
extension PatientDetailState: Identifiable {
var id: Patient.ID {
patient.id
}
}
struct PatientDetailView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct PatientDetailView_Previews: PreviewProvider {
static var previews: some View {
PatientDetailView()
}
}
解决方案
不确定其中一个/两个是否是实际问题,但是是很好的地方:(1)异步代码assign(to:on:)
在出现之前没有执行的竞争条件PatientListView
。(2) 您正在后台线程上接收结果。
对于后者,请务必receive(on: RunLoop.main)
在您的 , 之前使用assign(to:on:)
,因为state
UI 正在使用它。您可以将 替换为.eraseToAnyPublisher()
,receive(on:)
因为在当前场景中您真的不需要类型擦除(它不会破坏任何东西,但不需要)。
let publishedState = Publishers.realm(collection: patientService.allPatientsAsResults())
.print()
.map { results in
results.map { AnyViewModel(PatientDetailViewModel(patient: $0, patientService: patientService)) } }
.map { PatientListState(patients: $0) }
.receive(on: RunLoop.main)
.assign(to: \.state, on: self)
.store(in: &cancellables)
推荐阅读
- javascript - 调用后端 API 时处理 javascript/react 中的错误
- r - 在 R 中,如何根据列值替换字符串的所有实例
- node.js - 无法扩展从 mongoDb 查询返回的数组内的对象键
- firebase - 如何从 Cloud Firestore 中的索引中排除字段?
- ssis - SQL 命令不会获取任何数据 SSIS
- python - 如何在 Python 中用一个镜像相对于另一个镜像绘制两个直方图?
- server - 我在我的网站索引页面上发现了一个可疑的 php 脚本,有人可以向我解释一下吗
- reactjs - 如何在 reactJs 中使用相同的组件渲染两个不同的反应路线?
- javascript - 在没有 javascript 互操作的情况下从 Blazor 设置 HTMLMediaElement.playbackRate
- r - 如何合并R中的两列?