LoginSignup
37

More than 3 years have passed since last update.

SwiftUIとRealmを連携してUIを自動更新する

Last updated at Posted at 2020-02-04

はじめに

SwiftUIとRealmを連携してライブアップデートを効かせる方法を記載します。

要点

  • Realmオブジェクトはライブアップデート機能を備えており、該当のインスタンスは常に最新の値で更新されている
  • Realmオブジェクトを @Published で保持したとしても、値に変更があった際に自動で更新されるようにはならない
  • observeメソッドを利用してSwiftUIに再レンダリングの指示を与えることで、常に最新の値がUIに反映されるようにすることができる

Realmのインストール

RealmはSwiftPackageManagerに対応しているので、今回はこれを利用してインストールします

  1. Dependencyを追加する
    image

  2. realm-cocoa を選択する
    image

  3. バージョンを指定する
    image

  4. プロダクトを選択する(両方選択します)
    image

  5. import RealmSwift を記述すれば利用可能になる

Entityの用意

  • まずは準備としてRealmで保持するデータの型を定義します。
  • 今回は動作検証用として、以下の挙動を行うようにしています
    • setUpメソッドを実行すると2秒ごとにデータを一度全て削除し、ランダムな名称が設定されたItemデータが再セットされる
import RealmSwift

class ItemEntity: Object, Identifiable {
    @objc dynamic var id: String = ""
    @objc dynamic var name: String = ""

    override class func primaryKey() -> String? { "id" }
    override class func indexedProperties() -> [String] { ["id"] }

    private static var realm = try! Realm()

    static func setUp() {
        Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { (timer) in
            try! realm.write {
                realm.deleteAll()
                realm.add(createFixture(), update: .modified)
            }
        }
    }

    static func all() -> Results<ItemEntity> {
        realm.objects(ItemEntity.self)
    }

    private static func createFixture() -> [ItemEntity] {
        (0..<10)
            .map { _ in (0..<1000).randomElement()! }
            .map { number -> ItemEntity in
                let item = ItemEntity()
                item.id = "\(number)"
                item.name = "item\(number)"
                return item
            }
    }
}

ObservableObjectを利用する

  • Entityを用意したらViewModel(ObservableObject)を介してRealmのデータを取得するようにします
  • しかし、このコードは最初にフェッチしたデータしか表示されません。
    • @Published で変更が監視されるのはその変数の値自体が変わったタイミングなので、RealmのResultsインスタンスの内部状態に変更があっても、Viewの再レンダリングが行われないためです。
struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        List {
            // DBに変更があってもSwiftUIはその変更を検知できず、UIは初期表示から更新されない
            ForEach(viewModel.itemEntities) { itemEntity in
                Text(itemEntity.name)
            }
        }.onAppear {
            ItemEntity.setUp()
        }
    }
}

class ViewModel: ObservableObject {
    @Published var itemEntities: Results<ItemEntity> = ItemEntity.all()
}

image

対応方法

  • それではDBのデータに変更があった際に、Viewを再レンダリングさせるにはどうすれば良いでしょうか
  • Realmのオブジェクトが持つ observe メソッドを利用することでこれを実現することができます
  • このメソッドを利用すると、DBのデータに変更が起きるたびに受け渡したクロージャが実行されるので、そのタイミングをフックしてObservableObjectの状態を更新してあげれば期待した挙動をさせることができます
entity.observe { (change) in
    switch change {
    case let .initial(results):
        // do something
    case let .update(results, deletions, insertions, modifications):
        // do something
    case let .error(error):
        // do something
    }
}
struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()

    var body: some View {
        List {
            ForEach(viewModel.itemEntities) { (itemEntity: ItemEntity) in
                if itemEntity.isInvalidated {
                    EmptyView()
                } else {
                    Text(itemEntity.name)
                }
            }
        }.onAppear {
            ItemEntity.setUp()
        }
    }
}

class ViewModel: ObservableObject {
    @Published var itemEntities: Results<ItemEntity> = ItemEntity.all()
    private var notificationTokens: [NotificationToken] = []

    init() {
        // DBに変更があったタイミングでitemEntitiesの変数に値を入れ直す
        notificationTokens.append(itemEntities.observe { change in
            switch change {
            case let .initial(results):
                self.itemEntities = results
            case let .update(results, _, _, _):
                self.itemEntities = results
            case let .error(error):
                print(error.localizedDescription)
            }
        })
    }

    deinit {
        notificationTokens.forEach { $0.invalidate() }
    }
}

※ もしくは、 @Published を利用せずに手動で状態の変更を通知する方法でも同様の挙動を実現できます。
この方法を利用すれば、プロパティの値を置換することなく再レンダリングをさせることができます。

import Combine
class Store: ObservableObject {
    var objectWillChange: ObservableObjectPublisher = .init()
    private(set) var itemEntities: Results<ItemEntity> = ItemEntity.all()
    private var notificationTokens: [NotificationToken] = []

    init() {
        notificationTokens.append(itemEntities.observe { _ in
            // SwiftUIに再レンダリングが必要なことを伝える
            self.objectWillChange.send()
        })
    }

    // ...
}

この実装によって、自動更新の挙動を実現することができました。(gif参照)
image

(おまけ)EnvironmentObjectを利用する

  • 上記の例はObservableObjectを利用しましたが、EnvironmentObjectとしてデータを管理したいというケースもあると思います
  • この変更はとても簡単で以下の修正を入れるだけです
    • (ViewModelをStoreにリネーム)
    • Viewが保持するインスタンスを @ObservedObject から @EnvironmentObject に変更
    • Viewの初期化時に environmentObject Modifierでデータを保持するオブジェクトを受け渡す
  • これによりEnvironmentObjectがバインドされたViewは、DBに更新があった時に全て自動的に更新されるようになります
- class ViewModel: ObservableObject {
+ class Store: ObservableObject {
    // ...
}
struct ContentView: View {
    - @ObservedObject(initialValue: ViewModel()) private var viewModel: ViewModel
    + @EnvironmentObject private var store: Store
let contentView = ContentView()
+    .environmentObject(Store())

サンプルコード

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37