【Swift】Core Dataの使い方。Fetched Results Controllerでフェッチ結果を扱いやすくする(Swift 2.1、XCode 7.2)
Fetched Results Controllerとは
本記事ではCore DataのFetched Results Controller(以下、フェッチドリザルトコントローラー)について説明する。
フェッチドリザルトコントローラーとは、フェッチ結果と管理オブジェクトコンテキスト(以下、コンテキスト)を管理するコントローラーである。フェッチ結果の扱いやセクション分けを容易にしたり、コンテキストの変更を通知してくれたりする。
フェッチドリザルトコントローラーを試す
実際にフェッチドリザルトコントローラーを使ってみよう。
以降の手順を行う前のXcodeプロジェクトをGitHubに置いたので、試してみる人はご利用されたし。
⇒「テスト用プロジェクト」
事前準備では、フェッチドリザルトコントローラーを使わずに、入力文字列を含むデータをフェッチして表示し、スワイプして削除できるように実装しておいた。これをフェッチドリザルトコントローラーを使う実装に変更する。
ViewController.swiftを以下のコードに変更する。
ViewDidLoadメソッドでフェッチドリザルトコントローラーのインスタンスを作り、そのインスタンスに対して各メソッドでフェッチ実行やデータ削除、保存を実行している。
フェッチドリザルトコントローラーがフェッチ結果を保持しているので、フェッチ結果の配列をViewControllerで保持する必要がなくなった。さらに、コンテキストが更新されるとフェッチ結果も自動で更新される。
ただし、フェッチ結果が自動で更新されるのはプロセスが終了してからなので、データ削除後のテーブルビュー更新はフェッチドリザルトコントローラーのデリゲートメソッド「コンテキスト変更時の呼び出しメソッド」で行っている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
// // ViewController.swift // import UIKit import CoreData class ViewController: UIViewController, UITableViewDataSource, UISearchBarDelegate, NSFetchedResultsControllerDelegate{ @IBOutlet weak var testTableView: UITableView! @IBOutlet weak var testSearchBar: UISearchBar! //フェッチドリザルトコントローラー var frController:NSFetchedResultsController! //検証用データ let dataList = [["月刊コロコロコミック", "小学館",390,20.2,"2016/5/16 10:30:00"], ["コロコロイチバン!","小学館",540,25.3,"2016/4/23 09:00:00"], ["最強ジャンプ","集英社",420,13.2,"2016/6/9 7:00:00"], ["Vジャンプ","集英社",300,13.4,"2016/1/3 12:00:00"], ["週刊少年サンデー","小学館",280,16.7,"2016/8/23 11:00:00"], ["週刊少年マガジン","講談社",250,40.5,"2016/10/10 7:30:00"], ["週刊少年ジャンプ","集英社",300,60.3,"2016/9/9 10:00:00"], ["週刊少年チャンピオン","秋田書店",280,23.5,"2015/5/1 11:30:00"], ["月刊少年マガジン","講談社",320,45.1,"2016/7/2 13:30:00"], ["月刊少年チャンピオン","秋田書店",220,12.6,"2015/11/10 7:30:00"], ["月刊少年ガンガン","スクウェア",240,33.5,"2016/2/2 7:30:00"], ["月刊少年エース","KADOKAWA", 330,9.8,"2016/7/1 8:30:00"], ["月刊少年シリウス","講談社",350,20.2,"2016/11/26 15:00:00"], ["週刊ヤングジャンプ","集英社",300,33.3,"2014/3/16 8:30:00"], ["ビッグコミックスピリッツ","小学館",240,11.2,"2014/9/29 11:30:00"], ["週刊ヤングマガジン","講談社",310,26.7,"2016/8/8 10:00:00"]] //最初からあるメソッド override func viewDidLoad() { super.viewDidLoad() //デリゲート先を自分に設定する。 testSearchBar.delegate = self //何も入力されていなくてもReturnキーを押せるようにする。 testSearchBar.enablesReturnKeyAutomatically = false //管理オブジェクトコンテキストを取得する。 let applicationDelegate = UIApplication.sharedApplication().delegate as! AppDelegate let managedContext = applicationDelegate.managedObjectContext //コンフリクトが発生した場合はマージする。 managedContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy //フェッチリクエストを作成する。 let fetchRequest = NSFetchRequest(entityName: "Book") //並び順を指定する。 fetchRequest.sortDescriptors = [NSSortDescriptor(key:"name", ascending:false)] //フェッチドリザルトコントローラーを作成する。 frController = NSFetchedResultsController(fetchRequest:fetchRequest, managedObjectContext: managedContext, sectionNameKeyPath: nil, cacheName: nil) //フェッチドリザルトコントローラーのデリゲート先に自分を設定する。 frController.delegate = self //検証用データを格納する。 insertBook() } //本を保存するメソッド func insertBook(){ do { //Bookエンティティの件数を取得する。 try frController.performFetch() if(frController.fetchedObjects!.count == 0){ //何も保存されていない場合は検証用データを保存する。 for data in dataList { let book = NSEntityDescription.insertNewObjectForEntityForName("Book", inManagedObjectContext: frController.managedObjectContext) as! Book book.name = data[0] as? String //雑誌名 book.publisher = data[1] as? String //出版社 book.price = data[2] as? Int //価格 book.approvalRate = data[3] as? Float //支持率 let dateFormatter = NSDateFormatter() dateFormatter.dateFormat = "yyyy/M/d H:mm:ss" book.releaseDate = dateFormatter.dateFromString(data[4] as! String)! //発売日 } //管理オブジェクトコンテキストの中身を保存する。 try frController.managedObjectContext.save() } } catch { print(error) } } //データを返すメソッド func tableView(tableView:UITableView, cellForRowAtIndexPath indexPath:NSIndexPath) -> UITableViewCell { //セルを取得する。 let cell = tableView.dequeueReusableCellWithIdentifier("TestCell", forIndexPath:indexPath) as UITableViewCell //セルのラベルに本のタイトルを設定する。 let book = frController.objectAtIndexPath(indexPath) as! Book cell.textLabel?.text = "\(book.name!) \(book.publisher!) \(book.price!)円" return cell } //データの個数を返すメソッド func tableView(tableView:UITableView, numberOfRowsInSection section:Int) -> Int { if let section = frController.sections?[section] { return section.numberOfObjects } else { return 0 } } //検索ボタン押下時の呼び出しメソッド func searchBarSearchButtonClicked(searchBar: UISearchBar) { //キーボードをしまう。 testSearchBar.endEditing(true) if(testSearchBar.text != "") { //属性nameが検索文字列と一致するデータをフェッチ対象にする。 frController.fetchRequest.predicate = NSPredicate(format:"name CONTAINS %@", testSearchBar.text!) } else { frController.fetchRequest.predicate = nil } do { //フェッチリクエストを実行する。 try frController.performFetch() } catch { print(error) } //テーブルを再読み込みする。 testTableView.reloadData() } //編集可否を答えるメソッド func tableView(tableView: UITableView,canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool { //すべての行を編集可能にする。 return true } //テーブルビュー編集時の呼び出しメソッド func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == UITableViewCellEditingStyle.Delete { do { //選択行のオブジェクトを管理オブジェクトコンテキストから取得する。 let book = frController.objectAtIndexPath(indexPath) as! Book //管理オブジェクトコンテキストからオブジェクトを削除する。 frController.managedObjectContext.deleteObject(book) //管理オブジェクトコンテキストの中身を保存する。 try frController.managedObjectContext.save() } catch { print(error) } } } //コンテキスト変更時の呼び出しメソッド func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) { if type == .Delete { //選択行をテーブルビューから削除する。 testTableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade) } } } |
以下は実際のプレイ動画。フェッチドリザルトコントローラーを使った実装に変更しただけなので、動き自体は変更前と変わらない。
セクション分けを行う
フェッチドリザルトコントローラーを生成するときの引数は4つある。この中の「セクション分けの属性」にエンティティの属性名を指定すると、指定した属性の値が同じものでセクション分けをしてくれる。セクション分けのロジックを考える必要が無くなるので便利だ。
managedObjectContext: 管理オブジェクトコンテキスト,
sectionNameKeyPath: セクション分けの属性,
cacheName: キャッシュ名)
実際にセクションを分けをしてみよう。
ViewController.swfitのフェッチドリザルトコントローラーを生成する箇所を以下のコードに変更する。セクション分けの属性に「publisher(出版社)」を指定している。
1 2 3 |
//フェッチドリザルトコントローラーを作成する。 frController = NSFetchedResultsController(fetchRequest:fetchRequest, managedObjectContext: managedContext, sectionNameKeyPath: "publisher", cacheName: nil) |
ViewController.swiftに以下のメソッドを追加する。
フェッチドリザルトコントローラーのsectionsプロパティにセクション情報が設定されているので、このプロパティを利用してセクション数、セクション名を返している。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//セクション数を返すメソッド func numberOfSectionsInTableView(tableView: UITableView) -> Int { if frController.sections != nil { return frController.sections!.count } else { return 0 } } //セクション名を返すメソッド func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return frController.sections![section].name } |
「コンテキスト変更時の呼び出しメソッド」を以下のコードに変更する。
セクションからデータがすべて無くなる場合、セクションを残したままにすると以下のようなエラーが発生する。
CoreData: error: Serious application error. Exception was caught during Core Data change processing. This is usually a bug within an observer of NSManagedObjectContextObjectsDidChangeNotification. Invalid update: invalid number of sections.
The number of sections contained in the table view after the update (5) must be equal to the number of sections contained in the table view before the update (6), plus or minus the number of sections inserted or deleted (0 inserted, 0 deleted). with userInfo (null) Terminating app due to uncaught exception 'NSInternalInconsistencyException’, ..省略
そこで、テーブルビューのセクション数と更新後のコンテキストのセクション数が異なる場合、該当セクションをテーブルビューから削除するようにした。
また、行とセクションを同時に消すときのデータの不整合を発生させないために、beginUpdates/endUpdatesメソッドで削除処理を囲った。
ちなみに、beginUpdatesメソッドを呼んでからendUpdatesメソッドを呼ぶまでのあいだは、テーブルビューのセルを削除してもインデックスは更新されなくなる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
//コンテキスト変更時の呼び出しメソッド func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) { if type == .Delete { //テーブルビューの更新開始 testTableView.beginUpdates() //テーブルビューからレコードを削除する。 testTableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade) if (testTableView.numberOfSections != frController.sections!.count) { //セクションのデータが無くなる場合、テーブルビューからセクションを削除する。 testTableView.deleteSections(NSIndexSet(index: (indexPath?.section)!), withRowAnimation: .Fade) } //テーブルビューの更新終了 testTableView.endUpdates() } } |
以下は実際のプレイ動画