こんにちは。755でiOSアプリ開発をしている林です。
現在755では新機能の開発を行いながら、既存のObjective-CのSwift化を行なっています。今回はその中からCoreDataからRealmへのデータベース移行を行なったため、その過程等を紹介したいと思います。
リリースされてからサービス継続期間が長いiOSプロジェクトでは既存のObjective-Cから移行するのはなかなか大変な面もあると思います。
755ではCoreData周りの既存実装が、新機能を開発して行くにあたって足枷になっていたり、クラッシュを起こす場合もあったりと、メンテがしづらい状態になっていたため、今回Realmへの移行とモデルのSwift化を検討することになりました。
Realmについて
すでに多くの記事で紹介されていると思いますので、こちらでは簡単に紹介させていただきます。
Realmはオープンソースで開発されているモバイルアプリ向けのデータベースです。
公式サイトでは以下のように紹介されています。
モバイルデバイスで快適に動作するようにゼロから開発されました。 従来のデータベースとは異なり、Realmのオブジェクトは ネイティブオブジェクト です。オブジェクトを変更するためにデータベースからコピーしたり、また元に戻すために変換する必要はありません。ネイティブのオブジェクトをそのまま扱えます。そしてオブジェクトは常に「自動更新」されます。別のスレッドやプロセスがオブジェクトを変更すると、他のスレッドやプロセスに即座に通知できます。オブジェクトは常に同期しています。
また下記のような設計になっているためSQLiteやCoreDataと比較した場合にに高速である点やAndroid/iOSの両方をサポートしている点などが特徴として挙げられます。
- MVCC + B+Tree (gitのようなデータ管理方法)
- zero-copy
- lazy-loading (column優先で保存される)
参考:Designing a Database: Realm Threading Deep Dive
設計の方針
普段Realmを使っていなくても特別なことを考えずに処理をできるよな設計にすることを前提に、Swift側とObjective-C側のRealmは共存を考慮することによる複雑化を避けるため、出来るだけrealmに関する処理はSwiftで書かれて、Objective-C側はSwift側のメソッドを呼ぶような設計にしていきました。
また既存の実装への負荷とエラーの知見を得ることを考慮し、すべてのモデルを移行するのではなく部分的にRealmを導入しダブルライトをして段階的な移行を行なっていきました。
各モデルに対してStoreクラスを作成しその中で、json / id等をやり取りすることで書き込み・読み込みができる、Notificationの扱いを簡単に行うような設計にしています。以下はあるModelクラスに対応したModelStoreのサンプルになります。
@objc class ModelStore {
var token: NotificationToken?
class func getInsertedModels(with jsonList: [[String : Any]], completion: @escaping ([String : Model]) -> Void) {
//書き込み用スレッドを作成し
//Realmの読み込み書き込み処理 また 保存したObjectの取り出しも行う
}
class func getModels(Ids: [String]) -> [String : Model] {
//必要なクエリを作成し
//複数のRealmオブジェクトを取り出す
}
class func getModel(Id: String) -> Model? {
//単体のRealmオブジェクトを取り出す
}
//Mark: - Notification
func initModelToken(model: Model, completion: @escaping RealmObjectNotiCompletion) {
initNotificationToken(object: model) { responce in
completion(responce)
}
}
func releasePostToken() {
releaseNotificationToken()
}
}
既存のCoreDataでは、モデルの更新が行われたタイミングで、KVOを利用して各画面の更新を行なっていたため、各モデルに対して用意したStoreクラス内にRealmのNotificationについての実装を行うような作りにしています。(Notificationについて後述)
注意点・対策等
- Realmファイルが壊れるとクラッシュバグが起きる
その場合Realmファイルを消して起動させることになる。 - マイグレーション
テーブル・カラム追加などであればmigrationは必要ありませんが、スキーマ変更、カラム変更にはmigrationが必要になる。 - マルチスレッド環境で扱うとクラッシュしたりRealmファイルが肥大化したりする
- Realm自体のobjectとRealmModelのobjectは複数スレッドでシェアできない
複数スレッドでRealmのやり取りを行うとその度にコピーがされるためRealmファイルが肥大化しアプリのデータ容量の肥大化に繋がる。そのために書き込み専用スレッドをつくる、または少ないスレッドでアクセスするなどの工夫が必要になる。 - 更新頻度が高いのでデグレを留意する必要がある
- resultオブジェクトの扱いが難しい
tableViewなどnumberOfRowではresult10個取れていても、もしブロックだったりトーク削除などで表示個数に変化があった場合、cellForRawAtでクラッシュする可能性がある。ただしRealmのresultオブジェクトで受け渡しを行う方が処理が軽くなる。 - primary key
一つのプロパティでprimary keyは作れないモデルの場合は、アプリ専用のプロパティid作成を行う。
などなど他にも多くの注意点があるかと思いますが、それらを踏まえた実装を行なっていきました。
コンパクション
Realmファイルの肥大化を防ぐため、アプリ起動とユーザーアカウントの切り替え時にコンパクションを行います。
Realmのアーキテクチャではファイルサイズは常に大きくなり、減ることはありません。マルチスレッドのセクションにも書いてあるように、そうすることによって優れたパフォーマンスと並列実行時の安全性を獲得しています。 さらに、ファイルサイズを拡張させるのはコストの高いシステムコールを使用するので、それが頻繁に呼ばれることを避けるためにRealmは実行時にファイルサイズを縮小することはしません。その代わり、空き領域は自動的に再利用されます。
let config = Realm.Configuration(shouldCompactOnLaunch: { totalBytes, usedBytes in
let oneHundredMB = 100 * 1024 * 1024
return (totalBytes > oneHundredMB) && (Double(usedBytes) / Double(totalBytes)) < 0.5
})
do {
let realm = try Realm(configuration: config)
} catch {
//handle error compacting or opening Realm
}
Notification
CoreData使用時に実装していたKVOの代わりに、addNotificationBlockメソッドでブロックを登録することでモデルの更新通知を行います。
しかしObjective-C側はSwift側のメソッドを呼ぶような設計にするために、Notification周りは下記のように@objc で定義したRealmObjectNotiResponseクラスでaddNotificationBlockのレスポンスから得られるプロパティをObjective-Cで扱えるようにするため保持するプロトコルを用意しました。これにより各モデルのStoreはモデルの方を意識することなくNotificationを利用することができます。
@objc class RealmObjectNotiResponse: NSObject {
let name: String
let oldValue: Any?
let newValue: Any?
init(propertyChange: RealmSwift.PropertyChange) {
self.name = propertyChange.name
self.oldValue = propertyChange.oldValue
self.newValue = propertyChange.newValue
}
}
typealias RealmObjectNotiCompletion = (([RealmObjectNotiResponse]) -> Void)
protocol RealmStoreUsable: class {
var token: NotificationToken? { get set }
func initNotificationToken<T: Object>(object: T, completion: @escaping RealmObjectNotiCompletion)
func releaseNotificationToken()
}
extension RealmStoreUsable {
func initNotificationToken<T: Object>(object: T, completion: @escaping RealmObjectNotiCompletion) {
token = object.addNotificationBlock { change in
switch change {
case .change(let properties):
let responce = properties.flatMap{ RealmObjectNotiResponse(propertyChange: $0) }
completion(responce)
case .deleted:
break
case .error(let error):
fatalError("\(error)")
break
}
}
}
func releaseNotificationToken() {
token?.stop()
}
}
最後に
今回はSwift化を進めていく観点から、複数の選択肢を考慮して、データモデルからSwift化をしていくことを踏まえてCoreDataのRealm化を行いました。
Objective-CでRealmを扱えるような実装を考慮する点や、これまでCoreDataで実装されていた部分(KVO等)をRealmに置き換えた際のパフォーマンス・電池消費などを考慮する点などCoreData・Realmそれぞれ特徴がありとっつきにくい点もありましたが、パフォーマンスも落ちることなくチームメンバーがデーターベースの処理・メンテをしやすくなりなったと感じています。今後もチームとして扱いやすい物・技術的挑戦できるものは積極的に取り入れていけたらと思います。
公開されてから時間が経っていますが、こちらも参考にできるかと思います。
Migrating an App from Core Data to Realm