Quantumleap
2215 words
11 minutes
RealmCocoaがまたアップデートしてるんだが

RealmSwift#

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

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"
    }
}

新コード#

// 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
}

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
    }
}

::: warning イニシャライザ

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

:::

LinkingObjectをたどるためには、

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

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

LinkingObjects<User> <0x7f9da952a1a0> (
	[0] User {
		id = 27;
		age = 43;
		cats = List<Cat> <0x600001d9d290> (
			[0] Cat {
				age = 1;
				name = Mike;
			}
		);
		userType = 0;
	}
)

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

疑問点#

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

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

この二つ、ほとんど同じように感じますし、実際に実行するとどちらも初期値 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))
    }
}

イニシャライザを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
    }
}

こうすると初期化されていない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
    }
}

この場合はイニシャライザ内で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
    }
}

このように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)
        }
    }
}

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

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])
            }
        }
    }

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

::: tip 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

このコードを実装すると、リストからデータを削除したときにクラッシュしてしまいます。というのも、データベースから削除されたにも関わらず、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)
        }
    }
}

という感じで、データベースが更新されるとその通知を受け取ってから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)
            }
        }
    }
}

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

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)
        }
    }
}

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()
        }
    }
}

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

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

var body: some View {
    Form {
        ForEach(users.freeze()) { user in
            Text("\(user.id)")
        }
        .onDelete(perform: delete)
    }
}

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

なんとアップデートでできるようになっていました。 RealmCocoa が SwiftUI に正式対応してるっぽい

RealmCocoaがまたアップデートしてるんだが
https://fuwari.vercel.app/posts/2021/07/realmswift/
Author
tkgling
Published at
2021-07-08