Edited at

Realmを使用したコメント機能付き記録アプリで見る実装ポイント(CoreDataと比較付き)

More than 3 years have passed since last update.


はじめに

私自身は公開しているアプリやサンプルに関してはCoreDataやRealmを活用する場合が多くあります。その中でも実際に処理を書いてみて「どこまで違うものなのか?」ということを自分なりにサンプルアプリを通じて制作してみることで見えてくるものがあるのではないか?と思い今回はCoreDataとRealmで全く同じ仕様のアプリを作成してみた備忘録として残しておきます。

私自身もCoreDataとRealmを比較して今回のサンプルを作成してRealmについて学習した個人的な雑感ですが、


  • CocoaPods経由のRealmSwiftのインストールがとても手軽である

  • PHPでのフレームワークで使用しているORMやRuby on RailsのActiveRecordのような感覚で使用することができる

  • ViewControllerで記載するデータの保存等に関する処理がシンプルで掴みやすくなった

と思いました。これからもRealmはしっかりと活用&深堀りしていきたいなと思いました。

加えて今回解説する部分に関しましては、私自身が本職がPHPやRubyのエンジニアで業務・プライベート共にMySQL等を平素でよく使用していることもありますので、各データに関する処理をMySQLのイメージに置き換えた場合の記載もしています。(KVSですのでそもそもRDBとは違いますがデータの振る舞いのイメージの参考として)

■ Github Sample Code:

RealmとCoreDataでのデータロジック比較の食べ物写真投稿サンプルアプリ

※1) 今回のサンプルアプリは保存先をRealmとCoreDataから選択できる仕様にはしていますが、Realmを使用したサンプルをご覧になりたい方は、下記のコマンドでプロジェクトでクローンした後にRealm実装のブランチ(develop/realm)をチェックアウトしていただきますよう宜しくお願い致します。

$ git clone git@github.com:fumiyasac/DatabasePersistencePattern.git

$ git checkout develop/realm ※任意

※2) こちらのサンプルに関してはまだ、変更と削除の処理に関しては現時点で実装されていません。一応前述の処理も近いうちに追加する予定です。


1. 大まかな仕様と画面の構成・解説の方針

このサンプルではRealmとCoreDataの処理の比較の解説もそうなのですが、機能の実装に関しても解説を行っていきます。登録処理も含めて下記のような仕様を想定して作成しました。


  • 保存するDBを選択する(前述のサンプルのmasterブランチ内参照)

  • 登録の新しい順(一意なID)ないしは評価の高い順での並べ替え

  • UISearchBarでのタイトルや本文での部分一致検索

  • 食べ物&おみやげ情報の追加(写真付き)とそれに紐づくコメントの追加機能(今回はテーブルのアソシエーションは未使用)

  • コメントを記載した際には評価の平均値を計算して食べ物&おみやげ情報の評価の部分に平均値を追加

と少し機能が多い形にしてみました。

Githubのmasterブランチ内のソースに関しては、DBの場合分けの処理やデータフェッチ後のデータを格納している変数の方が異なっているため、処理が煩雑になっていて恐縮ですが、Realmでの実装を最優先で見たい場合は「CoreDataではこんな風に実装するんだな」という感じで見て頂ければ幸いです。

■ 画面イメージ(アプリ全体のレイアウト表示):

一覧表示部分ではUISearchBarでの部分一致検索や各順番によるソート機能に関して、データ追加画面に関しては追加処理&評価平均値の算出処理に関して、


  • Realmでの処理の書き方

  • CoreDataで書く場合の処理の書き方

  • MySQLでのクエリイメージ

の3セットで解説を行っていきます。

■ Realmで想定しているDBスキーマ :

今回は「Omiyage:食べ物&おみやげのマスタデータ」のテーブルと「OmiyageComment:食べ物&おみやげに紐づくコメントのデータ」の2種類のテーブルを用意します。


  • Omiyageテーブル:食べ物&おみやげのマスタデータ

カラム名
データ型
説明

id
Int型
データの一意なID

title
String型
食べ物&おみやげのタイトル

detail
String型
食べ物&おみやげの詳細

