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

## はじめに

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

### 要点

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

## Realmのインストール

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

1. Dependencyを追加する
<img width="400" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/3500d9db-0837-0692-a18f-10402f15dd50.png"/>

2. realm-cocoa を選択する
<img width="400" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/7c801078-aae4-32c3-b229-e09eeb11f4f3.png"/>

3. バージョンを指定する
<img width="400" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/3ae33b58-c6cb-e916-6833-6cb26454c60c.png"/>

4. プロダクトを選択する（両方選択します）
<img width="400" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/58e89940-a3d7-27e2-2f71-c4d46ebaf492.png"/>

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

## Entityの用意

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

```swift
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の再レンダリングが行われないためです。

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

<img width="200" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/7b66bf38-c7bb-96ae-eee3-f76c19b04df8.png"/>

## 対応方法

- それではDBのデータに変更があった際に、Viewを再レンダリングさせるにはどうすれば良いでしょうか
- Realmのオブジェクトが持つ `observe` メソッドを利用することでこれを実現することができます
- このメソッドを利用すると、DBのデータに変更が起きるたびに受け渡したクロージャが実行されるので、そのタイミングをフックしてObservableObjectの状態を更新してあげれば期待した挙動をさせることができます

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

```swift
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` を利用せずに手動で状態の変更を通知する方法でも同様の挙動を実現できます。
この方法を利用すれば、プロパティの値を置換することなく再レンダリングをさせることができます。

```swift
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参照）
<img width="200" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/d8aa9c20-1a94-ff4d-ec52-c976ec43ff9a.gif"/>

### （おまけ）EnvironmentObjectを利用する

- 上記の例はObservableObjectを利用しましたが、EnvironmentObjectとしてデータを管理したいというケースもあると思います
- この変更はとても簡単で以下の修正を入れるだけです
    - （ViewModelをStoreにリネーム）
    - Viewが保持するインスタンスを `@ObservedObject` から `@EnvironmentObject` に変更
    - Viewの初期化時に `environmentObject` Modifierでデータを保持するオブジェクトを受け渡す
- これによりEnvironmentObjectがバインドされたViewは、DBに更新があった時に全て自動的に更新されるようになります

```swift
- class ViewModel: ObservableObject {
+ class Store: ObservableObject {
    // ...
}
```

```swift
struct ContentView: View {
    - @ObservedObject(initialValue: ViewModel()) private var viewModel: ViewModel
    + @EnvironmentObject private var store: Store
```

```swift
let contentView = ContentView()
+    .environmentObject(Store())
```

## サンプルコード
https://github.com/chocoyama/SwiftUI-Realm