はじめに
SwiftUIとRealmを連携してライブアップデートを効かせる方法を記載します。
要点
- Realmオブジェクトはライブアップデート機能を備えており、該当のインスタンスは常に最新の値で更新されている
- Realmオブジェクトを
@Published
で保持したとしても、値に変更があった際に自動で更新されるようにはならない - observeメソッドを利用してSwiftUIに再レンダリングの指示を与えることで、常に最新の値がUIに反映されるようにすることができる
Realmのインストール
RealmはSwiftPackageManagerに対応しているので、今回はこれを利用してインストールします
- Dependencyを追加する
- realm-cocoa を選択する
- バージョンを指定する
- プロダクトを選択する(両方選択します)
-
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()
}
対応方法
- それでは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参照)
(おまけ)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())
サンプルコード