はじめに
今回はバックグラウンドからCore Dataを使用する方法について記載したいと思います。
例えば、バックグラウンドタスクなどのサブスレッド上で動いているものからCore Dataに書き込みを行いたい場合や、一度に時間のかかる大量のデータの処理を行いたい場合など、UIの更新を止めてしまうのを避けるためにこれが必要になります。
なお、ここではCore Dataの基本的な使い方についての知識はあることを前提として書いております。
もしまだ使ったことがないという方は、こちらの記事をご参照いただければと思います。
作成/実行環境は Xcode12.0.1 / Swift5.3 です。
準備
今回はサンプルとして、以下のようにNoteというエンティティが1つある前提とします。
text1 〜 text3はOptionalです。
全体的な流れ
バックグラウンドからCore Dataを使うにあたり、やらなければならないことは大きく以下の3つです。
- バックグラウンド用のNSManagedObjectContextの作成
- perform/performAndWaitを使っての処理実行
- マージポリシーの設定
バックグラウンド用のNSManagedObjectContextの作成
Core Dataはマルチスレッド環境で動作するよう設計されていますが、スレッドセーフではありません。
NSManagedObjectのインスタンスを複数のスレッドやキューをまたいで使用すると、普通にデータの不整合を起こしたりアプリがクラッシュしたりします。
Core Dataでは、この問題をシリアルキューを使用して解決するようになっています。
具体的には、NSManagedObjectContextは初期化時に内部に1つのシリアルキューを保持します。
これは初期化時の設定により、メインキューへの参照、もしくは独自に作成したプライベートキューのいずれかになります。
このシリアルキューを使い、データの読み書きをすべて直列化された処理の中で行うようにすることで排他制御をなし、安全に読み書きできる仕組みを取っています。
我々がよく使用するNSPersistentContainerのviewContextは、内部でメインキューへの参照を持つため、常にメインスレッド、メインキュー上で動作するようになっています。
そのため、バックグラウンドからCore Dataへアクセスするためには、まず内部でプライベートキューを持った独自のNSManagedObjectContextのインスタンス(以下、コンテキスト)を作成する必要があるということです。
ちなみに、Appleの公式ドキュメント(この記事の最後にリンクを載せています)を読むと言及されていますが、「コンテキストはそれを使用したいスレッドの中で初期化をし、それ以外のスレッドに渡さないようにする」ですとか、「NSManagedObjectのインスタンス(以下、管理オブジェクト)は他のキューへ渡さないようする」ですとか、そういった原則を掲げています。
なので、バックグラウンドでCore Dataを使用する際は、スレッドごとにコンテキストを作成し、管理オブジェクトはそのコンテキストが持つキューから呼ばれた処理の中でのみ登場するようにする必要があります。
perform/performAndWaitを使っての処理実行
コンテキストは内部にシリアルキューを持つと説明しましたが、しかし、これは我々が直接利用できるようにはなっていません。
このシリアルキューの恩恵にあずかるためには、NSManagedObjectContextのインスタンスメソッドであるperform、もしくはperformAndWaitを使用します。
これらはDispatchQueueのasyncやsyncみたいなもので、引数にクロージャーを1つ渡すようになっており、そのクロージャーを非同期的あるいは同期的に呼び出してくれます。
そしてデータの読み書きは、このクロージャーの中で行います。
マージポリシーの設定
複数のスレッドから読み書きが行われれば、当然それをマージする必要がでてきます。
自動で行うこともできますし、手動で行うことも可能です。
(手動でのマージ方法については割愛いたします)
いずれにせよ、最後にこのマージのポリシーを決定、設定して準備万端となります。
バックグラウンド用のNSManagedObjectContextの作成
ではさっそく、バックグラウンド用のコンテキストの作り方から見ていきます。
方法としては3つあります。
- NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)で作成する
- NSPersistentContainerのnewBackgroundContextメソッドを使う
- NSPersistentContainerのperformBackgroundTaskメソッドを使う
それぞれに特徴があるので、1つずつ説明します。
NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)で作成する
一番基本的(マニュアル)な方法です。
NSManagedObjectContextのイニシャライザであるinit(concurrencyType:)を使用しています。
引数のconcurrencyTypeはNSManagedObjectContextConcurrencyTypeの値を取り、これは
mainQueueConcurrencyTypeまたはprivateQueueConcurrencyTypeのいずれかを指定することができます。
・mainQueueConcurrencyType
こちらを渡すと、NSPersistentContainerが持つviewContextと同様、メインキューに結び付けられたコンテキストが作成されます。
こちらは常にメインスレッド(メインキュー)でのみ動作するため、今回のようなバックグラウンド処理用途には使用しません。(UIに絡む処理で、viewContextと分けたい場合などに用いる)
・privateQueueConcurrencyType
こちらを渡すと、今回お目当てとなる「内部に独自のシリアルキューを持ったコンテキスト」が作成されます。
↓サンプルコード
// コンテキスト作成
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
コードにあるように、この方法で作成したコンテキストには、persistentStoreCoordinatorプロパティまたはparentプロパティのいずれか1つに値を設定する必要があります。
今回、これらのプロパティに関する詳細は割愛させていただきますが、簡単に言えば、persistentStoreCoordinatorを設定した場合、作成したコンテキストに変更が生じる(データの読み書きを行ってsaveを呼ぶ)と、すぐさまPersistentStore(SQLite等の実体ファイル)にその変更が反映されますが、parent(別のコンテキストを指定する)を設定すると、コンテキストの変更はまずその親のコンテキストに反映され、そして親のコンテキストが保存されたタイミングでPersistentStoreに反映されるようになります。
※ parentに設定したコンテキストがさらに親を持つ場合は、最後の親(persistentStoreCoordinatorが設定されたもの)にたどり着くまで保存を繰り返す必要があります。
ここではひとまず、persistentStoreCoordinatorを設定しています。
ちなみに、両方のプロパティをセットすると実行時エラーになります。
また、3行目のpersistentContainer.persistentStoreCoordinatorは、Xcodeプロジェクト作成時に「Use Core Data」にチェックを入れると自動で作ってくれるAppDelegateファイルにあるものだと思ってください。
NSPersistentContainerのnewBackgroundContextメソッドを使う
NSPersistentContainerのインスタンスメソッドであるnewBackgroundContextを使用するパターンです。(iOS10から追加)
これは名前のとおり、まさにバックグラウンド用のコンテキストを作成するためのメソッドになります。
↓サンプルコード
// コンテキスト作成
let context = persistentContainer.newBackgroundContext()
サンプルを挙げるまでもない……
persistentContainerは上記同様、AppDelegateにあるものと思ってください。(もちろん別途用意したものでもOK)
1つ目の例では作成したcontextのpersistentStoreCoordinatorプロパティに値をセットしていましたが、こちらではnewBackgroundContextを呼び出したpersistentContainerが持つpersistentStoreCoordinatorが自動でセットされるため、不要になります。
また、persistentStoreCoordinatorがデフォルトでセットされるため、parentプロパティに値をセットすると実行時エラーになります。(persistentStoreCoordinatorにnilを入れてparentを入れれば平気かも?)
コンテキストの親子関係を必要としないのであれば、1行で済むこちらのほうが楽でいいですね。
NSPersistentContainerのperformBackgroundTaskメソッドを使う
最後はnewBackgroundContextをさらに便利にしたものです。
↓サンプルコード
// コンテキスト作成と処理の実行
persistentContainer.performBackgroundTask { context in
// contextを使った何かしらの処理
}
引数にクロージャーを指定します。
performBackgroundTaskを呼ぶと内部で新たにコンテキストが作成され、そのコンテキストを引数として、指定したクロージャーが内部のシリアルキューを介して呼び出されます。(非同期で呼び出され、サブスレッドで動作)
newBackgroundContextと後述するperformメソッドを一体化させた感じですね。
それぞれの使い所
3つ紹介したそれぞれの使い所としては、
performBackgroundTask(3つ目)は、呼び出しのたびにコンテキストが作成され、クロージャーから抜けるとそれが破棄されるため、例えばバックグラウンドでJSONのダウンロードを行ったあと、中身のデータをまとめてCore Dataに保存したい場合など、何かを単発で処理したいケースに向いています。
newBackgroundContext(2つ目)は、作成したコンテキストを使い回すことができるため、「データを読み込んで、何かの処理を行って、結果を書き込んで、また何かをやって…」みたいな、しばらくの間Read/Writeが発生するような場合に使えます。
NSManagedObjectContextのイニシャライザ(1つ目)は、上記2つ以外、例えばメインスレッド利用のコンテキストを作る場合や、コンテキストの親子関係を利用する場合など、上記2つでは利かない要求が発生する場合に使用します。
何はともあれ、これでバックグラウンド用のコンテキストは作成できました。
perform/performAndWaitを使って処理を実行
さて、コンテキストの作成ができましたので、次はperformやperformAndWaitのメソッドを使ってデータの読み書きを行っていきます。
1つずつサンプルを挙げて説明したいと思います。
perform
↓まずはperformから。
func performTest1() {
print("-- start --")
print("performTest: \(Thread.current)")
// コンテキスト作成
let context = self.persistentContainer.newBackgroundContext()
// 書き込み処理
context.perform {
print("perform 1: \(Thread.current)")
self.addNote(id: 100, text: "performTest", into: context)
try? context.save()
print("perform 1: end")
}
// 読み込み処理
context.perform {
print("perform 2: \(Thread.current)")
self.printAllNotes(in: context)
print("perform 2: end")
}
print("-- end --")
}
上はAppDelegate.swiftの適当な場所に書いて、適当なタイミングでperformTest1()を呼んでいるものと思ってください。
addNoteメソッドやprintAllNotesメソッドなどについては、この記事の最後のほうにある「AppDelegateのソース」の項目に載せています。
↓で、実行結果
-- start --
performTest: <NSThread: 0x281baee80>{number = 1, name = main}
-- end --
perform 1: <NSThread: 0x281bc00c0>{number = 6, name = (null)}
perform 1: end
perform 2: <NSThread: 0x281bc00c0>{number = 6, name = (null)}
100, performTest, (nil), (nil)
perform 2: end
上でも触れたとおり、performは使い方としてはDispatchQueueのasyncメソッドと同じです。
呼ぶと引数で渡したクロージャーが非同期に呼び出され、サブスレッド(プライベートキューを持つコンテキストの場合)で実行されます。
ログを見ても、メインスレッドから呼んでいますが、クロージャーの中はサブスレッドで動いていることがわかります。
そして、エンティティの作成や保存、フェッチなどはすべて、このクロージャーの中で行うようにします。
そうすることでキューをまたがず、シリアルキューのため処理順が入れ替わることもなくかつ排他的に処理がされ、データの整合性が保たれることになるわけです。
performはもちろんネストして呼ぶことも可能です。
↓サンプル2
func performTest2() {
print("-- start --")
print("performTest: \(Thread.current)")
// コンテキスト作成
let context = self.persistentContainer.newBackgroundContext()
// 書き込み処理
context.perform {
print("perform 1: \(Thread.current)")
self.addNote(id: 100, text: "performTest 1", into: context)
// 書き込み処理2
context.perform {
print("perform 2: \(Thread.current)")
self.addNote(id: 200, text: "performTest 2", into: context)
// 読み込み処理
context.perform {
print("perform 3: \(Thread.current)")
self.printAllNotes(in: context)
print("perform 3: end")
}
try? context.save()
print("perform 2: end")
}
print("perform 1: end")
}
print("-- end --")
}
↓実行結果
-- start --
performTest: <NSThread: 0x280a93080>{number = 1, name = main}
-- end --
perform 1: <NSThread: 0x280af82c0>{number = 6, name = (null)}
perform 1: end
perform 2: <NSThread: 0x280af82c0>{number = 6, name = (null)}
perform 2: end
perform 3: <NSThread: 0x280af82c0>{number = 6, name = (null)}
100, performTest 1, (nil), (nil)
200, performTest 2, (nil), (nil)
perform 3: end
特に難しいことはないですね。
最後に、このperformメソッドはautorelease poolが適用され、かつクロージャーを抜けた後で自動的にNSManagedObjectContextのprocessPendingChangesメソッドが実行されます。
autorelease poolが適用されるというのは、通常、処理中に使用したメモリはすべてのサブルーチンを抜けた(イベントループの終わりに達した)後でARCによって自動的に解放が行われますが、これだとfor文などでメモリの取得が数多く繰り返し行われると、その途中でメモリ不足となってしまう可能性が出てしまいます。
それを防ぐため、for文の中を autoreleasepool { } で括ると、このスコープの中で作成したインスタンスはこのスコープを抜けた時に解放が行われるようになります。
performは内部でこれが適用されているため、performのクロージャーの中で取ったメモリはそれを抜けた段階で解放される、というわけです。
また、processPendingChangesというのは、コンテキストのsaveの一歩手前、UNDOマネージャにそこまでの変更が登録されるメソッドになります。
これは通常、データの読み書きを行った後はすぐsaveを呼ぶ(saveの中でprocessPendingChangesが自動的に呼ばれる)ため、あまり意識する必要はないかと思います。(バックグラウンドから使うのならなおさら)
performAndWait
続いてperformAndWaitですが、こちらは使い方としてはDispatchQueueのsyncメソッドと同じです。
名前のとおり同期的に引数のクロージャーが呼び出され、その中の処理を抜けるまで制御が返ってきません。
↓サンプルコード
func performAndWaitTest1() {
print("-- start --")
print("performAndWaitTest: \(Thread.current)")
// コンテキスト作成
let context = self.persistentContainer.newBackgroundContext()
// 書き込み処理1
context.performAndWait {
print("performAndWait 1: \(Thread.current)")
self.addNote(id: 100, text: "performAndWaitTest 1", into: context)
// 書き込み処理2
context.performAndWait {
print("performAndWait 2: \(Thread.current)")
self.addNote(id: 200, text: "performAndWaitTest 2", into: context)
print("performAndWait 2: end")
}
try? context.save()
print("performAndWait 1: end")
}
// 読み込み処理
context.performAndWait {
print("performAndWait 3: \(Thread.current)")
self.printAllNotes(in: context)
print("performAndWait 3: end")
}
print("-- end --")
}
↓実行結果。
-- start --
performAndWaitTest: <NSThread: 0x283aa5d80>{number = 1, name = main}
performAndWait 1: <NSThread: 0x283aa5d80>{number = 1, name = main}
performAndWait 2: <NSThread: 0x283aa5d80>{number = 1, name = main}
performAndWait 2: end
performAndWait 1: end
performAndWait 3: <NSThread: 0x283aa5d80>{number = 1, name = main}
100, performAndWaitTest 1, (nil), (nil)
200, performAndWaitTest 2, (nil), (nil)
performAndWait 3: end
-- end --
performと違って、performAndWaitはそれを呼び出したのと同じスレッド上でクロージャーが実行されていることがわかります。
ここではメインスレッドから呼び出したため、中の処理もすべてメインスレッドとなっています。
ただし、DispatchQueueのsyncはメインスレッドからメインキュー(DispatchQueue.mainとか)のsyncを呼ぶとデッドロックを発生させますが、performAndWaitはリエントラント(再入可能)となっているため、こちらはデッドロックになることなく処理が進められています。(viewContextのようなメインキューへの参照を持つコンテキストでも同様)
また、ネストの呼び出しを行っても正しく処理され、かつクロージャーが終わるまで待たされるため上から下まで順番にログ出力が行われています。
↓ではこれを、サブスレッドから呼んでみます。
func performAndWaitTest2() {
// シリアルキューの作成
let queue = DispatchQueue(label: "performAndWaitTest2")
queue.async {
print("DispatchQueue: \(Thread.current)")
self.performAndWaitTest1()
}
}
↓実行結果。
DispatchQueue: <NSThread: 0x281f19e40>{number = 5, name = (null)}
-- start --
performAndWaitTest: <NSThread: 0x281f19e40>{number = 5, name = (null)}
performAndWait 1: <NSThread: 0x281f19e40>{number = 5, name = (null)}
performAndWait 2: <NSThread: 0x281f19e40>{number = 5, name = (null)}
performAndWait 2: end
performAndWait 1: end
performAndWait 3: <NSThread: 0x281f19e40>{number = 5, name = (null)}
100, performAndWaitTest 1, (nil), (nil)
200, performAndWaitTest 2, (nil), (nil)
performAndWait 3: end
-- end --
各クロージャーの中がすべて同じサブスレッドとなりました。
また、こちらはperformと違って、ドキュメントに特にautorelease poolですとかprocessPendingChangesに関する記述はないので、必要なら自ら行う必要がありそうです。(すみません、そこまで調べていません)
performBackgroundTask
NSManagedObjectContextの作成方法の3つ目に挙げた、NSPersistentContainerのperformBackgroundTaskについても触れておきたいと思います。
こちらは上で「newBackgroundContext + performのようなもの」と説明していますが、一点だけ注意が必要です。
それは、newBackgroundContext + performはネストしても何をしてもコンテキストは使い回されますが、performBackgroundTaskは呼び出すたびに内部で新たなコンテキストが作成されるため、ネストして呼び出してもそれぞれ別物のコンテキストになる点です。
↓サンプルコード
func performBackgroundTaskTest() {
print("-- start --")
print("performBackgroundTaskTest: \(Thread.current)")
// 書き込み処理1
self.persistentContainer.performBackgroundTask { context in
print("performBackgroundTask 1: \(Thread.current)")
print("context 1: \(context)")
// 書き込み処理2
self.persistentContainer.performBackgroundTask { context in
print("performBackgroundTask 2: \(Thread.current)")
print("context 2: \(context)")
self.addNote(id: 200, text: "performBackgroundTaskTest 2", into: context)
try? context.save()
print("performBackgroundTask 2: end")
}
self.addNote(id: 100, text: "performBackgroundTaskTest 1", into: context)
try? context.save()
print("performBackgroundTask 1: end")
}
// 読み込み処理
self.persistentContainer.performBackgroundTask { context in
print("performBackgroundTask 3: \(Thread.current)")
print("context 3: \(context)")
self.printAllNotes(in: context)
print("performBackgroundTask 3: end")
}
print("-- end --")
}
↓実行結果。
-- start --
performBackgroundTaskTest: <NSThread: 0x2829faac0>{number = 1, name = main}
-- end --
performBackgroundTask 3: <NSThread: 0x282991580>{number = 6, name = (null)}
context 3: <NSManagedObjectContext: 0x2805fc8c0>
performBackgroundTask 3: end
performBackgroundTask 1: <NSThread: 0x2829a1540>{number = 5, name = (null)}
context 1: <NSManagedObjectContext: 0x2805fc7e0>
performBackgroundTask 2: <NSThread: 0x282991580>{number = 6, name = (null)}
context 2: <NSManagedObjectContext: 0x2805f80e0>
performBackgroundTask 1: end
performBackgroundTask 2: end
コンテキストもばらばら、クロージャーのスレッドもばらばらです。(スレッドは一部使い回されていますが)
タイミング的に書き込みが間に合わなかったため、追加したデータもログに吐き出されていません。
なので、やはりperformBackgroundTaskは、非同期に単発で何かを処理したいケースで使うのが良さそうです。
マージポリシーの設定
最後にマージについて押さえておきます。
上でも述べたとおり、複数のスレッドから同時に読み書きが行われた場合、データの競合(コンフリクト)が発生する可能性が出てきます。
例えば、以下のような流れです。
- スレッドA: id = 100のNoteを読み込み
- スレッドB: id = 100のNoteを読み込み
- スレッドB: text1の内容を変更後、コンテキストに保存
- スレッドA: text1の内容を変更後、コンテキストに保存
スレッドAとBは並列処理で同時進行として、結果的に上から順番に処理が行われた場合、AはBの変更が行われる前のデータからの変更になるため、AとBのどちらの変更を採用していいのかが判断できません。
この時、特に何のマージに関する設定を行っていない場合、コンテキストのsaveを呼ぶと例外のエラーが飛ばされます。
その例外(Errorオブジェクト)の中には競合に関する情報があるので、それを使えば自前で競合を解決することもできますが、面倒なので複雑なマージを必要とするのでなければやりたくありません。
なので、コンテキストに対してマージポリシー(競合検出時にそれをどう回避するか)の設定を行い、それを内々で自動的に解決してもらうようにします。
具体的には、NSManagedObjectContextのmergePolicyプロパティに対し、以下の4つのうちいずれかのものをセットします。
- NSMergeByPropertyStoreTrumpMergePolicy
- NSMergeByPropertyObjectTrumpMergePolicy
- NSOverwriteMergePolicy
- NSRollbackMergePolicy
ちなみに、このいずれもセットしない場合(デフォルト値)はNSErrorMergePolicy(またはNSMergePolicy.error)となります。
これもご紹介します。
各ポリシーの説明
NSMergeByPropertyStoreTrumpMergePolicy
NSMergeByPropertyStoreTrumpMergePolicyは、persistent store(NSPersistentStore、つまりデータベースやファイル等。以下、ストア)のバージョンとin-memory(コンテキストが現在保持している内容を指す)のバージョンの間の競合を、管理オブジェクトの属性(プロパティ)単位でマージして解決するポリシーです。
競合したプロパティがあれば、ストア側のプロパティを採用します。
iOS 10以上の場合、NSMergePolicy.mergeByPropertyStoreTrumpでも指定可能です。
NSMergeByPropertyObjectTrumpMergePolicy
NSMergeByPropertyObjectTrumpMergePolicyは、ストアのバージョンとin-memoryのバージョンの間の競合を、管理オブジェクトの属性(プロパティ)単位でマージして解決するポリシーです。
競合したプロパティがあれば、in-memory側のプロパティを採用します。
iOS 10以上の場合、NSMergePolicy.mergeByPropertyObjectTrumpでも指定可能です。
NSOverwriteMergePolicy
NSOverwriteMergePolicyは、ストアのバージョンとin-memoryのバージョンの間の競合を、管理オブジェクト単位でマージして解決するポリシーです。
競合したプロパティがあれば、in-memory側の管理オブジェクトの内容でまるごと上書きします。
iOS 10以上の場合、NSMergePolicy.overwriteでも指定可能です。
NSRollbackMergePolicy
NSRollbackMergePolicyは、ストアのバージョンとin-memoryのバージョンの間の競合を、管理オブジェクト単位でマージして解決するポリシーです。
競合したプロパティがあれば、管理オブジェクトをまるごとストア側の内容に戻します。
iOS 10以上の場合、NSMergePolicy.rollbackでも指定可能です。
NSErrorMergePolicy
NSErrorMergePolicyは、自動での競合の解決を行わず、例外エラーを発生させるポリシーです。
コンテキストを作成してmergePolicyに何も設定しなかった場合、これがデフォルト値として設定されます。
iOS 10以上の場合、NSMergePolicy.errorでも指定可能です。
各ポリシーの挙動の確認
言葉だけではわかりづらいと思いますので、サンプルコードで挙動を確認します。
↓サンプルコード
func mergeTest() {
let viewContext = persistentContainer.viewContext
// 初期データ登録
viewContext.performAndWait {
self.deleteAllNotes(with: viewContext)
self.addNote(id: 100, text1: "viewContext", text2: "viewContext", text3: "viewContext", into: viewContext)
self.addNote(id: 200, text1: "viewContext", text2: "viewContext", text3: "viewContext", into: viewContext)
self.addNote(id: 300, text1: "viewContext", text2: "viewContext", text3: "viewContext", into: viewContext)
self.addNote(id: 400, text1: "viewContext", text2: "viewContext", text3: "viewContext", into: viewContext)
self.addNote(id: 500, text1: "viewContext", text2: "viewContext", text3: "viewContext", into: viewContext)
try? viewContext.save()
print("=== INITIAL DATA ===")
self.printAllNotes(in: viewContext)
}
// テスト
let queue = DispatchQueue(label: "mergeTest")
queue.async {
print("=== Test for mergeByPropertyStoreTrump ===")
self.mergeTestCore(id: 100, mergePolicy: .mergeByPropertyStoreTrump)
print("=== Test for mergeByPropertyObjectTrump ===")
self.mergeTestCore(id: 200, mergePolicy: .mergeByPropertyObjectTrump)
print("=== Test for overwrite ===")
self.mergeTestCore(id: 300, mergePolicy: .overwrite)
print("=== Test for rollback ===")
self.mergeTestCore(id: 400, mergePolicy: .rollback)
print("=== Test for error ===")
self.mergeTestCore(id: 500, mergePolicy: .error)
// 結果確認
viewContext.performAndWait {
print("=== RESULT DATA ===")
self.printAllNotes(in: viewContext)
}
}
}
func mergeTestCore(id: Int, mergePolicy: NSMergePolicy) {
// viewContextの設定
let viewContext = persistentContainer.viewContext
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = mergePolicy
// コンテキストの作成
let bgContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
bgContext.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
bgContext.automaticallyMergesChangesFromParent = true
bgContext.mergePolicy = mergePolicy
// データ更新
viewContext.performAndWait {
// ①
let note = self.getNote(id: id, from: viewContext)!
note.text2 = "viewContext 2"
note.text3 = "viewContext 2"
bgContext.performAndWait {
// ②
let note = self.getNote(id: id, from: bgContext)!
note.text1 = "bgContext"
note.text3 = "bgContext"
// ③
try? bgContext.save()
}
print("\(note.id), \(note.text1!), \(note.text2!), \(note.text3!)")
// ④
do {
try viewContext.save()
}
catch {
print(error)
}
}
}
引き続きAppDelegate.swiftの中に書いて、任意の場所からmergeTest()を呼んでいると思ってください。
最初に5件のNoteを登録し、5つのマージポリシーを1つずつ競合が発生するようにして更新しています。
イメージとしては↓のような感じです。(コメント①〜③まで)
text1はbgContextだけが、text2はviewContextだけが更新しています。
text3はどちらも更新しており、このプロパティが競合対象となります。
では実行してみます。↓
=== INITIAL DATA ===
100, viewContext, viewContext, viewContext
200, viewContext, viewContext, viewContext
300, viewContext, viewContext, viewContext
400, viewContext, viewContext, viewContext
500, viewContext, viewContext, viewContext
=== Test for mergeByPropertyStoreTrump ===
100, viewContext, viewContext 2, viewContext 2
=== Test for mergeByPropertyObjectTrump ===
200, viewContext, viewContext 2, viewContext 2
=== Test for overwrite ===
300, viewContext, viewContext 2, viewContext 2
=== Test for rollback ===
400, viewContext, viewContext 2, viewContext 2
=== Test for error ===
500, viewContext, viewContext 2, viewContext 2
Error Domain=NSCocoaErrorDomain Code=133020 "Could not merge changes." UserInfo={conflictList=(
"NSMergeConflict (0x283c7c5c0) for NSManagedObject (0x280aeb3e0) with objectID '0xfa39f4360de1a370 <x-coredata://327D9384-1B5E-4176-8765-AAFBD4855662/Note/p1125>' with oldVersion = 1 and newVersion = 2 and old object snapshot = {\n id = 500;\n text1 = viewContext;\n text2 = viewContext;\n text3 = viewContext;\n} and new cached row = {\n id = 500;\n text1 = bgContext;\n text2 = viewContext;\n text3 = bgContext;\n}"
), NSExceptionOmitCallstacks=true}
=== RESULT DATA ===
100, bgContext, viewContext 2, bgContext
200, bgContext, viewContext 2, viewContext 2
300, viewContext, viewContext 2, viewContext 2
400, bgContext, viewContext, bgContext
500, bgContext, viewContext 2, viewContext 2
以下、結果をイメージで確認します。
NSMergeByPropertyStoreTrumpMergePolicy
NSMergeByPropertyStoreTrumpMergePolicyはプロパティ単位で、競合発生時はストア側が優先されるポリシーですので、viewContext側の内容はtext2だけ反映され、text3は先にストアに反映したbgContext側の内容が採用となっています。
「プロパティ単位で先勝ち」のポリシーと覚えて良さそうです。
NSMergeByPropertyObjectTrumpMergePolicy
NSMergeByPropertyObjectTrumpMergePolicyはプロパティ単位で、競合発生時はin-memory側が優先されるポリシーですので、競合となったtext3はviewContext側が採用されました。
「プロパティ単位で後勝ち」のポリシー。
NSOverwriteMergePolicy
NSOverwriteMergePolicyは管理オブジェクト単位で、競合発生時はin-memory側が優先されるポリシーですので、競合はtext3だけですが結果はまるっとviewContext側が採用されました。
「管理オブジェクト単位で後勝ち」のポリシー。
NSRollbackMergePolicy
NSRollbackMergePolicy管理オブジェクト単位で、競合発生時はストア側が優先されるポリシーですので、viewContext側の変更はすべて破棄されました。
「管理オブジェクト単位で先勝ち」のポリシー。
NSErrorMergePolicy
こちらはイメージは用意していませんが、ログを見ると確かにエラーが出ており、UserInfoのconflictListにその競合の内容(ストア側の古いバージョンと新しいバージョン)が書かれているのがわかります。
ログの結果を見る限り、競合解決前はひとまずNSMergeByPropertyObjectTrumpMergePolicyと同じ結果になるようですね。
競合解決に関するAppleのドキュメントへのリンクは最後に載せております。
automaticallyMergesChangesFromParent
最後に1つ、NSManagedObjectContextが持つautomaticallyMergesChangesFromParentプロパティについても記しておきたいと思います。
上記サンプルではさらっとこれにtrueを設定していましたが、これは名前のとおり、「親(persistentStoreCoordinatorもしくはparentに設定したもの)に変更が生じたら、その内容を自動的に自分にマージするか否か」を示すプロパティです。
↓サンプルコード
func autoMergeTest() {
// テスト
let queue = DispatchQueue(label: "autoMergeTest")
queue.async {
print("=== Test for automaticallyMergesChangesFromParent: false ===")
self.autoMergeTestCore(id: 800, autoMerge: false)
print("=== Test for automaticallyMergesChangesFromParent: true ===")
self.autoMergeTestCore(id: 900, autoMerge: true)
}
}
func autoMergeTestCore(id: Int, autoMerge: Bool) {
// viewContextの設定
let viewContext = persistentContainer.viewContext
viewContext.automaticallyMergesChangesFromParent = autoMerge
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
// コンテキストの作成
let bgContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
bgContext.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
bgContext.automaticallyMergesChangesFromParent = autoMerge
bgContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
// データ登録
bgContext.performAndWait {
self.addNote(id: id, text: "autoMergeTest", into: bgContext)
try? bgContext.save()
}
// 内容確認
if let note = self.getNote(id: id, from: viewContext) {
print("Before: \(note.id), \(note.text1 ?? "(nil)")")
}
// データ更新
bgContext.performAndWait {
let note = self.getNote(id: id, from: bgContext)!
note.text1 = "Modified"
try? bgContext.save()
}
// 内容確認
if let note = self.getNote(id: id, from: viewContext) {
print("After: \(note.id), \(note.text1 ?? "(nil)")")
}
}
autoMergeTest()を任意の場所で呼んでください。
↓実行結果。
=== Test for automaticallyMergesChangesFromParent: false ===
Before: 800, autoMergeTest
After: 800, autoMergeTest
=== Test for automaticallyMergesChangesFromParent: true ===
Before: 900, autoMergeTest
After: 900, Modified
automaticallyMergesChangesFromParentをtrueにした場合のみ、text1の編集直後にその内容が拾えていることがわかります。
ただし、気付いた方もいるかと思いますが、内容確認のところでコンテキストや管理オブジェクトを viewContext.performAndWait で囲まずそのまま使用しています。
これはここまで説明してきたとおりダメな使用方法なのですが、しかしperformAndWaitで囲むと、id = 800(automaticallyMergesChangesFromParentがfalse)の方も「Modified」となってログ出力されます。
つまりはマージされた結果が取れてしまいます。
どうやらこれをfalseにしたからと言って、自分で処理しない限りいつまでもマージされないというわけでもなく、どこかのタイミングでは内部でそれを行うようです。(イベントループを抜けた後とか?)
ここらへんは詳細を探しましたがどこにも記載がなく、よくわからないままとなっています💦
(どなたか詳しい方がいましたら、ご教示いただけますと幸いです)
なので、ひとまずこのプロパティに関しては、額面通りの挙動を期待してtrue/falseを決め(例えば、UIの更新はユーザーアクションで行うからfalseにしよう、とか)、そうじゃない挙動をしたら別な対策を打つ、みたいな捉え方をしておくのがいいのかもしれません。
デバッグオプション
デバッグ時に役に立つものを最後に1つ。
Xcodeのメニューから Product > Edit > Scheme > Edit Scheme… をたどり、開いた画面のRun (Debug) のArgumentsタブの「Arguments Passed On Launch」に以下の内容を加えると、プログラム実行中、誤ったコンテキストや管理オブジェクトの使い方(スレッドやキュー間にまたがって使用した場合等)をするとその場所でブレイクしてくれるようになります。
-com.apple.CoreData.ConcurrencyDebug 1
これを設定した上で、上記のautomaticallyMergesChangesFromParentのテスト用コードを動かすと、ちゃんとperformAndWaitで囲んでいないところでブレイクしてくれます😅
AppDelegateのソース
上記サンプルコード中に使われているメソッドなどを記述したAppDelegateのソースです。
(テスト部分は除く)
import UIKit
import CoreData
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "LearnCoreDataInBackground")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
extension AppDelegate {
// MARK: - Core Data Operations
/**
* ノートを1件追加する
*/
@discardableResult
func addNote(id: Int, text: String, into context: NSManagedObjectContext) -> Note {
return addNote(id: id, text1: text, text2: nil, text3: nil, into: context)
}
/**
* ノートを1件追加する
*/
@discardableResult
func addNote(id: Int, text1: String?, text2: String?, text3: String?, into context: NSManagedObjectContext) -> Note {
let entity = NSEntityDescription.entity(forEntityName: "Note", in: context)!
let note = Note(entity: entity, insertInto: context)
note.id = Int32(id)
note.text1 = text1
note.text2 = text2
note.text3 = text3
return note
}
/**
* ノートを全件取得する
*/
private func fetchNotes(with context: NSManagedObjectContext) throws -> [Note] {
let request = Note.fetchRequest() as NSFetchRequest<Note>
request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)]
return try context.fetch(request) as [Note]
}
/**
* ノートを取得する
*/
private func getNote(id: Int, from context: NSManagedObjectContext) -> Note? {
let request = Note.fetchRequest() as NSFetchRequest<Note>
request.predicate = NSPredicate(format: "id == %d", id)
let result = try? context.fetch(request) as [Note]
return result?.first
}
/**
* ノートを全件出力する
*/
private func printAllNotes(in context: NSManagedObjectContext) {
try? fetchNotes(with: context).forEach { note in
print("\(note.id), \(note.text1 ?? "(nil)"), \(note.text2 ?? "(nil)"), \(note.text3 ?? "(nil)")")
}
}
/**
* ノートを全件削除する
*/
private func deleteAllNotes(with context: NSManagedObjectContext) {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Note")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try persistentContainer.persistentStoreCoordinator.execute(deleteRequest, with: context)
}
catch {
print(error)
}
}
/**
* ノートを全件、表示後に削除する
*/
private func showAndDeleteAll(in context: NSManagedObjectContext? = nil) {
let context = context ?? self.persistentContainer.viewContext
printAllNotes(in: context)
deleteAllNotes(with: context)
}
}
参考リンク
Apple Developer Documentation
・Using Core Data in the Background
・NSManagedObjectContext – Concurrency
・Conflict Resolution
おわりに
いかがでしたでしょうか。
いつも記事を書き出す前は「短く、簡潔に」と思っているのに、いざ書き終えると「めっさ長なった……」と自身に呆れている自分がいます。
何でも細かいことが気になる性分ゆえ、ついつい深追いして結果長文になるという悪循環です。
しかし、世の中には私の他にも細かいところまで気になるという方はたくさんいると思いますので、この記事もそうした方々の一助となれば幸いです。
それでは。