- Faça uma solicitação assíncrona
- Vincule o resultado do tópico principal a diferentes visualizações
- Se necessário, atualize o banco de dados no dispositivo de forma assíncrona em um thread de segundo plano
- Se ocorrerem erros durante a execução dessas operações, mostre uma notificação
- Cumprir com o princípio SSOT para relevância de dados
- 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.
- O uso do operador merge nos permite obter a execução assíncrona de uma consulta ao banco de dados e sua atualização.
- Para a conveniência de construir uma consulta ao banco de dados, RxCoreData é usado
- 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
}
}
- , . , sync(). fetchLimiter . , fetchInProcess .
- 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)
}
- Executamos uma solicitação de contatos no thread de segundo plano, e com o resultado resultante trabalhamos no principal
- 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
- Usando o operador compactMap para obter contatos de uma matriz
- 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()
}
}
}
- Converta uma sequência de Event.Element para Element
- Se o evento contiver um erro, retornamos o manipulador convertido em uma sequência vazia
- Se Evento contiver um resultado, retorne uma seqüência com um elemento contendo esse resultado.
- 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() }
}
- Com RxAlert a janela é criada com uma mensagem e um único botão
- O resultado é convertido para vazio
- 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)
}
- Verificamos se a sequência contém apenas um elemento, o método sync () é chamado
- 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)
}
- Uma sequência vazia é retornada se uma atualização estiver em andamento
- Uma sequência vazia é retornada se nenhum dado for recebido
- Um evento é retornado com um erro
- 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)
}
- Mostrar mensagem de erro
- Verifique se controller.presentedViewController tem uma mensagem de erro
- Execute um manipulador para o botão Ok e certifique-se de que a caixa de mensagem esteja oculta
- Para um resultado vazio, nenhum erro é mostrado e nenhum campo é preenchido
- 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 .