CoreDataをマルチスレッドで使う方法
CoreDataをマルチスレッドで使い、UIスレッドをブロックせずにバックグラウンドで saveを行う方法はいくつかあります。その方法については Multi-Context CoreData というブログに詳しく紹介されています。
このうち、ブログの最後で紹介されている三段構成に興味があったのでテストコードを書いてみたのですが、謎の挙動にしばらく悩まされてしまいました。
Manaded Object Context の三段構成
詳しくはブログを見ていただくとして、三段構成をとった場合は、Managed Object Context(以下 Context)が三つ存在し、以下のような親子関係をとります。
- [親] Writing Context (実際にDBにデータを書くcontext)
- [子] Main Context (UIスレッドで画面表示に使用)
- [孫] Update Context (モデルに変更を加えるためのcontext)
- [子] Main Context (UIスレッドで画面表示に使用)
何かモデルに変更を加えたい場合は、孫コンテキストであるUpdate Contextを使って変更し、saveします。すると、その変更は直接の親である Main Contextに反映されます。
Main ContextはUIスレッド用のコンテキストです。リストビューのモデルとして使うNSFetchedResultsControllerなどはこのコンテキストを使って生成します。孫コンテキストを saveするとその変更はMain Contextに反映されるため、NSFetchedResultsControllerがその変更を検出してUIが更新されます。
ところが、子コンテキストであるMain Contextでも saveを行わないと、親コンテキストである Writing Contextには変更が伝搬しません。そのため、この時点ではまだ DBには変更は書き込まれません。
変更を永続化するためには Main Contextでも saveを呼ぶ必要があります。Main Contextでも saveを呼んでやることで、ようやく変更内容は最上位の親コンテキストである Writing Contextに伝搬します。
これで終わりではありません。Writing Contextに伝搬した修正をDBに書き込むためには、Writing Contextでも saveを呼ぶ必要があります。
複雑なので、全体の流れを番号付きで記述してみます。
- Update Contextでモデルに変更を加える
- Update Contextで save
- 変更が Main Contextに伝搬し、UIに変更が現れる。
- UI用に作った NSFetchedResultsControllerにも変更が伝わる
- Main Contextで save
- 変更が Writing Contextに伝搬
- Writing Contextで save
- 変更が DBに反映される
モデルのロールバックと NSFetchedResultsControllerの不整合
上記のこのような流れになるので、7が完了する前にアプリがクラッシュすると、モデルの状態は変更前の状態に戻ってしまうことになります。それを確認するため、オブジェクトを追加してsaveを行い、各タイミングでアプリを強制終了してみました。
場合分け
1で強制終了した場合
UIにも変更が伝わる前のタイミングです。強制終了しても特に問題は起こりません。次回起動時はオブジェクトが追加される前から開始します。
3(2の直後)に強制終了した場合
UIに変更が伝わり、画面上でもオブジェクトが追加されたことが確認できるタイミングです。ここで強制終了した場合、次回起動時はオブジェクトが追加される前から開始します。ユーザにとっては「あれ、画面に見えたのに」と思うかもしれませんが、この三段構成では起こり得る瞬間で、仕方ありません。
5(4の直後)に強制終了した場合
状況としては3の場合と同じですが、変更はWriting Contextまで伝わっています。ただ、Writing Contextの saveは行われていないので、DBには何も変更が入っていません。
ここで再起動すると、当然オブジェクトが追加される前にロールバックするはずなのですが、 なぜか NSFetchedResultsControllerの返す情報だけはオブジェクトが追加された後の値が帰ってきます。
たとえば、Table Viewの numberOfSectionsなどの実装が以下のようになっているとします。
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return self.fetchedResultsController.sections?.count ?? 0
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let sectionInfo = self.fetchedResultsController.sections![section]
return sectionInfo.numberOfObjects
}
この、sections.countや sectionInfo.numberOfObjectsの値を見てみると なぜかオブジェクトが一つ増えた時の値が帰ってきます。
たとえば、
- DBにはエントリが1つもないのに、セクション数として1が返ってくる
- DBにはエントリが3つしかないのに、numberOfObjectsが4を返す
といった現象が起こります。
DBに永続化されているオブジェクトの数が少ないので、このままアプリを動かすと Table Viewがレンジ外にアクセスしようとしたりしてクラッシュします。
原因
結論から言うと、 NSFetchedResultsControllerの cacheを有効にしており、そのキャッシュとDBの整合性が取れなくなっていたのが原因 でした。
let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: mainManagedObjectContext,
sectionNameKeyPath: nil,
cacheName: "Master")
NSFetchedResultsControllerには、初期化時の cacheNameにIDを指定すると、キャッシュが有効になるという仕組みがあります。このキャッシュというのが曲者で、オンメモリでキャッシュするだけでなく、 キャッシュ結果をファイルシステムに永続化します。
具体的には、アプリの Library/Caches/com.example.AppName/.CoreDataCaches/ というフォルダにキャシュが作られ、セクションの数や各セクション内のオブジェクトの数などが永続化されます。
上記 5の状態でアプリが強制終了すると、そのキャッシュファイルだけ更新され、DBが更新されないため、両者に不整合が起こります。
対策
キャッシュを使わないという対応も安直にはアリですが、パフォーマンス上キャッシュを有効にしておきたい場合は 起動時に毎回キャッシュを消す という方法もあります。
NSFetchedResultsControllerには deleteCacheWithNameというメソッドがあります。このメソッドにキャッシュ名を与えるか、nilを与えることでキャッシュをクリアすることができます。
// main contextに反映後、writer contextでの saveが行われないと、
// キャッシュとDBの不整合が起こることがあるので、FetchedResultsControllerのキャッシュを毎回消す
NSFetchedResultsController.deleteCacheWithName(nil)
これをアプリの起動時に呼び出しておけば、DBとキャッシュの不整合が起こらなくなります。ただし、前回起動時のキャッシュが使われないので、起動直後のパフォーマンスが犠牲になるかもしれません。
雑感
このキャッシュとDBの間の不整合は簡単にアプリのクラッシュを誘発するので、注意が必要です。
今回は三段構成を使ったケースで起こることが確認できましたが、そうでない場合でも起こる可能性は十分あると思います。たとえば、三段構成を使わない場合は、
- DBへのコミット
- UIスレッドにマージ
- キャッシュファイルの更新
という順序になることが多いとおもいます。この場合はDBが先に永続化されるので一見安全そうにみえますが、 オブジェクトが減るようなコミットが行われた場合 は、やはりキャシュされているセクション情報の方が値が大きい、と言ったことが起こる可能性があります。
ただし、Main Contextで saveを行わないような作りになっている場合はキャシュファイルも作られないようです。たとえば、CoreDataの更新はバックグラウンドスレッドでだけおこない、Main Contextはその修正を受け取ってマージするだけ、という作りになっている場合です。そのような作りになっている場合は今回の問題は起こらないかもしれませんが、もう少し調査が必要そうです。自分のアプリに影響があるか気になる場合は、アプリを動かしてみて、該当パスに .CoreDataCachesフォルダが作られているかいないかを見てみるのが手っ取り早いです。
いずれにせよ、万が一に備えてクリアする方法を用意しておくと良いかもしれません。たとえば、サポート窓口に「アプリが起動しない!」という問い合わせがあった場合に備え、アプリを safeモードで起動するURLスキームなどを定義しておく方法があります。(そのURLスキームで起動したらキャシュを削除する)