Fonte única da verdade (SSOT) em MVVM com RxSwift e CoreData

Freqüentemente, a seguinte funcionalidade precisa ser implementada em um aplicativo móvel:



  1. Faça uma solicitação assíncrona
  2. Vincule o resultado do tópico principal a diferentes visualizações
  3. Se necessário, atualize o banco de dados no dispositivo de forma assíncrona em um thread de segundo plano
  4. Se ocorrerem erros durante a execução dessas operações, mostre uma notificação
  5. Cumprir com o princípio SSOT para relevância de dados
  6. Teste tudo


A solução desse problema é bastante simplificada pela abordagem arquitetônica do MVVM e pelas estruturas RxSwift e CoreData .



A abordagem descrita abaixo usa princípios de programação reativa e não está exclusivamente ligada a RxSwift e CoreData . E, se desejado, pode ser implementado com outras ferramentas.



Como exemplo, pegarei um snippet de um aplicativo que exibe dados do vendedor. O controlador tem duas saídas UILabel para número de telefone e endereço e um UIButton para ligar para este número de telefone. ContactsViewController .



Deixe-me explicar a implementação do modelo para a visualização.



Modelo



Fragmento de arquivo gerado automaticamente SellerContacts + CoreDataProperties de DerivedSources

com 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?

}


Repositório .



Método de fornecimento de dados do vendedor:



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


É aqui que o SSOT é implementado . Uma solicitação é feita para CoreData e CoreData é atualizado conforme necessário. Todos os dados são recebidos SOMENTE do banco de dados, e updater.sync () só pode gerar um evento com erro, mas NÃO com dados.



  1. O uso do operador merge nos permite obter a execução assíncrona de uma consulta ao banco de dados e sua atualização.
  2. Para a conveniência de construir uma consulta ao banco de dados, RxCoreData é usado
  3. Atualizando o banco de dados


Porque uma abordagem assíncrona de recebimento e atualização de dados é usada, você deve usar Observable <Event <... >> . Isso é necessário para que o assinante não receba um Erro ao receber um erro ao receber dados remotos, mas apenas mostre esse erro e continue a responder às alterações no CoreData . Mais sobre isso mais tarde.



DatabaseUpdater

No aplicativo de exemplo, os dados remotos são recuperados do Firebase Remote config . CoreData é atualizado apenas se fetchAndActivate () sai com um status .successFetchedFromRemote .



Mas você pode usar qualquer outra restrição de atualização, por exemplo, por tempo.

Método Sync () para atualizar o banco de dados:



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

Neste exemplo, o ViewModel simplesmente chama o método sellerContacts () do Repositório e retorna o resultado.



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


ViewController

No controlador, você precisa vincular o resultado da consulta aos campos. Para fazer isso, ométodo bindContacts () é chamado em 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. Executamos uma solicitação de contatos no thread de segundo plano, e com o resultado resultante trabalhamos no principal
  2. Se um elemento que contém um evento chegar com um erro, uma mensagem de erro será exibida e uma sequência vazia será retornada. Mais sobre flatMapError e operador showMessage abaixo
  3. Usando o operador compactMap para obter contatos de uma matriz
  4. Definir dados para pontos de venda


Operador .flatMapError ()

Para converter o resultado de uma sequência de Evento em um elemento que ela contém ou para exibir um erro, use o 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. Converta uma sequência de Event.Element para Element
  2. Se o evento contiver um erro, retornamos o manipulador convertido em uma sequência vazia
  3. Se Evento contiver um resultado, retorne uma seqüência com um elemento contendo esse resultado.
  4. Uma sequência vazia é retornada por padrão


Essa abordagem permite que você lide com erros de execução de consulta sem enviar um Evento de Erro ao assinante. E o monitoramento da mudança no banco de dados permanece ativo.



Operador .showMessage ()

Para mostrar mensagens ao usuário, use o 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. Com RxAlert a janela é criada com uma mensagem e um único botão
  2. O resultado é convertido para vazio
  3. Se um evento for necessário após exibir uma mensagem, retornamos o resultado. Caso contrário, primeiro a convertemos em uma sequência vazia e depois retornamos


Porque .showMessage () pode ser usado não apenas para mostrar notificações de erro, é útil para poder ajustar se a sequência está vazia ou com um evento.



Testes



Tudo o que foi descrito acima não é difícil de testar. Vamos começar pela ordem de apresentação.



RepositoryTests DatabaseUpdaterMock é

usado para testar o repositório . Lá é possível rastrear se o método sync () foi chamado e definir o resultado de sua execução:



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. Verificamos se a sequência contém apenas um elemento, o método sync () é chamado
  2. Verificamos se a sequência contém dois elementos. Um contém um Evento com erro, o outro o resultado de uma consulta do banco de dados, o método sync () é chamado


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. Uma sequência vazia é retornada se uma atualização estiver em andamento
  2. Uma sequência vazia é retornada se nenhum dado for recebido
  3. Um evento é retornado com um erro
  4. Uma sequência vazia é retornada se os dados foram atualizados


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 mensagem de erro
  2. Verifique se controller.presentedViewController tem uma mensagem de erro
  3. Execute um manipulador para o botão Ok e certifique-se de que a caixa de mensagem esteja oculta
  4. Para um resultado vazio, nenhum erro é mostrado e nenhum campo é preenchido
  5. Para uma solicitação bem-sucedida, nenhum erro é mostrado e os campos são preenchidos


Testes de operador



.flatMapError ()

.showMessage ()



Usando uma abordagem de design semelhante, implementamos busca de dados assíncrona, atualização e notificação de erro sem perder a capacidade de responder às alterações de dados, seguindo o princípio SSOT .



All Articles