SwiftUIでEquatableなView(EquatableView)で冗長な更新を防ぐ
SwiftUIを使うと良い感じにUIを作ることができます
しかし、特に大規模なアプリを作成していると、たまにパフォーマンスの問題に当たることがあります
SwiftUIでは、Equatableを継承しているViewは、Equatableの評価結果が等価である場合は、Viewを更新しないという挙動になります
この仕組みを使うと、冗長な更新を防ぎ、パフォーマンスを向上させることができそうです
そこで、EquatableなView(EquatableView)の挙動を確認しつつ、効果的な使い方を考えてみました
※ Xcode14/iOS16で検証しています
EquatableなViewの挙動の確認
まずは、EquatableなViewと、EquatableではないViewとの挙動の違いを確認します
ベースとして、下記のような2個の数字をインクリメントするViewを作りました
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が良い感じに差分更新してくれています
良い感じの差分更新がなくなるパターン
少しChildViewとContentViewを変えて、ChildViewでタップを検出して、ContentViewModelのプロパティをインクリメントするようにしてみました
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のプロパティがEquatableではないことが理由で、再描画されてしまっていることを確認するために、もう1つ別のパターンで挙動を見てみます
もとのコードに戻して、ChildViewのプロパティのtextをChildViewModelに入れました
+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は良い感じに差分更新されていなさそうなことがわかりました
ViewがEquatableを継承している場合
再び、ChildViewでタップを検出してインクリメントする実装に戻り、今回はChildViewにEquatableを継承して、textでチェックするようにしました
-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というViewやequatable modifierが用意されており、これらを使って明示的にEquatableであることを表現できます
また、プロパティがtextのみの場合の挙動を見るに、ViewがEquatableを明示的に継承していなくても、プロパティがEquatableな場合は、SwiftUI側で暗黙的にEquatableと同じ挙動にしている?ようです
EquatableなViewでパフォーマンスを改善する
これまでの2つのシンプルなChildViewでは、パフォーマンスの問題は発生しにくいですが、ChildViewが大量にある場合、例えばListでどうなるか見てみます
100個のChildViewを表示し、それぞれのChildViewがタップされたら該当のcountをインクリメントするリストを作成してみました
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全てが再描画されているようです
そこで、ChildViewにEquatableを継承し、textで差分を評価するようにします
-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のみが再描画されるようになっていることがわかります
(余談) LazyVStackとButton
上のリストの例でEquatableを継承していない場合のコードで、ChildViewを100万個にして、下部にスクロールすると大量にメモリを確保し出すという現象に会いました
これは、Equatableを継承したり、ButtonをZStackでラップすると、大量にメモリを確保されなくなりました
Equatableの方はわかるのですが、ZStackでラップすると動作が改善する理由がわからず悩んでいます
gist -> https://gist.github.com/fuziki/aa69a6b2553e730102b28553c9ede5c1
まとめ
EquatableなView(EquatableView)の挙動を色々と見てみました
普段はEquatableなプロパティのみであればSwiftUIが良い感じにしてくれますし、小さい画面では気にするする必要はなさそうです
しかし、無限スクロールがあるなど大量のコンテンツを表示する可能性がある画面では、Equatableを継承して明示的にViewの更新の必要性を通知し、最適な描画となるように工夫してあげると良さそうです