SwiftUIでのRealmの使い方まとめ(プロパティラッパー、フェッチ、プレビュー、etc.)
はじめに
この記事は、Realm初心者の僕がSwiftUIでRealmを使うにあたってつまずいたところを主にまとめた物です。新たな知見があれば追記していくつもりです。間違いなどありましたら気軽にコメント欄でご指摘ください。
プロジェクトの例として、DiaryとPageというオブジェクトで構成される日記アプリを説明で用います。Diaryは複数のPageを内包する、というぐらいの認識で大丈夫です。
導入方法(XcodeプロジェクトでSwiftPM)
Xcodeのサイドバーのプロジェクト -> PROJECT -> Package Dependencies -> "+"ボタンをクリック -> サーチフィールドに"https://github.com/realm/realm-swift"を入力 -> 右下の"Add Package"ボタンをクリック
オブジェクトの定義
Realmで扱いたいオブジェクトは、Objectを継承したclassとして宣言します。それぞれのプロパティには@Persistedというプロパティラッパーを付与します。
プロパティタイプは、Bool、Int、Int8、Int16、 Int32、Int64、Double、Float、String、Date、Dataがサポートされています。
オブジェクトを識別するためのプライマリーキーには引数のprimaryKeyをtrueとします。ここではUUID().uuidStringを使用していますが、一意性が担保されていてRealmで保存できるプロパティタイプならなんでもいいようです。公式チュートリアルではRealmSwift.ObjectIdをプライマリーキーに設定していますが、SwiftUIのセレクションやナビゲーションなどでIDをSceneStorageなどに保存することを考えれば、Stringの方が便利なのではないでしょうか。
One-to-Manyのリレーションについては、親オブジェクトの方でRealmSwift.List、子オブジェクトの方でLinkingObjectsとして宣言します(Core Dataでいうところのinverse)。RealmSwift.Listについては、SwiftUIにも同名のタイプが存在するのでモジュール名を省略できません。
日記アプリのモデルを実装した例
import Foundation
import RealmSwift
final class Diary: Object, Identifiable {
@Persisted(primaryKey: true) var id = UUID().uuidString
@Persisted var name: String
@Persisted var pages: RealmSwift.List<Page>
convenience init(name: String, pages: [Page] = []) {
self.init()
self.name = name
self.pages = .init()
self.pages.append(objectsIn: pages)
}
}
final class Page: Object, Identifiable {
@Persisted(primaryKey: true) var id = UUID().uuidString
@Persisted var title: String
@Persisted var body: String
@Persisted var createdDate: Date
@Persisted var isMarked = false
@Persisted(originProperty: "pages") var diary: LinkingObjects<Diary>
convenience init(
title: String,
body: String,
createdDate: Date = .now,
isMarked: Bool = false
) {
self.init()
self.title = title
self.body = body
self.createdDate = createdDate
self.isMarked = isMarked
}
}
Realmオブジェクトの伝搬
environment(_:_:)モディフィアを使用して下位のSceneやViewにconfigurationを共有することができます。特に指定しない場合、フェッチなどではdefaultConfigurationが使用されます。Realm自体を指定する方法もあるようです。
import SwiftUI
import RealmSwift
@main
struct DiaryApp: SwiftUI.App {
var body: some Scene {
WindowGroup {
MainView()
.environment(\.realmConfiguration, .init(...))
//または
.environment(\.realm, .init(...))
}
}
}
各種プロパティラッパー
@Persisted
オブジェクトを宣言する際にプロパティに付与します。オブジェクトの定義を参照してください。
@ObservedResults
Realmからオブジェクトをフェッチして、自動で変更を監視して反映してくれるものです(Core Dataでいうところの@FetchRequest)。イニシャライザでconfigurationを特に指定しない場合、内部のRealmではEnvironmentValueで渡されたrealmConfigurationが使用されます。$記号をつけてアクセスすることで、filterやsortDescriptorを設定できるようです。その他の使用法についてはViewでRealmオブジェクトの情報を表示するを参照してください。
struct DiaryList: View {
@ObservedResults(Diary.self) var diaries
...
var body: some View {
List(diaries) { diary in
...
}
}
}
// 呼び出し
DiaryList()
@ObservedRealmObject
ObjectまたはRealmSwift.Listの変更を監視してViewに反映させるプロパティラッパーです(@ObservedObjectのようなもの)。
テキストフィールドなどにBindingを渡すときは$page.titleのように簡単に記述できます。内部的にrealm.write { ... }のようなトランザクションを扱っているので、UIからオブジェクトに変更を加えるとすぐにRealmに反映されます。
struct PageEditor: View {
@ObservedRealmObject var page: Page
...
var body: some View {
TextField("タイトル", text: $page.title)
...
}
}
// 呼び出し
let page: Page
PageEditor(page: page)
ViewでRealmオブジェクトの情報を表示する
単一の場合
@ObservedRealmObjectプロパティラッパーを使用します。wrappedValueの中のプロパティのバインディングを作る際には、SwiftUI.Stateなどと同様に先頭に$記号をつけます。簡単ですね。
@ObservedRealmObject var page: Page
// そのまま渡す場合
page.title // String
// バインディングを渡す場合
$page.title // Binding<String>
複数の場合
複数のRealmオブジェクトをSwiftUIで扱う方法は2つあります。一つはResults、もう一つはRealmSwift.Listです。対応するプロパティラッパーは次の通りです。
| wrappedValue | プロパティラッパー | 生成元 |
|---|---|---|
Results |
ObservedResults |
現在のView |
RealmSwift.List |
ObservedRealmObject |
上の階層のView |
Results
@ObservedResults(Page.self) var pages // Viewのinitの引数にならない
pages // Results<Page>
$pages // ObservedResults<Page>
ResultsはSwift.RandomAccessCollectionに準拠しているので、SwiftUI.ListやForEachで扱うことができます。
さらに、ObservedResultsはRealmSwift.BoundCollectionというプロトコルにも準拠しています。このプロトコルは、書き込みトランザクションに関する記述を省略できるという便利機能を備えています。つまり下記のように書けます。
@Environment(\.realm) var realm
let newPage = Page(title: "New Page", body: "")
// 便利機能なしver
realm.thaw().write {
diary.append(newPage)
}
// 便利機能ありver
$diary.pages.append(newPage)
ソースコードを見る限り、BoundCollectionは内部的に書き込みトランザクションを開いているようです。このほかにもremoveやmoveなどのメソッドも用意されています。
ObservedResultsのfilterについては後述します。
List
こちらもSwift.RandomAccessCollectionに準拠しているので、ListやForEachで扱うことができます。
@ObservedRealmObject var pages: RealmSwift.List<Page> // Viewのinitの引数となる
pages // RealmSwift.List<Page>
$pages // ObservedRealmObject<List<Page>>.Wrapper(あまり使わない)
検索、フィルター
プレビューデータの扱い方
この例ではプレビューデータをひとつのスコープにまとめるためにenumを使用します。
enum PreviewData {
static var realm: Realm {
let realm = try! Realm(configuration: .init(inMemoryIdentifier: "PreviewData.realm"))
try! realm.write {
realm.deleteAll()
}
try! realm.write {
Self.diaries.forEach { diary in
realm.add(diary)
}
}
return realm
}
}
どのRealmにも紐づけられていない(管理されていない)オブジェクトをプレビューで使用するのは危険なので、staticなrealmプロパティからしかプレビューデータにアクセスできないように、生のオブジェクトのアクセスレベルをfileprivateに制限しています。
extension PreviewData {
fileprivate static var diaries: [Diary] {
[
...
]
}
}
Preview側ではこのようにアクセスします。
struct PageEditor_Previews: PreviewProvider {
static var previews: some View {
let page = PreviewData.realm.objects(Page.self).first!
PageEditor(page: page)
}
}
後書き
バージョン
RealmSwift: version 10.20.2
Xcode: version 13.2
参考記事
Discussion