29
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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が良い感じに差分更新してくれています

defo.gif

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

少し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.gif

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は良い感じに差分更新されていなさそうなことがわかりました

vm.gif

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と同じ挙動にしている?ようです

eq.gif

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全てが再描画されているようです

list.gif

そこで、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のみが再描画されるようになっていることがわかります

list-eq.gif

(余談) LazyVStackとButton

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

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

image.png

まとめ

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

29
17
0

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
29
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?