[Swift][Realm]Realm が提供する Collection に関する 変更通知 (RealmCollectionChange)

     
⌛️ 5 min.

Realm が提供する Collection に関する 変更通知 (RealmCollectionChange) の振る舞いについて確認してみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS15.1 Sequoia
  • Xcode 16.1 Beta3
  • iOS 18.2 beta
  • Swift 5.9

なお、Realm は、2025/Sep/30 に end-of-life になるとのお知らせが出ています。
realm-swift の部分は、open source project として残るようです。

RealmCollectionChange

Realm の変更通知の監視対象には以下の要素があります。

・Realm 全体
・Realm の Collection
・Realm の Object

全体像については、こちらをどうぞ。
[Swift] [Realm] Realm でのデータ更新通知まとめ

この記事では、Realm の Collection に対する監視の詳細を確認していきます。

Realm の Collection に対しての監視では、変更内容が RealmCollectionChange という型(enum) で通知されます。
RealmCollectionChange のドキュメントは、こちらで確認してください。

テストコード前提

テスト対象データ

Realm に保存するデータには、以下の2種類用意しました。
・Team
・Fighter

class Fighter: Object, Identifiable {
    @Persisted(primaryKey: true) public var id: UUID = UUID()
    @Persisted var name: String = ""
    @Persisted var maxHitPoint: Int = 100
    @Persisted(originProperty: "fighters") var team: LinkingObjects<Team>

    var feelingValue: Int = 20
}

class Team: Object {
    @Persisted(primaryKey: true) var id: UUID = UUID()
    @Persisted var name: String = ""
    @Persisted var fighters: List<Fighter> = List()
}

Team は 複数の Fighter を持ち、Fighter は Team への逆リンクを持つという関係です。

なお、Fighter には、Realm で保存対象としないプロパティ “feelingValue” も定義しています。

補助クラス/ extension

テストコードをシンプルにしたかったので、以下のような 補助クラス/extension を使用しています。

Realm の RealmCollectionChange に対して、以下のような extension を定義してアクセスしやすくしています。

extension RealmCollectionChange {
    var isInitial: Bool {
        switch self {
        case .initial: return true
        default: return false
        }
    }

    var isUpdate: Bool {
        switch self {
        case .update: return true
        default: return false
        }
    }

    var initialValues: CollectionType? {
        switch self {
        case .initial(let collectionType):  return collectionType
        default:
            return nil
        }
    }

    var updateValues: (array: CollectionType, deleted: [Int], inserted: [Int], modified: [Int])? {
        switch self {
        case .update(let collectionType, let deletions, let insertions, let modifications):
            return (collectionType, deletions, insertions, modifications)
        default:
            return nil
        }
    }
}

テストでは、XCTestExpectation が 複数回満たされることを利用して確認していくので、以下のようなクラスも使用します。

/// XCTestExpectation container
/// usage
/// ```
/// func test_something() async throws {
///     let expectations = RepeatedExpectation(5) // preparation (specify how many times expectation will be fulfilled
///
///     var cancellables: Set = Set()
///
///     // setup sut which should fulfill at some point
///
///     sut.objectDidChange.sink { change in
///       // call expectations.fulfill()
///       expectations.fulfill()
///     }.store(in: &cancellables)
///
///     // do something
///     // wait for expectation fulfilled n times (in following, wait for one fulfillment)
///     await fulfillment(of: [expectations[1]], timeout: 3)
///
///     // test result
///     XCTAssert.....
/// ```
public class RepeatedExpectation: Sendable {
    public var expectations: [XCTestExpectation] = []

    public init(_ num: Int) {
        for index in 0...num {
            let expectation = XCTestExpectation(description: "\(index)-th")
            expectation.assertForOverFulfill = false
            expectation.expectedFulfillmentCount = (index != 0) ? index : Int.max
            expectations.append(expectation)
        }
    }

    public subscript(index: Int) -> XCTestExpectation {
        guard index >= 0, index  expectations.count else { fatalError("invalid index")}
        return expectations[index]
    }

    public func fulfill() {
        expectations.forEach({
            $0.fulfill()
        })
    }
}

Realm での Collection監視の基本

RealmCollectionChange は、Realm の Results<CollectionType> に対して監視したときに通知される変更です。

以降では、RealmCollectionChange として通知されてくる内容を確認していきます。

初期データ

最初に、監視対象となるデータを作ります。

Fighter を2つ作り、name に “R01”, “R02” とそれぞれ設定しました。
また、Team は、1つ作り、2つの Fighter を持たせています。

        let fighter1 = Fighter()
        fighter1.name = "R01"
        let fighter2 = Fighter()
        fighter2.name = "R02"
        let team = Team()

        let realm = try await Realm(configuration: .init(inMemoryIdentifier: Date().description))
        try realm.write {
            realm.add(team)
            realm.add(fighter1)
            realm.add(fighter2)
            team.fighters.append(objectsIn: [fighter1, fighter2])
        }

# テスト用なので、inMemory で作っています。

監視の開始

以降では、Results を監視してみます。

