Databaseのことを考えずにiOSアプリを作る。ローカルデータベースのアーキテクチャ設計 - Realm, CoreData
登壇時に使用したスライドはこちら!
はじめに
RealmやCoreData, SwiftDataなどをローカルのデータベースとして使う際に苦労したことありませんか?どんな要素が開発者を苦労させるのか、まずはRealmやCoreDataなどのローカルデータベースを使う上で発生する問題を考えます。
データベースを使うとなぜ開発が難しくなるのか
データベースを使うと開発が難しくなる理由は、データベース用のオブジェクトの扱いにはいろんなルールがついてくるからです。適切な使い方をしなければランタイムのクラッシュの発生に繋がったり、想定通りの挙動をせず不具合の発生に繋がります。
クラッシュの発生するパターンを軽くリストアップしてみました。(一部GPTから引用)
Realmの場合
- threadを跨いでオブジェクトにアクセスするとクラッシュ
- realm.write {} ブロックの外でrealmオブジェクトを編集するとクラッシュ
- モデル定義にないプロパティへアクセスしようとするとクラッシュ
- 削除済みのRealmオブジェクトにアクセスしようとするとクラッシュ
- Realmインスタンスを適切にクローズしないとリソースのリークや不正な状態の参照でクラッシュ
CoreDataの場合
- Contextを異なるThread間で共有するとクラッシュ
- フェッチリクエストが完了する前に結果セットにアクセスしようとするとクラッシュ
- モデル定義にないエンティティにアクセスしようとするとクラッシュ
- 削除されたか、コンテキストに属していないマネージドオブジェクトを操作するとクラッシュ
- モデルの属性に対して予期しない型のデータを設定しようとするとクラッシュ
- コンパイルされたモデルとコード内のモデルが一致しない場合クラッシュ
ルールの脅威
これらのルールによって発生するの危険なことの一つは、ルール違反するような実装がされていても開発中にビルドは正常に動く上、コードも一見全く問題ないように見えるものが多いことです。それを検出するには開発中やコードレビューの中で見つけ出すか、もしくは開発者やユーザーがクラッシュに遭遇して初めて見つけることができます。
コードは一見問題ないように見えるため問題を見つけるのが難しい。誰かがクラッシュするまで問題が見つからない可能性がある。これはアプリの品質において絶大な脅威になります。
開発工数的にも強い脅威です。クラッシュを再現できるようになってもXcode上でどのファイルの何行目で起きたかエラー文に記載されていない場合もあるので、再現してから直すまでにもそれなりの時間を使うことになります。
これらのルールが品質においても開発生産性においても大きな脅威になり得ます。
普通の変数のようにデータを扱いたい!
データベース用のオブジェクトをルールに縛られず普通の変数のように扱ってスムーズに安全に開発を進めたい!そんな願いを叶える設計をRealmでの実例を見ながら説明していきます。
Solution
実際のケースではもっと複雑になりますが、例として簡潔なRealmオブジェクトを用意しました。RealmCardというclassにid, text, imageDataという3つのpropertyがあり、アプリ内にカードは複数存在してそれをRealmに保存している状況を考えます。
import Foundation
import RealmSwift
class RealmCard: Object {
@objc dynamic var id = UUID().uuidString
@objc dynamic var text: String = ""
var imageData = List<Data>()
override static func primaryKey() -> String? {
return "id"
}
}
このRealmCardクラスを少しでも扱う全てのclassにおいて、上記のようなルールを意識しながら開発を進める必要があり、ルールを違反すればクラッシュです。
Non-Realm Classの作成
そこで、Realmには全く依存しないが構造自体はRealmCardと同じClass "Card"を作成します。
import Foundation
class Card: Identifiable {
var id = UUID().uuidString
var text: String = ""
var imageData = [Data]()
}
そして、アプリ内では基本このCardクラスのみを扱います。このクラスを扱う間はRealmの全てのルールから脱却した状態になり、普通の変数を扱う時と全く変わらない自由度になります。
RealmとNon-Realmの変換
もちろんCardクラスはRealm Databaseに保存することができないのでこのクラスを使うだけではデータの保持はできません。
どうやってアプリ全体でRealmから脱却したCard Classを使いながらRealmにデータを保持するか。必要なことは、
- CardからRealmCard, RealmCardからCardに変換できる状態にする
- アプリ起動時にRealmCardデータをデータベースからfetchしてCardに変換する
- Cardが編集された時にRealmCardを自動で更新・保存する
この3つです。これをすることで、Realmに全く依存しないCardを使い続けているのにRealmに情報は常に保持され、アプリ終了後再びアプリを立ち上げても全てのCardが残っている状態になります。
以下が完成状態のコード例です。
RealmCard.swift
import Foundation
import RealmSwift
class RealmCard: Object {
@objc dynamic var id = UUID().uuidString
@objc dynamic var text: String = ""
var imageData = List<Data>()
override static func primaryKey() -> String? {
return "id"
}
func convertToCard() -> Card {
let card = Card()
card.id = self.id
card.text = self.text
card.imageData = Array(self.imageData)
return card
}
}
Cardクラスに変換するためのconvertToCard functionが追加されました。
Card.swift
import Foundation
protocol CardUpdateDelegate: AnyObject {
func cardDidUpdate(_ card: Card)
}
class Card: Identifiable {
var id = UUID().uuidString {
didSet { updateDelegate?.cardDidUpdate(self) }
}
var text: String = "" {
didSet { updateDelegate?.cardDidUpdate(self) }
}
var imageData = [Data]() {
didSet { updateDelegate?.cardDidUpdate(self) }
}
weak var updateDelegate: CardUpdateDelegate?
func convertToRealmCard() -> RealmCard {
let realmCard = RealmCard()
realmCard.id = self.id
realmCard.text = self.text
realmCard.imageData.append(objectsIn: self.imageData)
return realmCard
}
}
こちらはRealmCardクラスに変換するための convertToRealmCard functionに加えて、CardUpdateDelegateも追加されています。このDelegateの目的は、Cardクラスのpropertyが変更されたタイミングを知らせることです。
RealmService.swift
RealmServiceはRealmとの情報のやり取りを行うためのクラスです。全てのRealmに関わるoperationはこのクラスで行われ、アプリの他のクラスではRealmに一切依存せず、Realmに関わる記述を一切行いません。(RealmCard, CardなどのModelファイルを除く)
import Foundation
import RealmSwift
protocol RealmServiceProtocol {
var cards: [Card] { get set }
func fetchCards(completion: @escaping () -> Void)
}
class RealmService: RealmServiceProtocol {
// Realm情報の更新は同じThreadからやらなければクラッシュすることがあるので、Realm用ThreadとしてrealmQueueを用意
private var realmQueue = DispatchQueue(label: "wordwise.realmQueue")
var cards: [Card] = [] {
didSet {
cards.forEach { $0.updateDelegate = self }
synchronizeWithRealm()
}
}
// RealmからRealmCardをfetchしてCardに変換
func fetchCards(completion: @escaping () -> Void) {
realmQueue.async { [weak self] in
guard let self else { return }
do {
let realm = try Realm()
let fetchedRealmCards = realm.objects(RealmCard.self)
let cards = Array(fetchedRealmCards.map { $0.convertToCard() })
DispatchQueue.main.async {
self.cards = cards
}
} catch {
print("Error fetchingCards: \(error.localizedDescription)")
}
completion()
}
}
// データの追加、更新、削除を行いCardのデータとRealmCardのデータを揃える
private func synchronizeWithRealm() {
realmQueue.async { [weak self] in
guard let self else { return }
do {
let realm = try Realm()
try realm.write {
let existingIds = Set(realm.objects(RealmCard.self).map { $0.id })
let newIds = Set(self.cards.map { $0.id })
let deletedIds = existingIds.subtracting(newIds)
let deletedObjects = realm.objects(RealmCard.self).filter("id IN %@", Array(deletedIds))
realm.delete(deletedObjects)
for card in self.cards {
let realmCard = card.convertToRealmCard()
realm.add(realmCard, update: .modified)
}
}
} catch {
print("Error synchronizeWithRealm: \(error.localizedDescription)")
}
}
}
}
extension RealmService: CardUpdateDelegate {
func cardDidUpdate(_ card: Card) {
synchronizeWithRealm()
}
}
class MockRealmService: RealmServiceProtocol {
var cards: [Card] = []
func fetchCards(completion: @escaping () -> Void) {
completion()
}
}
このクラスによってRealm情報の取得と保存、そしてCard配列の管理を行います。アプリ内ではRealmServiceの中のCard配列を使うことで、Realmに依存しない状態でアプリを動かすことができます。
fetchCards
アプリ起動後にデータベースに保存されたRealmCardを全て取得し、Card配列に変換します。アプリ内の他のクラスではRealmCardではなく変換されたCard配列のみを利用して開発を進めます。
synchronizeWithRealm
Card配列に対して何か変更を行った時、その変更をRealmデータベースに保存されたRealmCardにも適用するためのfunctionです。cards arrayのdidSetと、Cardクラスのpropertyが変更されたときに呼ばれるcardDidUpdate functionから呼ばれます。つまり、少しでもCard配列に変更が加わった時は毎回呼ばれる状態です。
RealmServiceProtocol・MockRealmService
RealmServiceをモックアップするためのProtocol。他クラスからRealmServiceを使いたい時はRealmServiceProtocolを参照し、SwiftUIのPreviewやUnit TestなどではMockRealmServiceをに切り替えることでRealmから依存しない高速なPreview・UnitTestを実現できます。
このアーキテクチャによるアプリ全体の変化
まず普通のアーキテクチャの場合、Realm Cardを扱う全てのファイル(View, ViewModel, Testファイルなど)がRealmに依存し、Realmオブジェクトのルールに従う義務を負います。
今回紹介したアーキテクチャを利用した場合、Realm CardはRealm ServiceとRealm Databaseの間のやり取りの時のみ使われ、ルールを考える必要があるのはRealm Serviceのみになります。Realm Service以外の全てのファイルではRealmに全く依存しない、RealmServiceによって管理されたCard配列を使うことになるので、Realmのことを一切考えずに開発できます。
このアーキテクチャのメリット
1. 保守性・開発効率の向上!
全てのデータベースのルールから脱却できるので、データベースのことを考えずに開発ができます。
2. 最高レベルのUIパフォーマンスを達成できる!
データベースの更新処理は常にバックグラウンドスレッドで行われるためデータベースのパフォーマンスにアプリのUIは影響を受けません。データ量が膨大になるとローカルデータベースでもユーザーが感じられるほど重くなることがあるので、その差で生まれるアプリ品質の向上は大きな恩恵です。
3. テストも書きやすくなる+動作も高速!
テストの実行がデータベースに依存しないので、testabilityが向上します。ユニットテストのパラレルでの実行も容易になります。
4. コード全体がデータベースに依存しない。
仮にあなたがRealmからSwiftDataにデータベースを移行したいとなった場合、あなたはどれぐらいの範囲のコードを修正する必要があるでしょうか。もしアプリ全体でデータベースのオブジェクトを直で扱っている場合は、そのオブジェクトを少しでも触っているファイルは書き直すことになるかもしれません。しかし、もし今回紹介したアーキテクチャを取り入れていれば、データベースを変更したくなっても、その時修正する必要があるファイルは上記の例ではModelファイル(RealmCard, Card)とRealmServiceだけです。
このアーキテクチャのデメリット
1. バックグラウンドスレッドではあるがRealm情報の保存が少しでもデータを変更するたびに保存処理が呼ばれるので不要に高頻度で多数の保存処理・クラス変換処理が呼ばれ、負荷がかかる
この問題については一定期間に行われる保存処理の最大数を設定するなどの対処法で解決できます。例えば"データ保存処理は1秒間に1回まで"という制限を設けることで仮に1秒間に100回のデータ更新が行われても保存処理は最大1秒に1回までに抑えられ、負荷は低いレベルを保ち、データを失う心配もありません。
2. RealmとRealmではないModelを書くことになるので単純にModelを書く量が2倍になる+変換処理も追加で書く必要があるのでコードが増える
この問題は避けられないですが、RealmのModel, RealmではないModel,RealmService以外は一切Realmに関わる処理を書かずに済むので、総合的にはコードが減少するケースもあります。
おわりに
データベース用のオブジェクトはとてもデリケートなアイテムで、それを直で触る時はいろんなルールが付き纏い、ルールに背けばクラッシュします。データベース用のオブジェクトは情報のアップデートも時間がかかり、ローカルデータベースでさえ感じられるほどの遅延が起きることがあります。
アプリの品質的にも開発効率的にも、今回紹介したアプローチなどを活用してデータベース用のオブジェクトを直で使うことを避けるのがおすすめです。
Discussion