我创建了一个与 Realm 数据库一起使用的自定义 Publisher,该数据库似乎可以单独按预期运行,但不想与 SwiftUI 很好地配合。
我已将问题隔离到 View 模型和 SwiftUI 之间的接口(interface)。 View 模型的行为似乎与预期一致,基于来自各种属性观察器和 .print() 语句的结果,我为寻找错误而加入了这些语句,但超出了 View 模型的范围, View 模型的数据存储库(由'state' 属性)被报告为空,因此 UI 为空白。
有趣的是,如果我将组合代码替换为 Realm 结果查询的直接数组转换,UI 将按预期显示(尽管我尚未在添加/删除项目等时实现动态更新的通知 token )。
我怀疑我看不到所有树木的木材,因此非常感谢外部视角和指导:-)
下面的代码库 - 我省略了 Apple 生成的大部分样板。
场景委托(delegate):
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()
}
}
Patient.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
}
}
View 模型
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")
}
}
PatientListViewModel
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")
}
}
PatientListView
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))
}
}
PatientDetailViewModel
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
}
}
患者详细信息 View
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)
关于swift - 将 SwiftUI 与自定义发布者结合起来 - 使用 .assign 订阅者时出现意外行为,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/59698091/