Single Source of Truth (SSOT) auf MVVM mit RxSwift & CoreData

In einer mobilen Anwendung muss häufig die folgende Funktionalität implementiert werden:



  1. Stellen Sie eine asynchrone Anforderung
  2. Binden Sie das Ergebnis im Hauptthread an verschiedene Ansichten
  3. Aktualisieren Sie gegebenenfalls die Datenbank auf dem Gerät asynchron in einem Hintergrundthread
  4. Wenn beim Ausführen dieser Vorgänge Fehler auftreten, wird eine Benachrichtigung angezeigt
  5. Beachten Sie das SSOT- Prinzip für die Datenrelevanz
  6. Testen Sie alles


Die Lösung dieses Problems wird durch den Architekturansatz von MVVM und den Frameworks RxSwift und CoreData erheblich vereinfacht .



Der unten beschriebene Ansatz verwendet reaktive Programmierprinzipien und ist nicht ausschließlich an RxSwift und CoreData gebunden . Auf Wunsch kann es auch mit anderen Tools implementiert werden.



Als Beispiel nehme ich einen Ausschnitt aus einer Anwendung, in der Verkäuferdaten angezeigt werden. Der Controller verfügt über zwei UILabel-Steckdosen für Telefonnummer und Adresse sowie einen UIButton zum Anrufen dieser Telefonnummer. ContactsViewController .



Lassen Sie mich die Implementierung vom Modell bis zur Ansicht erläutern.



Modell



Fragment der automatisch generierten Datei SellerContacts + CoreDataProperties aus DerivedSources

mit Attributen:



extension SellerContacts {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<SellerContacts> {
        return NSFetchRequest<SellerContacts>(entityName: "SellerContacts")
    }

    @NSManaged public var address: String?
    @NSManaged public var order: Int16
    @NSManaged public var phone: String?

}


Repository .



Methode zur Bereitstellung von Verkäuferdaten:



func sellerContacts() -> Observable<Event<[SellerContacts]>> {
        // 1
        Observable.merge([
            // 2
            context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
            // 3
            updater.sync()
        ])
    }


Hier wird SSOT implementiert . Eine Anfrage wird an CoreData gestellt und CoreData wird nach Bedarf aktualisiert. Alle Daten werden NUR von der Datenbank empfangen, und updater.sync () kann nur ein Ereignis mit einem Fehler generieren, NICHT jedoch mit Daten.



  1. Mit dem Merge-Operator können wir eine asynchrone Ausführung einer Abfrage an die Datenbank und deren Aktualisierung erreichen.
  2. Zum bequemen Erstellen einer Abfrage in der Datenbank wird RxCoreData verwendet
  3. Aktualisieren der Datenbank


weil Wenn ein asynchroner Ansatz zum Empfangen und Aktualisieren von Daten verwendet wird, müssen Sie Observable <Event <... >> verwenden . Dies ist erforderlich, damit der Abonnent beim Empfang eines Fehlers beim Empfang von Remote-Daten keinen Fehler empfängt, sondern nur diesen Fehler anzeigt und weiterhin auf Änderungen in CoreData reagiert . Dazu später mehr.



DatabaseUpdater

In der Beispielanwendung werden Remote-Daten aus Firebase Remote Config abgerufen . CoreData wird nur aktualisiert, wenn fetchAndActivate () mit dem Status .successFetchedFromRemote beendet wird .



Sie können jedoch auch andere Aktualisierungsbeschränkungen verwenden, z. B. nach Zeit.

Sync () -Methode zum Aktualisieren der Datenbank:



func sync<T>() -> Observable<Event<T>> {
        // 1
        // Check can fetch
        if fetchLimiter.fetchInProcess {
            return Observable.empty()
        }
        // 2
        // Block fetch for other requests
        fetchLimiter.fetchInProcess = true
        // 3
        // Fetch & activate remote config
        return remoteConfig.rx.fetchAndActivate().flatMap { [weak self] status, error -> Observable<Event<T>> in
            // 4
            // Default result
            var result = Observable<Event<T>>.empty()
            // Update database only when config wethed from remote
            switch status {
            // 5
            case .error:
                let error = error ?? AppError.unknown
                print("Remote config fetch error: \(error.localizedDescription)")
                // Set error to result
                result = Observable.just(Event.error(error))
            // 6
            case .successFetchedFromRemote:
                print("Remote config fetched data from remote")
                // Update database from remote config
                try self?.update()
            case .successUsingPreFetchedData:
                print("Remote config using prefetched data")
            @unknown default:
                print("Remote config unknown status")
            }
            // 7
            // Unblock fetch for other requests
            self?.fetchLimiter.fetchInProcess = false
            return result
        }
    }


  1. , . , sync(). fetchLimiter . , fetchInProcess .
  2. Event


ViewModel

In diesem Beispielruftdas ViewModel einfach die Methode sellerContacts () aus dem Repository auf und gibt das Ergebnis zurück.



