[Разработка под iOS, Swift] Single source of truth (SSOT) on MVVM with RxSwift & CoreData

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
21-Окт-2020 21:32

Часто в мобильном приложении необходимо реализовать следующий функционал:
  • Выполнить асинхронный запрос
  • Забиндить результат в главном потоке на различные view
  • Если нужно, то асинхронно обновить базу данных на устройстве в фоновом потоке
  • Если возникают ошибки при выполнении этих операций, то показать уведомление
  • Соблюсти принцип SSOT для актуальности данных
  • Всё это протестировать

Решить эту задачу сильно упрощает архитектурный подход MVVM и фреймворки RxSwift, CoreData.
Описанный ниже подход использует принципы реактивного программирования и не привязан исключительно к RxSwift и CoreData. И при желании может быть реализован с помощью других инструментов.
В качестве примера я возьму фрагмент приложения в котором отображаются данные продавца. В контроллере два аутлета UILabel для телефона и адреса и одна UIButton для звонка по этому телефону. ContactsViewController.
Объясню реализацию от model к view.
Model
Фрагмент автосгенерированного файла SellerContacts+CoreDataProperties из DerivedSources
с атрибутами:
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.
Метод предоставляющий данные продавца:
func sellerContacts() -> Observable<Event<[SellerContacts]>> {
        // 1
        Observable.merge([
            // 2
            context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
            // 3
            updater.sync()
        ])
    }

Как раз в этом месте реализуется SSOT. Запрос делается к CoreData, и CoreData обновляется, если необходимо. Все данные получаются ТОЛЬКО из БД, а updater.sync() может сгенерировать только Event с ошибкой, но НЕ с данными.
  • Использование оператора merge позволяет нам добиться асинхронности выполнения запроса к базе данных и её обновления.
  • Для удобства построения запроса к БД используется RxCoreData
  • Выполняем обновление БД

Т.к. используется асинхронный подход получения и обновления данных, необходимо использовать Observable<Event<...>>. Это нужно для того, чтобы subscriber не получил Error, при ошибке во время получения remote data, а только показал эту ошибку и продолжал реагировать на изменения в CoreData. Об этом подробнее чуть позже.
DatabaseUpdater
В приложении из примера удаленные данные получаются из Firebase Remote Config. CoreData обновляется только в том случае, если fetchAndActivate() завершается со статусом .successFetchedFromRemote.
Но можно использовать любые другие ограничения обновления, например, по времени.
Метод sync() для обновления БД:
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
В данном примере во ViewModel просто вызывается метод sellerContacts() из Repository и возвращается результат.
func contacts() -> Observable<Event<[SellerContacts]>> {
        repository.sellerContacts()
    }

ViewController
В контроллере нужно забиндить результат запроса в поля. Для этого в viewDidLoad() вызывается метод bindContacts():
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)
    }

  • Выполняем запрос контактов в фоновом потоке, а с полученным результатом работаем в главном
  • Если приходит элемент содержащий Event с ошибкой, то показывается сообщение с ошибкой и возвращается пустая последовательность. Подробнее об операторе flatMapError и showMessage ниже
  • Используем оператор compactMap для получения контактов из массива
  • Устанавливаем данные в аутлеты

Оператор .flatMapError()
Для преобразования результата последовательности из Event в элемент в нём содержащийся или показа ошибки используется оператор:
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()
            }
        }
    }

  • Преобразуем последовательность из Event.Element в Element
  • Если Event содержит ошибку, то возвращаем handler преобразованный в пустую последовательность
  • Если Event содержит результат, то возвращаем последовательность с одним элементом, содержащим этот результат
  • По умолчанию возвращается пустая последовательность

Такой подход позволяет обрабатывать ошибки выполнения запросов, не посылая подписчику Error Event. И наблюдение за изменением в БД остаётся активным.
Оператор .showMessage()
Для показа сообщений пользователю используется оператор:
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() }
    }

  • С помощью RxAlert создаётся окно с сообщением и одной кнопкой
  • Результат преобразуется в Void
  • Если необходимо событие после показа сообщения, то возвращаем результат. Иначе сначала преобразуем его в пустую последовательность, а затем возвращаем

Т.к. .showMessage() может использоваться не только для показа уведомлений об ошибках, то полезно иметь возможность регулировать какая последовательность получается в итоге — пустая или с событием.
Тесты
Все описанное выше протестировать не трудно. Начнем по порядку изложения.
RepositoryTests
Для теста репозитория используется DatabaseUpdaterMock. Там есть возможность отслеживать вызывался ли метод sync() и устанавливать результат его выполнения:
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)
    }

  • Проверяем, что последовательность содержит только один элемент, вызывается метод sync()
  • Проверяем, что последовательность содержит два элемента. Один содержит Event с ошибкой, другой результат запроса из БД, вызывается метод sync()

DatabaseUpdaterTests

testSync(&#41;

SPL
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)
    }


  • Возвращается пустая последовательность, если обновление в процессе
  • Возвращается пустая последовательность, если данные не получены
  • Возвращается Event с ошибкой
  • Возвращается пустая последовательность, если данные обновились

ViewModelTests
ViewControllerTests

testBindContacts(&#41;

SPL
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)
    }


  • Показать сообщение об ошибке
  • Проверить, что в controller.presentedViewController сообщение об ошибке
  • Выполнить handler для кнопки Ок и убедиться, что окно с сообщением скрылось
  • Для пустого результата не показывается ошибка и не заполняются поля
  • Для успешного запроса не показывается ошибка и заполняются поля

Тесты для операторов
.flatMapError()
.showMessage()
Используя подобный подход в проектировании мы реализуем асинхронное получение, обновление данных и уведомление об ошибках без потери возможности реагировать на изменение данных, следуя принципу SSOT.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_swift, #_swift, #_rxswift, #_mvvm, #_razrabotka_pod_ios (
Разработка под iOS
)
, #_swift
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 23-Ноя 00:27
Часовой пояс: UTC + 5