Sumber kebenaran tunggal (SSOT) di MVVM dengan RxSwift & CoreData

Seringkali fungsi berikut perlu diterapkan dalam aplikasi seluler:



  1. Buat permintaan asinkron
  2. Ikat hasil di utas utama ke tampilan berbeda
  3. Jika perlu, perbarui database di perangkat secara asinkron di thread latar belakang
  4. Jika terjadi kesalahan saat melakukan operasi ini, tunjukkan pemberitahuan
  5. Mematuhi prinsip SSOT untuk relevansi data
  6. 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.



  1. Menggunakan operator penggabungan memungkinkan kita untuk mencapai eksekusi asinkron dari kueri ke database dan pembaruannya.
  2. Untuk kenyamanan membangun kueri ke database, RxCoreData digunakan
  3. 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
        }
    }


  1. , . , sync(). fetchLimiter . , fetchInProcess .
  2. 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)
    }


  1. Kami menjalankan permintaan untuk kontak di utas latar belakang, dan dengan hasil yang dihasilkan kami bekerja di utama
  2. 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
  3. Menggunakan operator compactMap untuk mendapatkan kontak dari array
  4. 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()
            }
        }
    }


  1. Ubah urutan dari Event.Element menjadi Element
  2. Jika Peristiwa mengandung kesalahan, maka kami mengembalikan penangan yang dikonversi ke urutan kosong
  3. Jika Peristiwa berisi hasil, kembalikan urutan dengan satu elemen yang berisi hasil ini.
  4. 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() }
    }


  1. Dengan jendela RxAlert dibuat dengan pesan dan satu tombol
  2. Hasilnya diubah menjadi Void
  3. 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)
    }


  1. Kami memeriksa bahwa urutan hanya berisi satu elemen, metode sync () dipanggil
  2. 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)
    }




  1. Urutan kosong dikembalikan jika pembaruan sedang berlangsung
  2. Urutan kosong dikembalikan jika tidak ada data yang diterima
  3. Sebuah Acara dikembalikan dengan kesalahan
  4. 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)
    }




  1. Tampilkan pesan kesalahan
  2. Periksa apakah controller.presentedViewController memiliki pesan kesalahan
  3. Jalankan penangan untuk tombol Ok dan pastikan kotak pesan disembunyikan
  4. Untuk hasil kosong, tidak ada kesalahan yang ditampilkan dan tidak ada bidang yang diisi
  5. 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 .



All Articles