func contacts() -> Observable<Event<[SellerContacts]>> {
        repository.sellerContacts()
    }


ViewController

Im Controller müssen Sie das Abfrageergebnis an die Felder binden. Zu diesem Zweck wird die Methode bindContacts () in viewDidLoad () aufgerufen:



private func bindContacts() {
        // 1
        viewModel?.contacts()
            .subscribeOn(SerialDispatchQueueScheduler.init(qos: .userInteractive))
            .observeOn(MainScheduler.instance)
             // 2
            .flatMapError { [weak self] in
                self?.rx.showMessage($0.localizedDescription) ?? Observable.empty()
            }
             // 3
            .compactMap { $0.first }
             // 4
            .subscribe(onNext: { [weak self] in
                self?.phone.text = $0.phone
                self?.address.text = $0.address
            }).disposed(by: disposeBag)
    }


  1. Wir führen eine Anfrage nach Kontakten im Hintergrund-Thread aus und arbeiten mit dem resultierenden Ergebnis hauptsächlich
  2. Wenn ein Element mit einem Ereignis mit einem Fehler eintrifft , wird eine Fehlermeldung angezeigt und eine leere Sequenz zurückgegeben. Weitere Details zu flatMapError und showMessage finden Sie weiter unten
  3. Verwenden des Operators compactMap zum Abrufen von Kontakten aus einem Array
  4. Einstellen von Daten auf Steckdosen


Operator .flatMapError () Verwenden Sie den Operator,

um das Ergebnis einer Sequenz von Event in ein darin enthaltenes Element zu konvertieren oder einen Fehler anzuzeigen:



func flatMapError<T>(_ handler: ((_ error: Error) -> Observable<T>)? = nil) -> Observable<Element.Element> {
        // 1
        flatMap { element -> Observable<Element.Element> in
            switch element.event {
            // 2
            case .error(let error):
                return handler?(error).flatMap { _ in Observable<Element.Element>.empty() } ?? Observable.empty()
            // 3
            case .next(let element):
                return Observable.just(element)
            // 4
            default:
                return Observable.empty()
            }
        }
    }


  1. Konvertieren Sie eine Sequenz von Event.Element in Element
  2. Wenn das Ereignis einen Fehler enthält, geben wir den in eine leere Sequenz konvertierten Handler zurück
  3. Wenn Event ein Ergebnis enthält, geben Sie eine Sequenz mit einem Element zurück, das dieses Ergebnis enthält.
  4. Standardmäßig wird eine leere Sequenz zurückgegeben


Mit diesem Ansatz können Sie Fehler bei der Abfrageausführung behandeln, ohne ein Fehlerereignis an den Abonnenten zu senden. Die Überwachung der Änderung in der Datenbank bleibt aktiv.



Operator .showMessage ()

Verwenden Sie den Operator, um dem Benutzer Nachrichten anzuzeigen :



public func showMessage(_ text: String, withEvent: Bool = false) -> Observable<Void> {
        // 1
        let _alert = alert(title: nil,
              message: text,
              actions: [AlertAction(title: "OK", style: .default)]
        // 2
        ).map { _ in () }
        // 3
        return withEvent ? _alert : _alert.flatMap { Observable.empty() }
    }


  1. Mit RxAlert wird ein Fenster mit einer Nachricht und einer einzelnen Schaltfläche erstellt
  2. Das Ergebnis wird in Void konvertiert
  3. Wenn nach dem Anzeigen einer Nachricht ein Ereignis benötigt wird, geben wir das Ergebnis zurück. Andernfalls konvertieren wir es zuerst in eine leere Sequenz und kehren dann zurück


weil .showMessage () kann nicht nur zum Anzeigen von Fehlerbenachrichtigungen verwendet werden, sondern es ist auch nützlich, anpassen zu können, ob die Sequenz leer ist oder ein Ereignis vorliegt.



Tests



Alles, was oben beschrieben wurde, ist nicht schwer zu testen. Beginnen wir in der Reihenfolge der Präsentation.



RepositoryTests DatabaseUpdaterMock wird

zum Testen des Repositorys verwendet . Dort ist es möglich zu verfolgen, ob die sync () -Methode aufgerufen wurde, und das Ergebnis ihrer Ausführung festzulegen:



