- Stellen Sie eine asynchrone Anforderung
- Binden Sie das Ergebnis im Hauptthread an verschiedene Ansichten
- Aktualisieren Sie gegebenenfalls die Datenbank auf dem Gerät asynchron in einem Hintergrundthread
- Wenn beim Ausführen dieser Vorgänge Fehler auftreten, wird eine Benachrichtigung angezeigt
- Beachten Sie das SSOT- Prinzip für die Datenrelevanz
- Testen Sie alles
Die Lösung dieses Problems wird durch den Architekturansatz von MVVM und den Frameworks RxSwift und CoreData erheblich vereinfacht .
Der unten beschriebene Ansatz verwendet reaktive Programmierprinzipien und ist nicht ausschließlich an RxSwift und CoreData gebunden . Auf Wunsch kann es auch mit anderen Tools implementiert werden.
Als Beispiel nehme ich einen Ausschnitt aus einer Anwendung, in der Verkäuferdaten angezeigt werden. Der Controller verfügt über zwei UILabel-Steckdosen für Telefonnummer und Adresse sowie einen UIButton zum Anrufen dieser Telefonnummer. ContactsViewController .
Lassen Sie mich die Implementierung vom Modell bis zur Ansicht erläutern.
Modell
Fragment der automatisch generierten Datei SellerContacts + CoreDataProperties aus DerivedSources
mit Attributen:
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?
}
Repository .
Methode zur Bereitstellung von Verkäuferdaten:
func sellerContacts() -> Observable<Event<[SellerContacts]>> {
// 1
Observable.merge([
// 2
context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
// 3
updater.sync()
])
}
Hier wird SSOT implementiert . Eine Anfrage wird an CoreData gestellt und CoreData wird nach Bedarf aktualisiert. Alle Daten werden NUR von der Datenbank empfangen, und updater.sync () kann nur ein Ereignis mit einem Fehler generieren, NICHT jedoch mit Daten.
- Mit dem Merge-Operator können wir eine asynchrone Ausführung einer Abfrage an die Datenbank und deren Aktualisierung erreichen.
- Zum bequemen Erstellen einer Abfrage in der Datenbank wird RxCoreData verwendet
- Aktualisieren der Datenbank
weil Wenn ein asynchroner Ansatz zum Empfangen und Aktualisieren von Daten verwendet wird, müssen Sie Observable <Event <... >> verwenden . Dies ist erforderlich, damit der Abonnent beim Empfang eines Fehlers beim Empfang von Remote-Daten keinen Fehler empfängt, sondern nur diesen Fehler anzeigt und weiterhin auf Änderungen in CoreData reagiert . Dazu später mehr.
DatabaseUpdater
In der Beispielanwendung werden Remote-Daten aus Firebase Remote Config abgerufen . CoreData wird nur aktualisiert, wenn fetchAndActivate () mit dem Status .successFetchedFromRemote beendet wird .
Sie können jedoch auch andere Aktualisierungsbeschränkungen verwenden, z. B. nach Zeit.
Sync () -Methode zum Aktualisieren der Datenbank:
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
In diesem Beispielruftdas ViewModel einfach die Methode sellerContacts () aus dem Repository auf und gibt das Ergebnis zurück.
func contacts() -> Observable<Event<[SellerContacts]>> {
repository.sellerContacts()
}
ViewController
Im Controller müssen Sie das Abfrageergebnis an die Felder binden. Zu diesem Zweck wird die Methode bindContacts () in viewDidLoad () aufgerufen:
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)
}
- Wir führen eine Anfrage nach Kontakten im Hintergrund-Thread aus und arbeiten mit dem resultierenden Ergebnis hauptsächlich
- Wenn ein Element mit einem Ereignis mit einem Fehler eintrifft , wird eine Fehlermeldung angezeigt und eine leere Sequenz zurückgegeben. Weitere Details zu flatMapError und showMessage finden Sie weiter unten
- Verwenden des Operators compactMap zum Abrufen von Kontakten aus einem Array
- Einstellen von Daten auf Steckdosen
Operator .flatMapError () Verwenden Sie den Operator,
um das Ergebnis einer Sequenz von Event in ein darin enthaltenes Element zu konvertieren oder einen Fehler anzuzeigen:
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()
}
}
}
- Konvertieren Sie eine Sequenz von Event.Element in Element
- Wenn das Ereignis einen Fehler enthält, geben wir den in eine leere Sequenz konvertierten Handler zurück
- Wenn Event ein Ergebnis enthält, geben Sie eine Sequenz mit einem Element zurück, das dieses Ergebnis enthält.
- Standardmäßig wird eine leere Sequenz zurückgegeben
Mit diesem Ansatz können Sie Fehler bei der Abfrageausführung behandeln, ohne ein Fehlerereignis an den Abonnenten zu senden. Die Überwachung der Änderung in der Datenbank bleibt aktiv.
Operator .showMessage ()
Verwenden Sie den Operator, um dem Benutzer Nachrichten anzuzeigen :
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() }
}
- Mit RxAlert wird ein Fenster mit einer Nachricht und einer einzelnen Schaltfläche erstellt
- Das Ergebnis wird in Void konvertiert
- Wenn nach dem Anzeigen einer Nachricht ein Ereignis benötigt wird, geben wir das Ergebnis zurück. Andernfalls konvertieren wir es zuerst in eine leere Sequenz und kehren dann zurück
weil .showMessage () kann nicht nur zum Anzeigen von Fehlerbenachrichtigungen verwendet werden, sondern es ist auch nützlich, anpassen zu können, ob die Sequenz leer ist oder ein Ereignis vorliegt.
Tests
Alles, was oben beschrieben wurde, ist nicht schwer zu testen. Beginnen wir in der Reihenfolge der Präsentation.
RepositoryTests DatabaseUpdaterMock wird
zum Testen des Repositorys verwendet . Dort ist es möglich zu verfolgen, ob die sync () -Methode aufgerufen wurde, und das Ergebnis ihrer Ausführung festzulegen:
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)
}
- Wir überprüfen, ob die Sequenz nur ein Element enthält, die sync () -Methode wird aufgerufen
- Wir überprüfen, ob die Sequenz zwei Elemente enthält. Eines enthält ein Ereignis mit einem Fehler, das andere das Ergebnis einer Abfrage aus der Datenbank. Die Methode sync () wird aufgerufen
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)
}
- Eine leere Sequenz wird zurückgegeben, wenn ein Update ausgeführt wird
- Eine leere Sequenz wird zurückgegeben, wenn keine Daten empfangen wurden
- Ein Ereignis wird mit einem Fehler zurückgegeben
- Eine leere Sequenz wird zurückgegeben, wenn die Daten aktualisiert wurden
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)
}
- Fehlermeldung anzeigen
- Überprüfen Sie, ob controller.presentedViewController eine Fehlermeldung enthält
- Führen Sie einen Handler für die Schaltfläche OK aus und stellen Sie sicher, dass das Meldungsfeld ausgeblendet ist
- Bei einem leeren Ergebnis wird kein Fehler angezeigt und es werden keine Felder ausgefüllt
- Bei einer erfolgreichen Anfrage wird kein Fehler angezeigt und die Felder werden ausgefüllt
Bedienertests
.flatMapError ()
.showMessage ()
Mit einem ähnlichen Entwurfsansatz implementieren wir asynchronen Datenabruf, Datenaktualisierung und Fehlerbenachrichtigung, ohne die Fähigkeit zu verlieren, auf Datenänderungen nach dem SSOT- Prinzip zu reagieren .