首页 > 解决方案 > 将 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()
    }
}

**使用数组:** **使用自定义发布者**

标签: swiftrealmswiftuicombine

解决方案


不确定其中一个/两个是否是实际问题,但是是很好的地方:(1)异步代码assign(to:on:)在出现之前没有执行的竞争条件PatientListView。(2) 您正在后台线程上接收结果。

对于后者,请务必receive(on: RunLoop.main)在您的 , 之前使用assign(to:on:),因为stateUI 正在使用它。您可以将 替换为.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)

推荐阅读