Source unique de vérité (SSOT) sur MVVM avec RxSwift et CoreData

Les fonctionnalitĂ©s suivantes doivent souvent ĂȘtre implĂ©mentĂ©es dans une application mobile:



  1. Faire une requĂȘte asynchrone
  2. Liez le résultat dans le thread principal à différentes vues
  3. Si nécessaire, mettez à jour la base de données sur l'appareil de maniÚre asynchrone dans un thread d'arriÚre-plan
  4. Si des erreurs se produisent lors de l'exécution de ces opérations, affichez une notification
  5. Se conformer au principe SSOT pour la pertinence des données
  6. Tester tout


La résolution de ce problÚme est grandement simplifiée par l'approche architecturale de MVVM et des frameworks RxSwift et CoreData .



L'approche dĂ©crite ci-dessous utilise des principes de programmation rĂ©active et n'est pas exclusivement liĂ©e Ă  RxSwift et CoreData . Et si vous le souhaitez, il peut ĂȘtre mis en Ɠuvre Ă  l'aide d'autres outils.



À titre d'exemple, je vais prendre un extrait d'une application qui affiche les donnĂ©es du vendeur. Le contrĂŽleur dispose de deux prises UILabel pour le numĂ©ro de tĂ©lĂ©phone et l'adresse et un UIButton pour appeler ce numĂ©ro de tĂ©lĂ©phone. ContactsViewController .



Laissez-moi vous expliquer l'implémentation du modÚle à la vue.



ModĂšle



Fragment de fichier généré automatiquement SellerContacts + CoreDataProperties de DerivedSources

avec les attributs:



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?

}


DĂ©pĂŽt .



Méthode de fourniture des données du vendeur:



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


C'est là que SSOT est implémenté . Une demande est adressée à CoreData et CoreData est mis à jour si nécessaire. Toutes les données sont reçues UNIQUEMENT de la base de données et updater.sync () ne peut générer qu'un événement avec une erreur, mais PAS avec des données.



  1. L'utilisation de l'opĂ©rateur de fusion nous permet de rĂ©aliser une exĂ©cution asynchrone d'une requĂȘte vers la base de donnĂ©es et sa mise Ă  jour.
  2. Pour faciliter la crĂ©ation d'une requĂȘte dans la base de donnĂ©es, RxCoreData est utilisĂ©
  3. Mettre à jour la base de données


Parce que une approche asynchrone de réception et de mise à jour des données est utilisée, vous devez utiliser Observable <Event <... >> . Cela est nécessaire pour que l'abonné ne reçoive pas d'erreur lors de la réception d'une erreur lors de la réception de données distantes, mais affiche uniquement cette erreur et continue de répondre aux modifications dans CoreData . Plus à ce sujet plus tard.



DatabaseUpdater

Dans l'exemple d'application, les données distantes sont extraites de Firebase Remote Config . CoreData n'est mis à jour que si fetchAndActivate () se termine avec un état .successFetchedFromRemote .



Mais vous pouvez utiliser toute autre restriction de mise Ă  jour, par exemple, par heure.

Méthode Sync () pour mettre à jour la base de données:



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

Dans cet exemple, le ViewModel appelle simplement la méthode sellerContacts () à partir du référentiel et renvoie le résultat.



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


ViewController

Dans le contrĂŽleur, vous devez lier le rĂ©sultat de la requĂȘte aux champs. Pour ce faire, lamĂ©thode bindContacts () est appelĂ©e dans viewDidLoad () :



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. Nous exécutons une demande de contacts dans le fil d'arriÚre-plan, et avec le résultat obtenu, nous travaillons dans le principal
  2. Si un élément contenant un événement arrive avec une erreur, un message d'erreur s'affiche et une séquence vide est renvoyée. Plus de détails sur flatMapError et l' opérateur showMessage ci-dessous
  3. Utilisation de l'opérateur compactMap pour obtenir des contacts d'un tableau
  4. Définition des données sur les prises


Operator .flatMapError ()

Pour convertir le résultat d'une séquence d' Event en un élément qu'il contient ou pour afficher une erreur, utilisez l'opérateur:



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. Convertir une séquence de Event.Element en Element
  2. Si l' événement contient une erreur, nous retournons le gestionnaire converti en une séquence vide
  3. Si Event contient un résultat, renvoie une séquence avec un élément contenant ce résultat.
  4. Une séquence vide est renvoyée par défaut


Cette approche vous permet de gĂ©rer les erreurs d'exĂ©cution de requĂȘte sans envoyer un Ă©vĂ©nement d'erreur Ă  l'abonnĂ©. Et le suivi des modifications dans la base de donnĂ©es reste actif.



Operator .showMessage ()

Pour afficher les messages à l'utilisateur, utilisez l'opérateur:



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. Avec RxAlert, la fenĂȘtre est crĂ©Ă©e avec un message et un seul bouton
  2. Le résultat est converti en Void
  3. Si un événement est nécessaire aprÚs l'affichage du message, nous renvoyons le résultat. Sinon, nous la convertissons d'abord en une séquence vide, puis nous retournons


Parce que .showMessage () peut ĂȘtre utilisĂ© non seulement pour afficher les notifications d'erreur, il est utile de pouvoir ajuster si la sĂ©quence est vide ou avec un Ă©vĂ©nement.



Des tests



Tout ce qui est décrit ci-dessus n'est pas difficile à tester. Commençons par ordre de présentation.



RepositoryTests DatabaseUpdaterMock est

utilisé pour tester le référentiel . Là, il est possible de suivre si la méthode sync () a été appelée et de définir le résultat de son exécution:



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. On vérifie que la séquence ne contient qu'un seul élément, la méthode sync () est appelée
  2. Nous vĂ©rifions que la sĂ©quence contient deux Ă©lĂ©ments. L'un contient un Ă©vĂ©nement avec une erreur, l'autre le rĂ©sultat d'une requĂȘte de la base de donnĂ©es, la mĂ©thode sync () est appelĂ©e


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. Une séquence vide est renvoyée si une mise à jour est en cours
  2. Une séquence vide est renvoyée si aucune donnée n'est reçue
  3. Un événement est renvoyé avec une erreur
  4. Une séquence vide est renvoyée si les données ont été mises à jour


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. Afficher le message d'erreur
  2. VĂ©rifiez que controller.presentedViewController a un message d'erreur
  3. Exécutez un gestionnaire pour le bouton OK et assurez-vous que la boßte de message est masquée
  4. Pour un résultat vide, aucune erreur n'est affichée et aucun champ n'est rempli
  5. Pour une demande réussie, aucune erreur n'est affichée et les champs sont remplis


Tests opérateurs



.flatMapError ()

.showMessage ()



En utilisant une approche de conception similaire, nous implémentons l'extraction de données asynchrone, la mise à jour et la notification d'erreur sans perdre la capacité de répondre aux modifications de données, en suivant le principe SSOT .



All Articles