- Faire une requĂȘte asynchrone
- Liez le résultat dans le thread principal à différentes vues
- Si nécessaire, mettez à jour la base de données sur l'appareil de maniÚre asynchrone dans un thread d'arriÚre-plan
- Si des erreurs se produisent lors de l'exécution de ces opérations, affichez une notification
- Se conformer au principe SSOT pour la pertinence des données
- 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.
- 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.
- Pour faciliter la crĂ©ation d'une requĂȘte dans la base de donnĂ©es, RxCoreData est utilisĂ©
- 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
}
}
- , . , sync(). fetchLimiter . , fetchInProcess .
- 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)
}
- 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
- 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
- Utilisation de l'opérateur compactMap pour obtenir des contacts d'un tableau
- 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()
}
}
}
- Convertir une séquence de Event.Element en Element
- Si l' événement contient une erreur, nous retournons le gestionnaire converti en une séquence vide
- Si Event contient un résultat, renvoie une séquence avec un élément contenant ce résultat.
- 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() }
}
- Avec RxAlert, la fenĂȘtre est crĂ©Ă©e avec un message et un seul bouton
- Le résultat est converti en Void
- 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)
}
- On vérifie que la séquence ne contient qu'un seul élément, la méthode sync () est appelée
- 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)
}
- Une séquence vide est renvoyée si une mise à jour est en cours
- Une séquence vide est renvoyée si aucune donnée n'est reçue
- Un événement est renvoyé avec une erreur
- 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)
}
- Afficher le message d'erreur
- VĂ©rifiez que controller.presentedViewController a un message d'erreur
- Exécutez un gestionnaire pour le bouton OK et assurez-vous que la boßte de message est masquée
- Pour un résultat vide, aucune erreur n'est affichée et aucun champ n'est rempli
- 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 .