average
Double型
コメントから算出した評価の平均値

createDate
NSDate型
登録日

imageData
NSData?型
食べ物&おみやげの画像データ


  • OmiyageCommentテーブル:食べ物&おみやげのマスタに紐づくコメントデータ

カラム名
データ型
説明

id
Int型
データの一意なID

omiyage_id
Int型
OmiyageテーブルのID

comment
String型
各々の食べ物&おみやげに関するコメント

star
Int型
コメントで付けられた評価

imageData
NSData?型
各々の食べ物&おみやげに関するコメントの画像データ


  • JFYI:CoreDateのxcdatamodelファイルでの設定

1.全体図

2.CDOmiyage (RealmのOmiyageテーブルに相当)

xcdatamodelでスキーマを定義した後に下記のエンティティファイルが生成されます。


  • CDOmiyage.swift

  • CDOmiyage+CoreDataProperties.swift

3.CDOmiyageComment (RealmのOmiyageCommentテーブルに相当)

xcdatamodelでスキーマを定義した後に下記のエンティティファイルが生成されます。


  • CDOmiyageComment.swift

  • CDOmiyageComment+CoreDataProperties.swift

下記のブログの記事はObjective-Cでのエンティティファイル作成に関する記事ですが、流れや操作手順はSwiftでも同様なので参考にしていただければと思います。

オペレーションの参考:[XCODE] CoreDataを用いたデータ管理を行う方法。準備編。

※今回は2つのDBに関するものを無理やり共存させているので各カラム名に「接頭語: cd_」を追加しています。

※「CDOmiyage:cd_id」は「RealmのOmiyageテーブルのidカラム」に、「CDOmiyageComment:cd_comment_id」は「RealmのOmiyageCommentのidカラム」に該当します。

サンプルの実装にあたってはアソシエーションを使用しても良いと思いましたが、一つのプロジェクト内にRealmとCoreDataが共存する形であったことや後述の理由等から使用していません。

また、Realmの導入や基本的な使い方やその他データ追加画面の実装に関しましては、

の記事内のリンク等を参考にして頂けましたら幸いです。

そしてCoreDataの導入の手順に関しても参考にした記事は下記になりますので、CoreDataをお使いの際には参考にしてみて下さい。

上記の記事はCoreDataを導入する際の手順やCRUD処理に関してとても詳細に記載されていますので本件でも参考にしました。


2. RealmとCoreDataを比較した際に感じた点

私自身はCoreDataから始めましたが、その後Realmを使ってみると気になったり驚いた点等は多々ありました。その中でも


★1. PrimaryKeyとAutoIncrementに関して

Realmではデータのモデルファイルの中に下記の記述を行うことでPrimaryKeyとAutoIncrementを設定することができます。


Omiyage.swift

class Omiyage: Object {

--- (省略) ---

//PrimaryKeyの設定
override static func primaryKey() -> String? {
return "id"
}

//PrimaryKeyのAutoIncrementした値の作成
static func getLastId() -> Int {
if let omiyage = realm.objects(Omiyage).last {
return omiyage.id + 1
} else {
return 1
}
}

--- (省略) ---

}


一方でCoreDataでは暗黙裏にSQLiteテーブルにPrimaryKeyを用意してくれるのですが、CoreDataではPrimaryKeyにアクセスをすることができません。データ設計がシンプルな場合においてはさほど致命的な問題にはならないと思いますが、高度な設計が必要になると辛い局面が出てきます。

これを解決するは「ダミーの一意なID」を用意する手法がよく取られます。

私が知る限りでは、以下の方法がありました。


  • UUIDを発行する

  • ダミーの一意なIDのカラムでの最大値を取得して+1をする

本サンプルでは後者を使用しています。具体的には下記のようなメソッドを作成して一意なIDを作成します。


AddController.swift

//(masterブランチ参照)

--- (省略) ---