監視するには、監視対象とする Results<CollectionType> が必要です。
以下では realm.objects で取得していますが、この取得方法でなければいけないということは特にありません。
ただし、フィルター等を指定して取得していると、そのフィルターによっては 要素を追加しても(コレクションが影響を受けないために)通知が来ないことはあります。

そのようにして取得した Resutls に対して、observe すると監視が開始されます。
注意点としては、返り値である NotificationToken は、保持しておかないと、監視が継続されません。
# このような点は Combine と似ています。Combine では、.sink 等の返り値である AnyCancellable を保持しておかないとキャンセルされてしまいます。

let realmTeams = realm.objects(Team.self)

let token = realmTeams.observe(keyPaths: [], { change in
    // do something
})

keyPaths にキーパスを指定すると Results に限定されない変更を監視できますがそれはここでは説明しません。

この記事では、以下のように、observe での通知を 変数 passedChange に記録して内容を確認していきます。

observe のコールバックがよばれるたびに、XCTExpectation を fulfill して、内容を確認していきます。

        let expectations = RepeatedExpectation(10)

        // ... omit ...

        let realmTeams = realm.objects(Team.self)
        var passedChange: RealmCollectionChange<Results<Team>>?

        let token = realmTeams.observe(keyPaths: [], { change in
            passedChange = change
            expectations.fulfill()
        })

initialの通知

observe での監視は、Combine での CurrentValue のように、監視設定時に、その時の値が通知されます。

そのときには、initial という case になっています。(RealmCollectionChange は enum です)

        let token = realmTeams.observe(keyPaths: [], { change in
            passedChange = change
            expectations.fulfill()
        })

        await fulfillment(of: [expectations[1]], timeout: 3) // wait for first publish
        XCTAssertEqual(passedChange?.isInitial, true)
        let initValues = try XCTUnwrap(passedChange?.initialValues)
        XCTAssertEqual(initValues.count, 1)
        XCTAssertEqual(initValues.first?.id, team.id)
        

上記は、以下を確認しています。

・最初に、initial で通知されるのを待つために、expectation が1度よばれるのを待ちます
・通知された change が initial であることの確認
・通知された initial での associated value で渡される collection のサイズが1であり、その要素は作成した Team の id と一致すること

これが、collection (この場合 Results<Team> を observe した時の動作です。

変更の通知(追加)

監視している collection に要素が追加されると、通知されます。
以下のコードでは、team2 を作成して通知されることを確認しています。

        // add team2
        let team2 = Team()
        try realm.write {
            realm.add(team2)
        }
        await fulfillment(of: [expectations[2]], timeout: 3) // wait for team2 adding
        XCTAssertEqual(passedChange?.isUpdate, true)
        var updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [0, 0, 1])
        XCTAssertEqual(updateValues.array[updateValues.inserted[0]].id , team2.id)

追加されたときには、update という case の RealmCollectionChange が通知されます。
その associated Value のうち、inserted に、追加された要素の index が保持されています。

この index は、associated Value として渡される 配列に含まれる 追加要素の index を意味します。

上記では、通知された update の associated Value の deleted, modified が空配列であることを確認しています。
そして、inserted が要素数1であり、該当追加要素の id が、 (追加した) team2 の id と一致することも確認しています。

このように、監視対象の collection に要素が追加されると通知がきます。

変更の通知(削除)

追加と同様に、監視している collection の要素が削除されても 通知がきます。

以下のコードでは、team を削除して、通知を確認しています。

        // delete team
        let deletedID = team.id
        let savedTeams = realmTeams.freeze()
        try realm.write {
            realm.delete(team)
        }
        await fulfillment(of: [expectations[3]], timeout: 3) // wait for team delete
        XCTAssertEqual(passedChange?.isUpdate, true)
        updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [1,0,0])
        XCTAssertEqual(savedTeams[updateValues.deleted[0]].id, deletedID)

追加された時は、update の inserted に追加要素の情報が入っていましたが、削除された時は、deleted に保持されています。

ただし、削除された要素は、update の associated value の渡してくる 配列 には、(すでに要素が削除されているので)含まれていないことに、注意が必要です。
上記のコードでは、削除後の通知がきた段階では 変数 realmTeams には、team2 のみが含まれています。

どの要素が削除されたかの詳細を確認したいときには、上記のコードのように削除前の Results を保持しておいて確認するしかありません。ただし、削除された要素の詳細にアクセスしようとすると、通常 invalidated された要素へのアクセスとされてしまうので、工夫が必要です。

追加/削除の通知についてのテストコード(全体)

テストコード全体は以下です。

