Combine & SwiftUI with a custom Publisher - 使用 .assign 订阅者时的意外行为
Combine & SwiftUI with a custom Publisher - unexpected behaviour when using .assign subscriber
我创建了一个用于 Realm 数据库的自定义 Publisher,它似乎可以独立运行,但不想与 Swift 很好地配合使用UI。
我已将问题隔离到视图模型和 Swift 之间的接口UI。根据各种 属性 观察者和 .print() 语句的结果,视图模型的行为似乎符合预期(由 'state' 属性 表示)报告为空,因此空白 UI。
有趣的是,如果我用 Realm Results 查询的直接数组转换替换我的 Combine 代码,UI 会按预期显示(尽管我还没有为项目 added/deleted,等等)。
我怀疑我看不到所有树木的木材,因此非常感谢外部视角和指导:-)
下面的代码库 - 我已经遗漏了大部分 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()
}
}
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
}
}
ViewModel
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: [=15=], 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: [=15=], patientService: patientService)) } }
.map { PatientListState(patients: [=15=]) }
.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
}
}
PatientDetailView
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()
}
}
不确定其中 one/both 个 is/are 是否是实际问题,但这些地方值得一看:
(1) 异步代码在 PatientListView
出现之前不执行 assign(to:on:)
的竞争条件。
(2) 您正在后台线程上接收结果。
对于后者,请务必在 assign(to:on:)
之前使用 receive(on: RunLoop.main)
,因为 state
正在被 UI 使用。您可以将 .eraseToAnyPublisher()
替换为 receive(on:)
,因为在当前情况下您并不真正需要类型擦除(它不会破坏任何东西,但不需要)。
let publishedState = Publishers.realm(collection: patientService.allPatientsAsResults())
.print()
.map { results in
results.map { AnyViewModel(PatientDetailViewModel(patient: [=10=], patientService: patientService)) } }
.map { PatientListState(patients: [=10=]) }
.receive(on: RunLoop.main)
.assign(to: \.state, on: self)
.store(in: &cancellables)
我创建了一个用于 Realm 数据库的自定义 Publisher,它似乎可以独立运行,但不想与 Swift 很好地配合使用UI。
我已将问题隔离到视图模型和 Swift 之间的接口UI。根据各种 属性 观察者和 .print() 语句的结果,视图模型的行为似乎符合预期(由 'state' 属性 表示)报告为空,因此空白 UI。
有趣的是,如果我用 Realm Results 查询的直接数组转换替换我的 Combine 代码,UI 会按预期显示(尽管我还没有为项目 added/deleted,等等)。
我怀疑我看不到所有树木的木材,因此非常感谢外部视角和指导:-)
下面的代码库 - 我已经遗漏了大部分 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()
}
}
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
}
}
ViewModel
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: [=15=], 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: [=15=], patientService: patientService)) } }
.map { PatientListState(patients: [=15=]) }
.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
}
}
PatientDetailView
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()
}
}
不确定其中 one/both 个 is/are 是否是实际问题,但这些地方值得一看:
(1) 异步代码在 PatientListView
出现之前不执行 assign(to:on:)
的竞争条件。
(2) 您正在后台线程上接收结果。
对于后者,请务必在 assign(to:on:)
之前使用 receive(on: RunLoop.main)
,因为 state
正在被 UI 使用。您可以将 .eraseToAnyPublisher()
替换为 receive(on:)
,因为在当前情况下您并不真正需要类型擦除(它不会破坏任何东西,但不需要)。
let publishedState = Publishers.realm(collection: patientService.allPatientsAsResults())
.print()
.map { results in
results.map { AnyViewModel(PatientDetailViewModel(patient: [=10=], patientService: patientService)) } }
.map { PatientListState(patients: [=10=]) }
.receive(on: RunLoop.main)
.assign(to: \.state, on: self)
.store(in: &cancellables)