Fuente única de verdad (SSOT) en MVVM con RxSwift y CoreData

A menudo, la siguiente funcionalidad debe implementarse en una aplicación móvil:



  1. Realizar una solicitud asincrónica
  2. Vincular el resultado en el hilo principal a diferentes vistas
  3. Si es necesario, actualice la base de datos en el dispositivo de forma asíncrona en un hilo en segundo plano
  4. Si se producen errores al realizar estas operaciones, muestre una notificación
  5. Cumplir con el principio SSOT para la relevancia de los datos
  6. Pruébalo todo


La solución de este problema se simplifica enormemente mediante el enfoque arquitectónico de MVVM y los marcos RxSwift y CoreData .



El enfoque que se describe a continuación utiliza principios de programación reactiva y no está vinculado exclusivamente a RxSwift y CoreData . Y, si se desea, se puede implementar utilizando otras herramientas.



Como ejemplo, tomaré un fragmento de una aplicación que muestra datos del vendedor. El controlador tiene dos salidas UILabel para el número de teléfono y la dirección y un botón UIB para llamar a este número de teléfono. ContactosViewController .



Permítanme explicar la implementación de modelo a vista.



Modelo



Fragmento del archivo autogenerado SellerContacts + CoreDataProperties de DerivedSources

con atributos:



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?

}


Repositorio .



Método que proporciona los datos del vendedor:



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


Aquí es donde se implementa SSOT . Se realiza una solicitud a CoreData y CoreData se actualiza según sea necesario. Todos los datos se reciben ÚNICAMENTE de la base de datos, y Updater.sync () solo puede generar un evento con un error, pero NO con datos.



  1. El uso del operador de fusión nos permite lograr la ejecución asincrónica de una consulta a la base de datos y su actualización.
  2. Para la conveniencia de construir una consulta a la base de datos, se usa RxCoreData
  3. Actualizando la base de datos


Porque Se utiliza un enfoque asincrónico de recepción y actualización de datos, debe usar Observable <Event <... >> . Esto es necesario para que el suscriptor no reciba un error en caso de error mientras recibe datos remotos, sino que solo muestra este error y continúa respondiendo a los cambios en CoreData . Más sobre esto más adelante.



DatabaseUpdater

En la aplicación de ejemplo, los datos remotos se recuperan de Firebase Remote Config . CoreData solo se actualiza si fetchAndActivate () sale con un estado .successFetchedFromRemote .



Pero puede usar cualquier otra restricción de actualización, por ejemplo, por tiempo.

Método Sync () para actualizar la base de datos:



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

En este ejemplo, ViewModel simplemente llama al método sellerContacts () del Repositorio y devuelve el resultado.



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


ViewController

En el controlador, debe vincular el resultado de la consulta a los campos. Para ello, los bindContacts () método es llamado en 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. Ejecutamos una solicitud de contactos en el hilo de fondo, y con el resultado resultante trabajamos en el principal
  2. Si un elemento que contiene un evento llega con un error, se muestra un mensaje de error y se devuelve una secuencia vacía. Más detalles sobre flatMapError y el operador showMessage a continuación
  3. Uso del operador compactMap para obtener contactos de una matriz
  4. Configuración de datos en puntos de venta


Operador .flatMapError ()

Para convertir el resultado de una secuencia de Evento en un elemento que contiene o para mostrar un error, use el operador:



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 una secuencia de Event.Element a Element
  2. Si el evento contiene un error, devolvemos el controlador convertido a una secuencia vacía
  3. Si el evento contiene un resultado, devuelve una secuencia con un elemento que contenga este resultado.
  4. Se devuelve una secuencia vacía por defecto


Este enfoque le permite manejar errores de ejecución de consultas sin enviar un evento de error al suscriptor. Y el seguimiento del cambio en la base de datos permanece activo.



Operador .showMessage ()

Para mostrar mensajes al usuario, use el operador:



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. Con RxAlert la ventana se crea con un mensaje y un solo botón
  2. El resultado se convierte en vacío
  3. Si se necesita un evento después de mostrar un mensaje, devolvemos el resultado. De lo contrario, primero lo convertimos en una secuencia vacía y luego regresamos


Porque .showMessage () se puede usar no solo para mostrar notificaciones de error, es útil para poder ajustar si la secuencia está vacía o con un evento.



Pruebas



Todo lo descrito anteriormente no es difícil de probar. Comencemos por orden de presentación.



RepositoryTests DatabaseUpdaterMock se

utiliza para probar el repositorio . Allí es posible rastrear si se llamó al método sync () y establecer el resultado de su ejecución:



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. Comprobamos que la secuencia contiene solo un elemento, se llama al método sync ()
  2. Comprobamos que la secuencia contiene dos elementos. Uno contiene un evento con un error, el otro el resultado de una consulta de la base de datos, se llama al método sync ()


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. Se devuelve una secuencia vacía si hay una actualización en curso.
  2. Se devuelve una secuencia vacía si no se reciben datos
  3. Un evento se devuelve con un error
  4. Se devuelve una secuencia vacía si los datos se han actualizado


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. Mostrar mensaje de error
  2. Compruebe que controller.presentedViewController tiene un mensaje de error
  3. Ejecute un controlador para el botón Aceptar y asegúrese de que el cuadro de mensaje esté oculto
  4. Para un resultado vacío, no se muestra ningún error y no se rellenan campos
  5. Para una solicitud exitosa, no se muestra ningún error y los campos se completan


Pruebas de operador



.flatMapError ()

.showMessage ()



Utilizando un enfoque de diseño similar, implementamos la recuperación de datos asíncrona, la actualización de datos y la notificación de errores sin perder la capacidad de responder a los cambios de datos, siguiendo el principio SSOT .



All Articles