- Buat permintaan asinkron
- Ikat hasil di utas utama ke tampilan berbeda
- Jika perlu, perbarui database di perangkat secara asinkron di thread latar belakang
- Jika terjadi kesalahan saat melakukan operasi ini, tunjukkan pemberitahuan
- Mematuhi prinsip SSOT untuk relevansi data
- Uji semuanya
Memecahkan masalah ini sangat disederhanakan dengan pendekatan arsitektur MVVM dan kerangka kerja RxSwift dan CoreData .
Pendekatan yang dijelaskan di bawah ini menggunakan prinsip pemrograman reaktif dan tidak secara eksklusif terkait dengan RxSwift dan CoreData . Dan, jika diinginkan, dapat diimplementasikan menggunakan alat lain.
Sebagai contoh, saya akan mengambil cuplikan dari aplikasi yang menampilkan data penjual. Pengontrol memiliki dua outlet UILabel untuk nomor telepon dan alamat dan satu UIButton untuk memanggil nomor telepon ini. ContactsViewController .
Izinkan saya menjelaskan implementasi dari model ke tampilan.
Model
Fragmen file yang dibuat secara otomatis SellerContacts + CoreDataProperties dari DerivedSources
dengan atribut:
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?
}
Repositori .
Metode penyediaan data penjual:
func sellerContacts() -> Observable<Event<[SellerContacts]>> {
// 1
Observable.merge([
// 2
context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
// 3
updater.sync()
])
}
Di sinilah SSOT diimplementasikan . Permintaan dibuat untuk CoreData , dan CoreData diperbarui sesuai kebutuhan. Semua data HANYA diterima dari database, dan updater.sync () hanya dapat menghasilkan Event dengan kesalahan, tetapi BUKAN dengan data.
- Menggunakan operator penggabungan memungkinkan kita untuk mencapai eksekusi asinkron dari kueri ke database dan pembaruannya.
- Untuk kenyamanan membangun kueri ke database, RxCoreData digunakan
- Memperbarui database
Karena pendekatan asinkron untuk menerima dan memperbarui data digunakan, Anda harus menggunakan Observable <Event <... >> . Ini diperlukan agar pelanggan tidak menerima kesalahan saat menerima kesalahan saat menerima data jarak jauh, tetapi hanya menampilkan kesalahan ini dan terus menanggapi perubahan di CoreData . Lebih lanjut tentang ini nanti.
DatabaseUpdater
Dalam aplikasi contoh, data jarak jauh diambil dari Firebase Remote Config . CoreData hanya diperbarui jika fetchAndActivate () keluar dengan status .successFetchedFromRemote .
Tetapi Anda dapat menggunakan batasan pembaruan lainnya, misalnya, berdasarkan waktu.
Metode Sync () untuk memperbarui database:
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
Dalam contoh ini, ViewModel hanya memanggil metode sellerContacts () dari Repositori dan mengembalikan hasilnya.
func contacts() -> Observable<Event<[SellerContacts]>> {
repository.sellerContacts()
}
ViewController
Di pengontrol, Anda perlu mengikat hasil kueri ke bidang. Untuk melakukan ini, metode bindContacts () dipanggil di 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)
}
- Kami menjalankan permintaan untuk kontak di utas latar belakang, dan dengan hasil yang dihasilkan kami bekerja di utama
- Jika elemen yang berisi Peristiwa datang dengan kesalahan, maka pesan kesalahan ditampilkan dan urutan kosong dikembalikan. Detail selengkapnya tentang flatMapError dan operator showMessage di bawah ini
- Menggunakan operator compactMap untuk mendapatkan kontak dari array
- Mengatur data ke outlet
Operator .flatMapError ()
Untuk mengonversi hasil urutan dari Peristiwa ke elemen yang dikandungnya atau untuk menampilkan kesalahan, gunakan operator:
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()
}
}
}
- Ubah urutan dari Event.Element menjadi Element
- Jika Peristiwa mengandung kesalahan, maka kami mengembalikan penangan yang dikonversi ke urutan kosong
- Jika Peristiwa berisi hasil, kembalikan urutan dengan satu elemen yang berisi hasil ini.
- Urutan kosong dikembalikan secara default
Pendekatan ini memungkinkan Anda untuk menangani kesalahan eksekusi kueri tanpa mengirimkan Kejadian Kesalahan ke pelanggan. Dan pemantauan perubahan dalam database tetap aktif. .ShowMessage ()
Operator
Untuk acara pesan ke pengguna, menggunakan operator:
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() }
}
- Dengan jendela RxAlert dibuat dengan pesan dan satu tombol
- Hasilnya diubah menjadi Void
- Jika suatu peristiwa dibutuhkan setelah menampilkan pesan, maka kita mengembalikan hasilnya. Jika tidak, pertama-tama kami mengubahnya menjadi urutan kosong dan kemudian kembali
Karena .showMessage () dapat digunakan tidak hanya untuk menampilkan pemberitahuan kesalahan, ini berguna untuk dapat menyesuaikan apakah urutan kosong atau dengan sebuah acara.
Tes
Semua yang dijelaskan di atas tidak sulit untuk diuji. Mari kita mulai dalam urutan presentasi.
RepositoryTests DatabaseUpdaterMock
digunakan untuk menguji repositori . Di sana dimungkinkan untuk melacak apakah metode sync () dipanggil dan menyetel hasil eksekusinya:
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)
}
- Kami memeriksa bahwa urutan hanya berisi satu elemen, metode sync () dipanggil
- Kami memeriksa bahwa urutan tersebut mengandung dua elemen. Satu berisi Peristiwa dengan kesalahan, yang lainnya merupakan hasil kueri dari database, metode sync () dipanggil
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)
}
- Urutan kosong dikembalikan jika pembaruan sedang berlangsung
- Urutan kosong dikembalikan jika tidak ada data yang diterima
- Sebuah Acara dikembalikan dengan kesalahan
- Urutan kosong dikembalikan jika data telah diperbarui
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)
}
- Tampilkan pesan kesalahan
- Periksa apakah controller.presentedViewController memiliki pesan kesalahan
- Jalankan penangan untuk tombol Ok dan pastikan kotak pesan disembunyikan
- Untuk hasil kosong, tidak ada kesalahan yang ditampilkan dan tidak ada bidang yang diisi
- Untuk permintaan yang berhasil, tidak ada kesalahan yang ditampilkan dan bidang diisi
Tes operator
.flatMapError ()
.showMessage ()
Dengan menggunakan pendekatan desain yang serupa, kami menerapkan pengambilan data asinkron, pembaruan, dan pemberitahuan kesalahan tanpa kehilangan kemampuan untuk merespons perubahan data, mengikuti prinsip SSOT .