RealmSwift (opens new window)

RealmSwift が10.10.0 (opens new window)にアップデートされて大幅な変更が入っていました。

RealmSwift 10.10.0

新規機能

  • 全てのプロパティが同一の方法で宣言できる
  • プライマリキーの設定が簡単になった
  • リストも簡単に宣言できる
  • RawRepresentable@Persistedに対応している型であれば Enum も保存できる
  • Map.merge()が実装され、辞書形式ペアを他のMapDictionaryに変換できるようになった
  • Map.asKeyValueSequence()が実装され、辞書形式の配列を返すようになった

バグ修正

  • より多くの Enum オブジェクトをサポート
  • RealmProperty<AnyRealmValue?>を宣言するとエラーを返す問題
  • KVOを経由してRLMDictionary/MapInvalidatedの通知が正しく設定されない問題

使い方

全てのプロパティを@Persistedで宣言できるのはとても便利。もっと早くコレに対応すべきだったのでは。

旧コード

// Legacy
class User: Object {
    @objc dynamic var id: Int = 0       // Int
    let age = RealmOptional<Int>() = 0  // RealmOptional<Int>
    let age = RealmProperty<Int?>() = 0 // RealmProperty<Int?>
    // Primary Key
    override static func primaryKey() -> String? {
        return "id"
    }
}
1
2
3
4
5
6
7
8
9
10
11

新コード