//データの最大値からダミーのIDを取得する
private func getNextOmiyageId() -> Int64 {

//NSManagedObjectContext取得
let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let managedObjectContext: NSManagedObjectContext = appDel.managedObjectContext

//フェッチリクエストと条件の設定
let fetchRequest = NSFetchRequest(entityName: "CDOmiyage")

//検索条件を設定する
let keyPathExpression = NSExpression(forKeyPath: "cd_id")
let maxExpression = NSExpression(forFunction: "max:", arguments: [keyPathExpression])
let description = NSExpressionDescription()
description.name = "maxId"
description.expression = maxExpression
description.expressionResultType = .Integer32AttributeType

//フェッチ結果を出力する
fetchRequest.propertiesToFetch = [description]
fetchRequest.resultType = .DictionaryResultType

//フェッチ結果をreturn
if let results = try? managedObjectContext.executeFetchRequest(fetchRequest) {
if results.count > 0 {
let maxId = results[0]["maxId"] as! Int
return maxId + 1
}
}
return 1
}


また上記のCoreDataでの処理についてはMySQLのクエリイメージ的には下記のようなことをしています。

(SQL的にはシンプルな構文なのですが...)

SELECT MAX(cd_id) FROM CDOmiyage;

こちらの実装ロジックの書き方に関しては、

を参考にしました。


★2. 画像データの保存に関して

画像データの保存に関しては、RealmでもCoreDataでもUIImage型をNSData型へ変換をかけた上でデータの保存を行います。(CoreDataの場合は該当カラムの型はBinaryData型に設定しておきます)

逆にUIへ表示する場合はこの逆の変換が必要になります。


★JFYI. RealmとCoreDataの比較に関する参考資料

RealmとCoreDataに関しての詳細な相違点や見解に関しては下記の記事が特にわかりやすかったです。

どちらがより良いと結論付けることはできないと思いますが、私自身もどちらに関してもまだまだ深堀りして突き詰めていきたいと感じる所存です。


3. 今回のサンプルを元にRealmとCoreDataの処理の書き方に関して

※食べ物&おみやげデータのに紐づくコメント追加と一覧表記に関して

食べ物&おみやげデータの追加に関してはこちらの追加処理とほぼ同様なので本サンプルのmasterブランチ内:AddController.swiftファイルの該当部分を参照してください。


★Point1: まずはコメントデータの追加

Realmでの処理:

こちらはOmiyageComment.swift(データのモデルファイルに該当する部分)の使用しているメソッドになります。

追加用のインスタンスを作成して、各変数に値を入れた後にsave()を実行するという流れになります。


OmiyageComment.swift

//(masterブランチ参照)

--- (省略) ---

//新規追加用のインスタンス生成メソッド
static func create() -> OmiyageComment {
let omiyageComment = OmiyageComment()
omiyageComment.id = self.getLastId()
return omiyageComment
}

//プライマリキーの作成メソッド
static func getLastId() -> Int {
if let omiyageComment = realm.objects(OmiyageComment).last {
return omiyageComment.id + 1
} else {
return 1
}
}

//インスタンス保存用メソッド
func save() {
try! OmiyageComment.realm.write {
OmiyageComment.realm.add(self)
}
}


下記が実際にController内で使用している箇所の記述になります。


AddCommentController.swift

//(masterブランチ参照)

--- (省略) ---

//Realmにデータを1件登録する
let omiyageCommentObject = OmiyageComment.create()
omiyageCommentObject.comment = self.omiyageCommentDetail
omiyageCommentObject.star = self.omiyageCommentStar
omiyageCommentObject.image = self.omiyageCommentImage
omiyageCommentObject.omiyage_id = self.detailId

//登録処理
omiyageCommentObject.save()


CoreDataでの処理:

ダミーのプライマリキーを作成するメソッドを作成し、データの登録時にこちらを読み込ませるようにします。


AddCommentController.swift

//(masterブランチ参照)

--- (省略) ---

//NSManagedObjectContext取得
let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let managedObjectContext: NSManagedObjectContext = appDel.managedObjectContext

//新規追加
let newMemoEntity = NSEntityDescription.insertNewObjectForEntityForName("CDOmiyageComment", inManagedObjectContext: managedObjectContext)

let imageData: NSData = UIImagePNGRepresentation(self.omiyageCommentImage)!

