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)