// Modern
class User: Object {
    @Persisted var id: Int = 0      // Int
    @Persisted var age: Int? = 0    // Int?
    // Primary Key
    @Persisted(primaryKey: true) userId: Int = 0
    @Persisted(indexed: true) point: Int = 0
    // Enum
    @Persisted var userType: UserType = .standard
}
enum UserType: Int, PersitableEnum {
    case standard   = 0
    case unlimited  = 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

LinkingObjects

LinkingObjectsはちょっと書き方が変わっていました。ドキュメントも古いままなのでわからなかったのですが、テストコードを読んでようやく意味を理解。

class User: Object{
    @Persisted var cats: List<Cat> = List<Cat>()
    override init() {
        super.init()
        self.cats.append(Cat(name: "Mike"))
    }
}
class Cat: Object {
    @Persisted var name: String = ""
    // Legacy
    let owner = LinkingObjects(fromType: User.self, property: "cats")
    // Modern
    @Persisted(originProperty: "cats") var owner: LinkingObjects<User>
    // Convenience
    convenience init(name: String) {
        self.init()
        self.name = name
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

イニシャライザ

引数を取るイニシャライザを定義した場合、convenienceself.init()を書かないと実行時にエラーが発生します。

LinkingObjectをたどるためには、

let owner = cat.owner.first!
guard let owner = cat.owner.first else { return }
1
2

のようにしてアクセスすれば良い。しかし、バックリンクは一つしかないはずなのに何故firstが必要なのかがわからない。

LinkingObjects<User> <0x7f9da952a1a0> (
	[0] User {
		id = 27;
		age = 43;
		cats = List<Cat> <0x600001d9d290> (
			[0] Cat {
				age = 1;
				name = Mike;
			}
		);
		userType = 0;
	}
)
1
2
3
4
5
6
7
8
9
10
11
12
13

ちなみにLinkingObject自体は参照すると上のようなデータを持っていたので、やはり[0]番目にアクセスするにはfirstとつけなければいけないようだ。

疑問点

以下のプロパティは使い方がいまいちわからなかった。

@Persisted(wrappedValue: 100) var id: Int
@Persisted var id: Int = 100
1
2

この二つ、ほとんど同じように感じますし、実際に実行するとどちらも初期値 100 で初期化されています。

注意点

単にクラスを定義するだけなら以下のように書けます。

class User: Object {
    @Persisted(primaryKey: true) var id: Int = 0
    @Persisted var age: Int? = 0
    // Override
    override init() {
        self.id = Int.random(in: Range(0 ... 100))
        self.age = Int.random(in: Range(0 ... 100))
    }
}
1
2
3
4
5
6
7
8
9
10

イニシャライザをoverrideしなければいけないことだけ忘れないように。

RealmSwift.Listを定義するとちょっとだけややこしくなります。

class User: Object {
    @Persisted(primaryKey: true) var id: Int = 0
    @Persisted var age: Int? = 0
    @Persisted var cats: List<Cat>
    override init() {
        self.id = Int.random(in: Range(0 ... 100))
        self.age = Int.random(in: Range(0 ... 100))
        self.cats.append(Cat(name: "Mike"))
    }
}
class Cat: Object {
    @Persisted var name: String = ""
    override init(name: String) {
        self.name = name
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

こうすると初期化されていないcatsに値を代入しようとしているとエラーが出ます。

class User: Object {
    @Persisted(primaryKey: true) var id: Int = 0
    @Persisted var age: Int? = 0
    @Persisted var cats: List<Cat>
    override init() {
        super.init()    // Required
        self.id = Int.random(in: Range(0 ... 100))
        self.age = Int.random(in: Range(0 ... 100))
        self.cats.append(Cat(name: "Mike"))
    }
}
class Cat: Object {
    @Persisted var name: String = ""
    override init(name: String) {
        self.name = name
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

この場合はイニシャライザ内でsuper.init()を実行しなければいけません。

マイグレーションのタイミング

データベースのマイグレーションおよびスキームバージョンのアップデートは起動時に行われるべきです。

ただし、いくつかの注意点があります。

import SwiftUI
import Realm
import RealmSwift
// グローバル変数で定義してはいけない
let realm = try! Realm()
@main
struct RealmSwiftDemoApp: SwiftUI.App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        #if DEBUG
        let config = Realm.Configuration(schemaVersion: 1, deleteRealmIfMigrationNeeded: true)
        Realm.Configuration.defaultConfiguration = config
        #else
        let config = Realm.Configuration(schemaVersion: 1)
        Realm.Configuration.defaultConfiguration = config
        #endif
        return true
    }
}
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

このようにlet realm = try! Realm()をグローバルで宣言すると、どこからでも利用できて便利なのですがRealm.Configurationでスキームバージョンを上げるよりも前に初期化されてしまうのでクラッシュします。

というよりも、realmのインスタンスはグローバルにすべきではありません。

何故なら、こうするとありとあらゆるファイルからデータベースの更新が可能になってしまい、コードの追加等で意図しないタイミングでデータベースが更新されてしまうからです。

なので、データベースを更新する専用のクラスをつくるほうが無難です。

データベース更新用のクラス

import Foundation
import RealmSwift
final class RealmManager {
    private static let realm = try! Realm()
    class Objects {
        static var users: RealmSwift.Results<User> {
            return realm.objects(User.self)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

というわけで以下のようなクラスをつくってみた。

SwiftUI から削除するとクラッシュする問題

    @State var users = RealmManager.Objects.users
    var body: some View {
        Form {
            ForEach(users) { user in
                Text("\(user.id)")
            }
            .onDelete(perform: delete)
        }
    }
    private func delete(offsets: IndexSet) {
        guard let realm = try? Realm() else { return }
        if let index = offsets.first {
            try? realm.write {
                realm.delete(users[index])
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

で、例えばこんなコードを書いてみます。

Realm のインスタンス

本来はrealmdelete()内で宣言したくなかったのですが、わかりやすくするために書きました。

libc++abi.dylib: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'RLMException', reason: 'Index 8 is out of bounds (must be less than 7).'
terminating with uncaught exception of type NSException
1
2
3

このコードを実装すると、リストからデータを削除したときにクラッシュしてしまいます。というのも、データベースから削除されたにも関わらず、SwiftUI がForEachですでに削除されているインデックスにアクセスしようとしてしまうためです。

ObservableObject を利用した回避法

要するに直接 Realm のデータRealmSwift.Resultsを削除しようとしたためにエラーが発生してしまうのでRealmSwift.ResultsArrayに変換して、SwiftUI 側ではArrayを使ってListを表示するようにします。

class UserModel: ObservableObject {
    private var token: NotificationToken?
    private var users: RealmSwift.Results<User> = RealmManager.Objects.users
    @Published var usersModel: [User] = []
    init() {
        // RealmSwift.Results<User>が更新されるとこのクロージャが実行される
        // そしてuserModelの配列がアップデートされる
        token = users.observe { [weak self] _ in
            self?.usersModel = Array(self!.users)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

という感じで、データベースが更新されるとその通知を受け取ってからArrayを更新します。あとは SwiftUI がArrayを参照してリストを表示するようにすれば良いので、

struct ContentView: View {
    @ObservedObject var userModel = UserModel()
    var body: some View {
        Form {
            ForEach(userModel.usersModel) { user in
                Text("\(user.id)")
            }
            .onDelete(perform: delete)
        }
    }
    private func delete(offsets: IndexSet) {
        guard let realm = try? Realm() else { return }
        // 削除されたArrayから、削除されるRealmSwiftl.Results<User>を計算する
        if let index = offsets.first, let user = realm.objects(User.self).filter("id=%@", userModel.usersModel[index].id).first {
            try? realm.write {
                realm.delete(user)
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

とすれば良いことになります。

ObservableObject を利用しない回避法

また、ObserverdObjectを使わない場合は以下のように書けます。

struct ContentView: View {
    @State var users: [User] = Array(RealmManager.Objects.users)
    var body: some View {
        Form {
            ForEach(users) { user in
                Text("\(user.id)")
            }
            .onDelete(perform: delete)
        }
    }
    private func delete(offsets: IndexSet) {
        guard let realm = try? Realm() else { return }
        if let index = offsets.first {
            try? realm.write {
                // ここの判定は曖昧なのでより厳密にしても良いかも
                // データベースから削除
                realm.delete(users[index])
            }
            // SwiftUIのリストから削除
            users.remove(atOffsets: offsets)
        }
    }
}
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

Frozen Objects を利用した回避法

struct ContentView: View {
    @State private(set) var users: RealmSwift.Results<User> = RealmManager.Objects.users
    @State private(set) var freezedUsers: RealmSwift.Results<User> = RealmManager.Objects.users
    var body: some View {
        Form {
            ForEach(users) { user in
                Text("\(user.id)")
            }
            .onDelete(perform: delete)
        }
    }
    private func delete(offsets: IndexSet) {
        guard let realm = try? Realm() else { return }
        if let index = offsets.first {
            try? realm.write {
                // ここの判定は曖昧なのでより厳密にしても良いかも
                // データベースから削除
                realm.delete(freezedUsers[index])
            }
            // freezeでコピー
            users = freezedUsers.freeze()
        }
    }
}
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

同じものを複数用意するというのが少々ダサいです。

個人的にはループのところで、

var body: some View {
    Form {
        ForEach(users.freeze()) { user in
            Text("\(user.id)")
        }
        .onDelete(perform: delete)
    }
}
1
2
3
4
5
6
7
8

ができたら便利だと思うのですが、これをやると削除しても SwiftUI がリストをアップデートしてくれませんでした、残念。