newMemoEntity.setValue(Int(self.getNextOmiyageCommentId()), forKey:"cd_comment_id")
newMemoEntity.setValue(self.detailId, forKey:"cd_id")
newMemoEntity.setValue(self.omiyageCommentDetail, forKey:"cd_comment_comment")
newMemoEntity.setValue(self.omiyageCommentStar, forKey:"cd_comment_star")
newMemoEntity.setValue(imageData, forKey:"cd_comment_imageData")

//登録処理
do {
try managedObjectContext.save()
} catch _ as NSError {
abort()
}

//データの最大値からダミーのIDを取得する
private func getNextOmiyageCommentId() -> Int64 {

//NSManagedObjectContext取得
let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let managedObjectContext: NSManagedObjectContext = appDel.managedObjectContext

//フェッチリクエストと条件の設定
let fetchRequest = NSFetchRequest(entityName: "CDOmiyageComment")

//検索条件を設定する
let keyPathExpression = NSExpression(forKeyPath: "cd_comment_id")
let maxExpression = NSExpression(forFunction: "max:", arguments: [keyPathExpression])
let description = NSExpressionDescription()
description.name = "maxId"
description.expression = maxExpression
description.expressionResultType = .Integer32AttributeType

//フェッチ結果を出力する
fetchRequest.propertiesToFetch = [description]
fetchRequest.resultType = .DictionaryResultType

//フェッチ結果をreturn
if let results = try? managedObjectContext.executeFetchRequest(fetchRequest) {
if results.count > 0 {
let maxId = results[0]["maxId"] as! Int
return maxId + 1
}
}
return 1
}


SQLでのイメージ(Realmの場合)

INSERT INTO omiyage_comment (id, omiyage_id, comment, star, image) VALUES (1, 1, 'コメントが入ります', 3 , 'xxxxxxx[binarydata]');


★Point2: コメントにつけられた評価の平均値を算出する

RealmもCoreDataも下記のような集計関数を使用することができます。


  • 最大値

  • 最小値

  • 平均

  • 合計

今回の処理では、追加されたコメントの評価から平均値を算出して、食べ物&おみやげデータのaverageカラム内の値を更新する処理をしています。

(平均値を取得する部分に関してのみ抜粋しています)

Realmでの処理:

omiyage_idをキーにして平均値を算出しています。


OmiyageComment.swift

//(masterブランチ参照)

--- (省略) ---

//平均値を取得
static func getAverage(target_id: Int) -> Double {

if let average: Double = realm.objects(OmiyageComment).filter("omiyage_id = %@", target_id).average("star") {
return average
} else {
return 0.0
}
}


CoreDataでの処理:

こちらも平均値を算出する場合は、処理の記述は違えどRealmの場合と基本的な考え方は同様です。


AddCommentController.swift

//(masterブランチ参照)

--- (省略) ---

//平均値を取得する
private func getAverage(target_id: Int) -> Double {

//NSManagedObjectContext取得
let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let managedObjectContext: NSManagedObjectContext = appDel.managedObjectContext

//フェッチリクエストと条件の設定
let fetchRequest = NSFetchRequest(entityName: "CDOmiyageComment")

//検索条件を設定する
fetchRequest.predicate = NSPredicate(format:"cd_id = \(target_id)")
let keyPathExpression = NSExpression(forKeyPath: "cd_comment_star")
let maxExpression = NSExpression(forFunction: "average:", arguments: [keyPathExpression])
let description = NSExpressionDescription()
description.name = "avgStar"
description.expression = maxExpression
description.expressionResultType = .DoubleAttributeType

//フェッチ結果を出力する
fetchRequest.propertiesToFetch = [description]
fetchRequest.resultType = .DictionaryResultType

//フェッチ結果をreturn
if let results = try? managedObjectContext.executeFetchRequest(fetchRequest) {
if results.count > 0 {
let avgStar = results[0]["avgStar"] as! Double
return avgStar
}
}
return 0.0
}


SQLでのイメージ(Realmの場合)

SELECT AVG(star) FROM omiyage_comment WHERE omiyage_id = 1;

