Объедините & SwiftUI с пользовательским издателем - неожиданное поведение при использовании подписчика .assign - PullRequest
0 голосов
/ 11 января 2020

Я создал собственный Publisher для использования с базой данных Realm, который, кажется, работает должным образом, но не хочет хорошо играть с SwiftUI.

Я изолировал проблему с интерфейсом между моделью представления и SwiftUI. Модель представления, кажется, ведет себя как ожидалось, основываясь на результатах различных наблюдателей свойств и операторов .print (), которые я добавила для поиска ошибок, но выходит за рамки модели представления, хранилища данных модели представления (представленного свойство 'state') отображается как пустое, следовательно, пустой интерфейс пользователя.

Интересно, что если я заменю свой код Combine прямым преобразованием массива запроса результатов области, пользовательский интерфейс отобразится, как и ожидалось (хотя я не реализовал токены уведомлений для динамического обновления c при добавлении элементов / удалено, et c).

Я подозреваю, что я не вижу древесину для всех деревьев, поэтому очень ценю внешнюю перспективу и рекомендации: -)

Кодовая база ниже - я пропустил сгенерированный Apple шаблон по большей части.

SceneDelegate:

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
    }
}

PatientService

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: $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
    }
}

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()
    }
}

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

1 Ответ

0 голосов
/ 12 января 2020

Не уверен, что одна / обе из них / являются действительной проблемой, но это хорошие места для поиска: (1) условие гонки, при котором асиновый код c не выполняет assign(to:on:) перед PatientListView появляется. (2) вы получаете результаты в фоновом потоке.

Для последнего обязательно используйте receive(on: RunLoop.main) перед вашим assign(to:on:), так как state используется пользовательским интерфейсом. Вы можете заменить .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)
...