func test_list_listAddRemove() async throws {
    let expectations = RepeatedExpectation(10)
    let fighter1 = Fighter()
    fighter1.name = "R01"
    let fighter2 = Fighter()
    fighter2.name = "R02"
    let team = Team()

    let realm = try await Realm(configuration: .init(inMemoryIdentifier: Date().description))
    try realm.write {
        realm.add(team)
        realm.add(fighter1)
        realm.add(fighter2)
        team.fighters.append(objectsIn: [fighter1, fighter2])
    }

    let realmTeams = realm.objects(Team.self)
    var passedChange: RealmCollectionChange<Results<Team>>?

    let token = realmTeams.observe(keyPaths: [], { change in
        passedChange = change
        expectations.fulfill()
    })

    await fulfillment(of: [expectations[1]], timeout: 3) // wait for first publish
    XCTAssertEqual(passedChange?.isInitial, true)
    let initValues = try XCTUnwrap(passedChange?.initialValues)
    XCTAssertEqual(initValues.count, 1)
    XCTAssertEqual(initValues.first?.id, team.id)

    // add team2
    let team2 = Team()
    try realm.write {
        realm.add(team2)
    }
    await fulfillment(of: [expectations[2]], timeout: 3) // wait for team2 adding
    XCTAssertEqual(passedChange?.isUpdate, true)
    var updateValues = try XCTUnwrap(passedChange?.updateValues)
    XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                   [0, 0, 1])
    XCTAssertEqual(updateValues.array[updateValues.inserted[0]].id , team2.id)

    // delete team
    let deletedID = team.id
    let savedTeams = realmTeams.freeze()
    try realm.write {
        realm.delete(team)
    }
    await fulfillment(of: [expectations[3]], timeout: 3) // wait for team delete
    XCTAssertEqual(passedChange?.isUpdate, true)
    updateValues = try XCTUnwrap(passedChange?.updateValues)
    XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                   [1,0,0])
    XCTAssertEqual(savedTeams[updateValues.deleted[0]].id, deletedID)
}

Realm での Collection 監視 応用編

通常 collection の監視は、追加・削除が基本な気がしますが、Realm では、collection の要素が更新されたときにも通知されます。

RealmCollectionChange の update の associated Value である modified は、このための情報です。

ただし、そのためには、どこを監視するのか、observe 時に引数の keyPaths で指定することが必要です。

        let token = realmTeams.observe(keyPaths: [\.fighters, \.name], { change in
            passedChange = change
            expectations.fulfill()
        })

上記では、Team.fighter と Team.name への変更も監視していることになります。

確認してみます。 まずは、name

        // change name
        try realm.write {
            team.name = "MoreName"
        }
        await fulfillment(of: [expectations[2]], timeout: 3)
        XCTAssertEqual(passedChange?.isUpdate, true)
        var updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [0, 1, 0])
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].id , team.id)
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].name , "MoreName")

変更であれば、update の associated Value の collection もその要素を保持しているので、update の渡してくる情報だけで確認できます。

Team がもつ collection への操作も確認してみます。(初期設定時に、fighter2 を team に追加していません)

        try realm.write {
            team.fighters.append(fighter2)
        }
        await fulfillment(of: [expectations[3]], timeout: 3) // for update
        XCTAssertEqual(passedChange?.isUpdate, true)
        updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [0, 1, 0])
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].id , team.id)
        let fightersUnderTeam = Set(updateValues.array[updateValues.modified[0]].fighters)
        XCTAssertEqual(fightersUnderTeam, [fighter1, fighter2])

変更の通知についてのテストコード(全体)

テストコード全体は以下です。

    func test_list_internalListaddRemove() async throws {
        let expectations = RepeatedExpectation(10)
        let fighter1 = Fighter()
        fighter1.name = "R01"
        let fighter2 = Fighter()
        fighter2.name = "R02"
        let team = Team()

        let realm = try await Realm(configuration: .init(inMemoryIdentifier: Date().description))
        try realm.write {
            realm.add(fighter1)
            realm.add(fighter2)
            realm.add(team)
            team.fighters.append(objectsIn: [fighter1])
        }

        let realmTeams = realm.objects(Team.self)
        var passedChange: RealmCollectionChange<Results<Team>>?

        let token = realmTeams.observe(keyPaths: [\.fighters, \.name], { change in
            passedChange = change
            expectations.fulfill()
        })
        await fulfillment(of: [expectations[1]], timeout: 3) // for initial

        // change
        try realm.write {
            team.name = "MoreName"
        }
        await fulfillment(of: [expectations[2]], timeout: 3)
        XCTAssertEqual(passedChange?.isUpdate, true)
        var updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [0, 1, 0])
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].id , team.id)
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].name , "MoreName")

        try realm.write {
            team.fighters.append(fighter2)
        }
        await fulfillment(of: [expectations[3]], timeout: 3) // for update
        XCTAssertEqual(passedChange?.isUpdate, true)
        updateValues = try XCTUnwrap(passedChange?.updateValues)
        XCTAssertEqual([updateValues.deleted.count, updateValues.modified.count, updateValues.inserted.count],
                       [0, 1, 0])
        XCTAssertEqual(updateValues.array[updateValues.modified[0]].id , team.id)
        let fightersUnderTeam = Set(updateValues.array[updateValues.modified[0]].fighters)
        XCTAssertEqual(fightersUnderTeam, [fighter1, fighter2])
    }

まとめ

Realm が提供する Collection に関する 変更通知 (RealmCollectionChange) の振る舞いについて確認しました。

Realm の Collection に関する RealmCollectionChange の振る舞い
  • observe すると、initial 通知がされる
  • 追加されると通知される
  • 削除されると通知されるが、削除された要素は取得できない
  • 変更は、変更監視するプロパティを observe で指定することが必要

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です