swift - 将 SwiftUI 与自定义发布者结合起来 - 使用 .assign 订阅者时出现意外行为

标签 swift realm swiftui combine

我创建了一个与 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()
    }
}

**Using arrays:** **Using custom Publisher**

最佳答案

不确定其中一个/两个是否是实际问题,但都是值得一看的好地方: (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/

相关文章:

security - 在 Glassfish 上进行 Realm 身份验证后重定向

swift - 在迁移期间删除属性

ios - SwiftUI - 从数组中删除项目导致 fatal error : Index out of Range

macos - SwiftUI 对悬停事件不是很敏感

ios - 在 Mapbox 的导航 View 上添加自定义按钮

ios - MPMoviePlayerController 未停留在 UIView 的范围内

ios - 使用 SwiftyJSON 解析 JSON - 更新 UI 时遇到问题

ios - 当一个对象的属性同时被另外两个对象设置时,CPU 会达到 %100

ios - 适用于 iOS 的 Realm DB 抛出模式不匹配错误,提供的模式版本 84 低于最后设置的版本 86

ios - 如何在 SwiftUI 中更改导航栏标题的文本属性?