平均値が算出できたら、その値をOmiyage情報の中に入れて更新してあげればOKです。


★Point3: 食べ物&おみやげデータの一覧表示(ID or 評価順でソート & 部分一致検索)

Realmでの処理:

評価順もしくはIDでの並べ替え及びUISearchBarで入力した値に関してのフィルタリングを行っています。

Realmでソートを行う場合は、sorted()メソッドの第1引数に「ソート対象のカラム」を、第2引数に「昇順・降順」の指定を使用します。

また、一致の条件を付ける際はfilter()メソッドを使用します。今回の想定としましては「タイトルまたは詳細に語句が含まれる」という条件を付与します。


Omiyage.swift

//(masterブランチ参照)

--- (省略) ---

//ソートをかけた順のデータの全件取得をする
static func fetchAllOmiyageList(sortOrder: String, containsParameter: String) -> [Omiyage] {

var omiyages: Results<Omiyage>

if containsParameter.isEmpty {
omiyages = realm.objects(Omiyage).sorted("\(sortOrder)", ascending: false)
} else {
let predicate = NSPredicate(format: "title CONTAINS %@ OR detail CONTAINS %@", containsParameter, containsParameter)
omiyages = realm.objects(Omiyage).sorted("\(sortOrder)", ascending: false).filter(predicate)
}

var omiyageList: [Omiyage] = []
for omiyage in omiyages {
omiyageList.append(omiyage)
}
return omiyageList
}


下記が実際にController内で使用している箇所の記述になります。


ViewController.swift

//(masterブランチ参照)

--- (省略) ---

func fetchObjectFromRealm() {

self.searchResultRealm.removeAllObjects()

if self.sortDefinitionValue == SortDefinition.SortId.rawValue {
self.sortOrder = "id"
} else {
self.sortOrder = "average"
}

let omiyages = Omiyage.fetchAllOmiyageList("\(self.sortOrder)", containsParameter: self.containsParameter)

self.cellCount = omiyages.count

if self.cellCount != 0 {
for omiyage in omiyages {
self.searchResultRealm.addObject(omiyage)
}
}
//Debug.
//print(self.searchResultRealm)

self.memoDataSegment.selectedSegmentIndex = DbDefinition.RealmUse.rawValue

self.reloadData()
}


CoreDataでの処理:

少し記述は煩雑かもしれませんがやっていることは上記のRealmの場合と同様なことを行っています。


ViewController.swift

//(masterブランチ参照)

--- (省略) ---

func fetchObjectFromCoreData() {

var error: NSError?

let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let managedObjectContext: NSManagedObjectContext = appDel.managedObjectContext

//フェッチリクエストと条件の設定
let fetchRequest = NSFetchRequest(entityName: "CDOmiyage")
fetchRequest.returnsObjectsAsFaults = false

if !self.containsParameter.isEmpty {

fetchRequest.predicate = NSPredicate(format: "cd_title contains %@ OR cd_detail contains %@", self.containsParameter, self.containsParameter)
}

if self.sortDefinitionValue == SortDefinition.SortId.rawValue {
self.sortOrder = "cd_id"
} else {
self.sortOrder = "cd_average"
}

let sortDescriptor = NSSortDescriptor(key: self.sortOrder, ascending: false)
fetchRequest.sortDescriptors = [sortDescriptor]

//フェッチ結果
let fetchResults: [AnyObject]?
do {
fetchResults = try managedObjectContext.executeFetchRequest(fetchRequest)
} catch let error1 as NSError {
error = error1
fetchResults = nil
}

//データの取得処理成功時
if let results: AnyObject = fetchResults {

self.cellCount = results.count

if self.cellCount != 0 {
self.searchResultCoreData = results
}
//Debug.
//print(self.searchResultCoreData)

//失敗時
} else {
print("Could not fetch \(error) , \(error!.userInfo)")
}

self.memoDataSegment.selectedSegmentIndex = DbDefinition.CoreDataUse.rawValue
self.reloadData()
}


SQLでのイメージ(Realmの場合)

SELECT * FROM omiyage WHERE (title LIKE '%牛丼%' OR detail LIKE '%牛丼%') ORDER BY average DESC;

