- 非同期リクエストを作成します
- メインスレッドの結果を別のビューにバインドします
- 必要に応じて、デバイス上のデータベースをバックグラウンドスレッドで非同期に更新します
- これらの操作の実行中にエラーが発生した場合は、通知を表示してください
- データの関連性に関するSSOTの原則に準拠する
- すべてテストする
この問題の解決は、MVVMとRxSwiftおよびCoreDataフレームワークのアーキテクチャアプローチによって大幅に簡素化されます。
以下で説明するアプローチは、リアクティブプログラミングの原則を使用しており、RxSwiftおよびCoreDataだけに関連付けられているわけではありません。また、必要に応じて、他のツールを使用して実装できます。
例として、販売者データを表示するアプリケーションのスニペットを取り上げます。コントローラには、電話番号とアドレス用の2つのUILabelアウトレットと、この電話番号を呼び出すための1つのUIButtonがあります。ContactsViewController。
モデルからビューへの実装について説明します。
モデル
属性を持つ DerivedSourcesからの自動生成ファイルSellerContacts + CoreDataPropertiesのフラグメント:
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?
}
リポジトリ。
売り手データを提供する方法:
func sellerContacts() -> Observable<Event<[SellerContacts]>> {
// 1
Observable.merge([
// 2
context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
// 3
updater.sync()
])
}
ここにSSOTが実装されています。要求がに行われCoreData、そしてCoreDataをされ、必要に応じて更新します。すべてのデータはデータベースからのみ受信され、updater.sync()はエラーのあるイベントのみを生成できますが、データのイベントは生成できません。
- マージ演算子を使用すると、データベースへのクエリの非同期実行とその更新を実現できます。
- データベースへのクエリを作成するのに便利なように、RxCoreDataが使用されます
- データベースの更新
なぜなら データの受信と更新の非同期アプローチが使用されている場合は、Observable <Event <... >>を使用する必要があります。これは、サブスクライバーがリモートデータの受信中にエラーを受信したときにエラーを受信せず、このエラーのみを表示し、CoreDataの変更に応答し続けるために必要です。これについては後で詳しく説明します。
DatabaseUpdater
サンプルアプリケーションでは、リモートデータはFirebase RemoteConfigから取得されます。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()メソッドを呼び出すだけで、結果を返します。
func contacts() -> Observable<Event<[SellerContacts]>> {
repository.sellerContacts()
}
ViewController
コントローラーで、クエリ結果をフィールドにバインドする必要があります。これを行うには、 bindContacts()メソッドが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)
}
- バックグラウンドスレッドで連絡先のリクエストを実行し、その結果、メインで作業します
- イベントを含む要素がエラーで到着した場合、エラーメッセージが表示され、空のシーケンスが返されます。以下のflatMapErrorおよびshowMessage演算子の詳細
- CompactMap演算子を使用して配列から連絡先を取得する
- アウトレットへのデータの設定
演算子.flatMapError()
シーケンスの結果をイベントから含まれる要素に変換する、またはエラーを表示するには、次の演算子を使用します。
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に変換します
- イベントにエラーが含まれている場合は、空のシーケンスに変換されたハンドラーを返します
- イベントに結果が含まれている場合は、この結果を含む1つの要素を持つシーケンスを返します。
- デフォルトでは空のシーケンスが返されます
このアプローチにより、サブスクライバーにエラーイベントを送信せずにクエリ実行エラーを処理できます。また、データベースの変更の監視はアクティブなままです。
演算子.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)
}
- シーケンスに要素が1つだけ含まれていることを確認し、sync()メソッドを呼び出します
- シーケンスに2つの要素が含まれていることを確認します。1つにはエラーのあるイベントが含まれ、もう1つにはデータベースからのクエリの結果が含まれ、sync()メソッドが呼び出されます。
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)
}
- 更新が進行中の場合、空のシーケンスが返されます
- データが受信されない場合、空のシーケンスが返されます
- イベントがエラーで返されます
- データが更新されている場合は、空のシーケンスが返されます
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)
}
- エラーメッセージを表示する
- controller.presentedViewControllerにエラーメッセージがあることを確認します
- [OK]ボタンのハンドラーを実行し、メッセージボックスが非表示になっていることを確認します
- 空の結果の場合、エラーは表示されず、フィールドは入力されません
- リクエストが成功した場合、エラーは表示されず、フィールドに入力されます
オペレーターテスト
.flatMapError()。
showMessage()
同様の設計アプローチを使用して、SSOTの原則に従って、データ変更に応答する機能を失うことなく、非同期データ取得、データ更新、およびエラー通知を実装します。