func testSellerContacts() throws {
        // 1
        // Success
        // Check sequence contains only one element
        XCTAssertThrowsError(try repository.sellerContacts().take(2).toBlocking(timeout: 1).toArray())
        updater.isSync = false
        // Check that element
        var result = try repository.sellerContacts().toBlocking().first()?.element
        XCTAssertTrue(updater.isSync)
        XCTAssertEqual(result?.count, sellerContacts.count)

        // 2
        // Sync error
        updater.isSync = false
        updater.error = AppError.unknown
        let resultArray = try repository.sellerContacts().take(2).toBlocking().toArray()
        XCTAssertTrue(resultArray.contains { $0.error?.localizedDescription == AppError.unknown.localizedDescription })
        XCTAssertTrue(updater.isSync)
        result = resultArray.first { $0.error == nil }?.element
        XCTAssertEqual(result?.count, sellerContacts.count)
    }


  1. Wir überprüfen, ob die Sequenz nur ein Element enthält, die sync () -Methode wird aufgerufen
  2. Wir überprüfen, ob die Sequenz zwei Elemente enthält. Eines enthält ein Ereignis mit einem Fehler, das andere das Ergebnis einer Abfrage aus der Datenbank. Die Methode sync () wird aufgerufen


DatabaseUpdaterTests



testSync ()
func testSync() throws {
        let remoteConfig = RemoteConfigMock()
        let fetchLimiter = FetchLimiter(serialQueue: DispatchQueue(label: "test"))
        let databaseUpdater = DatabaseUpdaterImpl(remoteConfig: remoteConfig, decoder: JSONDecoderMock(), context: context, fetchLimiter: fetchLimiter)
        // 1
        // Not update. Fetch in process
        fetchLimiter.fetchInProcess = true
        XCTAssertFalse(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
    
        var sync: Observable<Event<Void>> = databaseUpdater.sync()
        XCTAssertNil(try sync.toBlocking().first())
        XCTAssertFalse(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        
        waitForExpectations(timeout: 1)
        // 2
        // Not update. successUsingPreFetchedData
        fetchLimiter.fetchInProcess = false
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
        
        sync = databaseUpdater.sync()
        var result: Event<Void>?
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successUsingPreFetchedData, nil)
        
        waitForExpectations(timeout: 1)
        XCTAssertNil(result)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
        // 3
        // Not update. Error
        fetchLimiter.fetchInProcess = false
        remoteConfig.isFetchAndActivate = false
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
        sync = databaseUpdater.sync()
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.error, AppError.unknown)
        
        waitForExpectations(timeout: 1)
        
        XCTAssertEqual(result?.error?.localizedDescription, AppError.unknown.localizedDescription)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
        // 4
        // Update
        fetchLimiter.fetchInProcess = false
        remoteConfig.isFetchAndActivate = false
        result = nil
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
        
        sync = databaseUpdater.sync()
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successFetchedFromRemote, nil)
        
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(result)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertTrue(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
    }




  1. Eine leere Sequenz wird zurückgegeben, wenn ein Update ausgeführt wird
  2. Eine leere Sequenz wird zurückgegeben, wenn keine Daten empfangen wurden
  3. Ein Ereignis wird mit einem Fehler zurückgegeben
  4. Eine leere Sequenz wird zurückgegeben, wenn die Daten aktualisiert wurden


ViewModelTests



ViewControllerTests



testBindContacts ()
func testBindContacts() {
        // 1
        // Error. Show message
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        viewModel.contactsResult.accept(Event.error(AppError.unknown))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        // 2
        XCTAssertNotNil(controller.presentedViewController)
        let alertController = controller.presentedViewController as! UIAlertController
        XCTAssertEqual(alertController.actions.count, 1)
        XCTAssertEqual(alertController.actions.first?.style, .default)
        XCTAssertEqual(alertController.actions.first?.title, "OK")
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 3
        // Trigger action OK
        let action = alertController.actions.first!
        typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
        let block = action.value(forKey: "handler")
        let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(block as AnyObject).toOpaque())
        let handler = unsafeBitCast(blockPtr, to: AlertHandler.self)
        handler(action)
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        // 4
        XCTAssertNil(controller.presentedViewController)
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 5
        // Empty array of contats
        viewModel.contactsResult.accept(Event.next([]))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(controller.presentedViewController)
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 6
        // Success
        viewModel.contactsResult.accept(Event.next([contacts]))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(controller.presentedViewController)
        XCTAssertEqual(controller.phone.text, contacts.phone)
        XCTAssertEqual(controller.address.text, contacts.address)
    }




  1. Fehlermeldung anzeigen
  2. Überprüfen Sie, ob controller.presentedViewController eine Fehlermeldung enthält
  3. Führen Sie einen Handler für die Schaltfläche OK aus und stellen Sie sicher, dass das Meldungsfeld ausgeblendet ist
  4. Bei einem leeren Ergebnis wird kein Fehler angezeigt und es werden keine Felder ausgefüllt
  5. Bei einer erfolgreichen Anfrage wird kein Fehler angezeigt und die Felder werden ausgefüllt


Bedienertests



.flatMapError ()

.showMessage ()



Mit einem ähnlichen Entwurfsansatz implementieren wir asynchronen Datenabruf, Datenaktualisierung und Fehlerbenachrichtigung, ohne die Fähigkeit zu verlieren, auf Datenänderungen nach dem SSOT- Prinzip zu reagieren .



All Articles