ソートや部分一致等の処理に関しては今後とも、いろいろ調べて追記していければと思います。


4. 検索部分のUISearchBarでのデータの振る舞いとキーボードを引っ込める処理に関して

検索部分エリアに関しましては、UISearchBarを使用しています。

この部分の動きとしては、


  • UISearchBarをタップするとキーボードが現れる

  • 検索したい語句を入れると、食べ物&おみやげデータの「タイトル」もしくは「詳細」をみて「検索したい語句」が含まれているものだけを表示する

  • テキストフィールド内のバツボタンを押すと語句が消える(デフォルトの挙動)

  • キャンセルボタンを押すとキーボードが引っ込む(検索結果はそのまま)

となっています。

UISearchBarとキーボードを隠すタイミングですが、今回はテーブルビューを使用しているため、キーボードを隠す処理をおおもとのViewにつけたTapGestureRecognizerを利用してしまうと、テーブルビューのタップよりもこちらが優先されてしまうため今回はキャンセルボタンを押したタイミングでキーボードを隠すようにしました。


AddCommentController.swift

//(masterブランチ参照)

--- (省略) ---

//検索バーのデリゲート設定
self.memoDataSearchBar.delegate = self
self.memoDataSearchBar.showsCancelButton = true

//SearchBarに関する設定一覧
func searchBarCancelButtonClicked(searchBar: UISearchBar) {
self.view.endEditing(true)
}


GestureRecognizerをおおもとのViewにかける際にはちょっと注意が必要です。


あとがき

改めてRealmを使用してみて思ったのは、実際に今まで少し煩雑に書いてしまいがちであったデータの永続化の処理に関しては本当にシンプルかつ柔軟な書き方ができるので、とても重宝していますし、またORMに似た記載やメソッドチェーン等も使うことができるので今までよりももっとiOSのDBを使う処理が身近なものに感じることができました。

また、公式ドキュメントやサポートもかなり充実していますし勉強会も随時開催されていますので、とても心強いなと感じたのも私が感じている魅力の一つでもあります。

■公式ドキュメント:

何と言っても公式ドキュメントでインストールの方法から使い方までが盛りだくさんに掲載されています。(SlackでRealmの中の方ともやり取りができるサポートもあり、私も本当に助かっています)

■勉強会:

下記のように月に1回程度の頻度でmeetupも開催されています。Realmの中の人に直接お会いして質問したり、これまでの発表者の資料もありますのでブックマークしておくとよいかと思います。(私も時間がある際は参加しています)

■CoreDataに関しての参考:

CoreDataも歴史のある技術ではありますが、公式のドキュメントが意外と読みにくい部分があってなかなか取っ付きにくい印象があったのですが、下記の書籍が一番参考になりました。

上記の書籍に関しては、Objective-Cで記載されているのですがCoreDataの処理の概要を押さえるにはとっても参考になりました。

今後はもっと複雑なDB設計が必要になるようなアプリの開発やRealmをはじめとするデータ永続化に関する処理部分をしっかりとマスターして深堀っていきたいと感じる所存ですし、今後ともこのようなサンプルアプリのショーケース開発を用いた解説をQiitaへアウトプットしていければと感じる次第です。


追記とその他

2016.01.11:


  • 今回の記事のポイントとなる部分を勉強会にて発表する機会がありましたので、その際に使用したスライドもここに共有致します。下記資料も皆様のご理解の参考となれば幸いです。


  • 参考スライド:CoreDataと比較してrealmを使ったまとめ


  • 編集リクエスト頂きましたので修正を反映致しました。本当にありがとうございました!


2016.01.07:


  • 表記にTypoしていた部分がございましたので修正を加えました。ご指摘頂き本当にありがとうございました。表記の曖昧さや分かりにくい部分などございましたら編集リクエスト頂ければ幸いに思います。

このサンプルについて


  • GithubへのPull Requestならびに要望や改善に関する提案も受け付けていますのでお気軽にどうぞ!(Xcodeのエディタがおかしくなる等の些細なことでも全然構いません)