- Realizar una solicitud asincrónica
- Vincular el resultado en el hilo principal a diferentes vistas
- Si es necesario, actualice la base de datos en el dispositivo de forma asíncrona en un hilo en segundo plano
- Si se producen errores al realizar estas operaciones, muestre una notificación
- Cumplir con el principio SSOT para la relevancia de los datos
- 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.
- 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.
- Para la conveniencia de construir una consulta a la base de datos, se usa RxCoreData
- 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
}
}
- , . , sync(). fetchLimiter . , fetchInProcess .
- 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)
}
- Ejecutamos una solicitud de contactos en el hilo de fondo, y con el resultado resultante trabajamos en el principal
- 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
- Uso del operador compactMap para obtener contactos de una matriz
- 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()
}
}
}
- Convertir una secuencia de Event.Element a Element
- Si el evento contiene un error, devolvemos el controlador convertido a una secuencia vacía
- Si el evento contiene un resultado, devuelve una secuencia con un elemento que contenga este resultado.
- 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() }
}
- Con RxAlert la ventana se crea con un mensaje y un solo botón
- El resultado se convierte en vacío
- 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)
}
- Comprobamos que la secuencia contiene solo un elemento, se llama al método sync ()
- 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)
}
- Se devuelve una secuencia vacía si hay una actualización en curso.
- Se devuelve una secuencia vacía si no se reciben datos
- Un evento se devuelve con un error
- 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)
}
- Mostrar mensaje de error
- Compruebe que controller.presentedViewController tiene un mensaje de error
- Ejecute un controlador para el botón Aceptar y asegúrese de que el cuadro de mensaje esté oculto
- Para un resultado vacío, no se muestra ningún error y no se rellenan campos
- 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 .