SwiftUIでEquatableなViewで冗長な更新を防ぐ

# SwiftUIでEquatableなView(EquatableView)で冗長な更新を防ぐ
SwiftUIを使うと良い感じにUIを作ることができます
しかし、特に大規模なアプリを作成していると、たまにパフォーマンスの問題に当たることがあります
SwiftUIでは、Equatableを継承しているViewは、Equatableの評価結果が等価である場合は、Viewを更新しないという挙動になります
この仕組みを使うと、冗長な更新を防ぎ、パフォーマンスを向上させることができそうです
そこで、EquatableなView([EquatableView](https://developer.apple.com/documentation/swiftui/equatableview))の挙動を確認しつつ、効果的な使い方を考えてみました
※ Xcode14/iOS16で検証しています

# EquatableなViewの挙動の確認
まずは、EquatableなViewと、EquatableではないViewとの挙動の違いを確認します
ベースとして、下記のような2個の数字をインクリメントするViewを作りました

```swift
class ContentViewModel: ObservableObject {
    @Published var countA: Int = 0
    @Published var countB: Int = 0
}
struct ChildView: View {
    let text: String
    init(text: String) {
        print("SubView.init text: \(text)")
        self.text = text
    }
    var body: some View {
        let _ = print("SubView.body text: \(text)")
        Text(text)
    }
}
struct ContentView: View {
    @ObservedObject var vm = ContentViewModel()
    var body: some View {
        Button("count up A") { vm.countA += 1 }
        Button("count up B") { vm.countB += 1 }
        ChildView(text: "A -> \(vm.countA)")
        ChildView(text: "B -> \(vm.countB)")
    }
}
```

結果がこちらです
ボタンを押下すると、両方のChildViewのinitが呼ばれていますが、
countAをインクリメントすると上のChildViewのみが再描画、countBをインクリメントすると下のChildViewのみが再描画されていることがわかります
SwiftUIが良い感じに差分更新してくれています

![defo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/b6662a2a-965d-e44f-635c-711cd23e9de9.gif)

## 良い感じの差分更新がなくなるパターン

少しChildViewとContentViewを変えて、ChildViewでタップを検出して、ContentViewModelのプロパティをインクリメントするようにしてみました

```diff_swift
struct ChildView: View {
    let text: String
+    let onTap: () -> Void
-    init(text: String) {
+    init(text: String, onTap: @escaping () -> Void) {
        print("ChildView.init text: \(text)")
        self.text = text
+        self.onTap = onTap
    }
    var body: some View {
        let _ = print("ChildView.body text: \(text)")
-        Text(text)
+        Button(text, action: onTap)
    }
}
struct ContentView: View {
    @StateObject var vm = ContentViewModel()
    var body: some View {
-        Button("count up A") { vm.countA += 1 }
-        Button("count up B") { vm.countB += 1 }
-        ChildView(text: "A -> \(vm.countA)")
-        ChildView(text: "B -> \(vm.countB)")
+        ChildView(text: "A -> \(vm.countA)") { vm.countA += 1 }
+        ChildView(text: "B -> \(vm.countB)") { vm.countB += 1 }
    }
}
```

結果がこちらです
先ほどまでとは異なり、countAのみをインクリメントしているのに、両方のChildViewが再描画されています
これはおそらく、closureであるChildViewのonTapのプロパティがEquatableではないので、SwiftUIから見た場合に差分があるか判断がつかず、textの更新の有無に関わらず再描画されているようです

![onTap.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/d96d9911-0231-336d-72cf-bbf59f1229a0.gif)

onTapのプロパティがEquatableではないことが理由で、再描画されてしまっていることを確認するために、もう1つ別のパターンで挙動を見てみます
もとのコードに戻して、ChildViewのプロパティのtextをChildViewModelに入れました

```diff_swift
+class ChildViewModel: ObservableObject {
+    @Published var text: String
+    init(text: String) { self.text = text }
+}
struct ChildView: View {
-    let text: String
+    @ObservedObject var vm: ChildViewModel
    init(text: String) {
        print("ChildView.init text: \(text)")
-        self.text = text
+        self.vm = ChildViewModel(text: text)
    }
    var body: some View {
-        let _ = print("ChildView.body text: \(text)")
-        Text(text)
+        let _ = print("ChildView.body text: \(vm.text)")
+        Text(vm.text)
    }
}
struct ContentView: View {
    @StateObject var vm = ContentViewModel()
    var body: some View {
        Button("count up A") { vm.countA += 1 }
        Button("count up B") { vm.countB += 1 }
        ChildView(text: "A -> \(vm.countA)")
        ChildView(text: "B -> \(vm.countB)")
    }
}
```

実行してみると、たしかにcountAのみをインクリメントしているのに、両方のChildViewが再描画されています
これらのことからEquatableではないプロパティを持つViewは良い感じに差分更新されていなさそうなことがわかりました


![vm.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/8a93a5fc-825d-e1f0-7b1a-75a0ebf170c8.gif)

## ViewがEquatableを継承している場合

再び、ChildViewでタップを検出してインクリメントする実装に戻り、今回はChildViewにEquatableを継承して、textでチェックするようにしました

```diff_swift
-struct ChildView: View {
+struct ChildView: View, Equatable {
+    static func == (lhs: ChildView, rhs: ChildView) -> Bool {
+        lhs.text == rhs.text
+    }
    let text: String
    let onTap: () -> Void
    init(text: String, onTap: @escaping () -> Void) {
        print("ChildView.init text: \(text)")
        self.text = text
        self.onTap = onTap
    }
    var body: some View {
        let _ = print("ChildView.body text: \(text)")
        Button(text, action: onTap)
    }
}
struct ContentView: View {
    @StateObject var vm = ContentViewModel()
    var body: some View {
        ChildView(text: "A -> \(vm.countA)") { vm.countA += 1 }
        ChildView(text: "B -> \(vm.countB)") { vm.countB += 1 }
    }
}
```

実行してみると、タップしたChildViewのみが再描画されていることがわかります
実装的にViewの更新が不要であることが既知である場合、手動でEquatableを継承することで冗長な更新を防ぐことができるようです
SwiftUIのFrameworkには、[EquatableView](https://developer.apple.com/documentation/swiftui/equatableview)というViewや[equatable modifier](https://developer.apple.com/documentation/swiftui/view/equatable())が用意されており、これらを使って明示的にEquatableであることを表現できます
また、プロパティがtextのみの場合の挙動を見るに、ViewがEquatableを明示的に継承していなくても、プロパティがEquatableな場合は、SwiftUI側で暗黙的にEquatableと同じ挙動にしている？ようです

![eq.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/94955c0e-364f-7791-1949-709c545982c4.gif)

# EquatableなViewでパフォーマンスを改善する
これまでの2つのシンプルなChildViewでは、パフォーマンスの問題は発生しにくいですが、ChildViewが大量にある場合、例えばListでどうなるか見てみます
100個のChildViewを表示し、それぞれのChildViewがタップされたら該当のcountをインクリメントするリストを作成してみました

```swift
struct Count {
    let id: Int
    var count: Int = 0
}
class ContentViewModel: ObservableObject {
    @Published var counts: [Count] = (0..<100).map { .init(id: $0) }
}
struct ChildView: View {
    let text: String
    let onTap: () -> Void
    init(text: String, onTap: @escaping () -> Void) {
        self.text = text
        self.onTap = onTap
    }
    var body: some View {
        let _ = print("ChildView.body text: \(text)")
        Button(text, action: onTap)
    }
}
struct ContentView: View {
    @StateObject var vm = ContentViewModel()
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(vm.counts, id: \.id) { count in
                    ChildView(text: "\(count.id) -> \(count.count)") { vm.counts[count.id].count += 1 }
                }
            }
        }
    }
}
```

実行結果がこちらです
31番のChildViewのみ更新すれば良いはずがロードされているChildView全てが再描画されているようです

![list.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/4fa8b956-9676-be8e-3620-4c0f8a2e4860.gif)

そこで、ChildViewにEquatableを継承し、textで差分を評価するようにします

```diff_swift
-struct ChildView: View {
+struct ChildView: View, Equatable {
+    static func == (lhs: ChildView, rhs: ChildView) -> Bool {
+        return lhs.text == rhs.text
+    }
    let text: String
    let onTap: () -> Void
    init(text: String, onTap: @escaping () -> Void) {
        self.text = text
        self.onTap = onTap
    }
    var body: some View {
        let _ = print("ChildView.body text: \(text)")
        Button(text, action: onTap)
    }
}
```

実行してみると、押下したChildViewのみが再描画されるようになっていることがわかります

![list-eq.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/721c3f86-1f40-f337-d4e3-eef26c9f23cd.gif)

# (余談) LazyVStackとButton
上のリストの例でEquatableを継承していない場合のコードで、ChildViewを100万個にして、下部にスクロールすると大量にメモリを確保し出すという現象に会いました
これは、Equatableを継承したり、ButtonをZStackでラップすると、大量にメモリを確保されなくなりました
Equatableの方はわかるのですが、ZStackでラップすると動作が改善する理由がわからず悩んでいます

gist -> https://gist.github.com/fuziki/aa69a6b2553e730102b28553c9ede5c1

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/18bad30a-4df4-75c7-daf9-21bc6906b36e.png)

# まとめ
EquatableなView(EquatableView)の挙動を色々と見てみました
普段はEquatableなプロパティのみであればSwiftUIが良い感じにしてくれますし、小さい画面では気にするする必要はなさそうです
しかし、無限スクロールがあるなど大量のコンテンツを表示する可能性がある画面では、Equatableを継承して明示的にViewの更新の必要性を通知し、最適な描画となるように工夫してあげると良さそうです