【TIPS】SwiftUI.View はパフォーマンス考えるならむしろ Stored Property は作ってはいけない

例えばビューの描画に必要な状態などを計算するために、純粋関数の塊のヘルパー型を作ったとします：

```swift
struct ViewLayoutHelper {
    func somePosition(accordingTo aState: CGFloat) -> CGPoint {
        // ...
    }
    // ...
}
```

さて、このヘルパーを `View` の中でインスタンス化する必要がありますね。どのようにインスタンス化すればいいでしょうか？

UIKit に慣れた方、もしくは `SwiftUI.View` の描画仕組みを理解していない方なら、「何度もインスタンス化したくないから、Stored Propertyで作った方がパフォーマンスがいい」と考えるではないでしょうか？

```swift
struct MyView: View {
    let helper = ViewLayoutHelper()
    var body: some View {
        //...
    }
}
```

衝撃な事実かもですが、実は大抵の場合逆なんです。`MyView` の一度の描画で何度もこの `Helper` を呼び出さなくてはならない場合を除き、むしろ Computed Property の方がパフォーマンスいいです。

```swift
struct MyView: View {
    var helper: ViewLayoutHelper {
        .init()
    }
    var body: some View {
        //...
    }
}
```

理由は `SwiftUI.View` の描画仕組みを理解すれば簡単です：`SwiftUI.View` はビューそのものではなく、描画するためのレシピ、すなわち手順書でしかないです。だから `SwiftUI.View` インスタンス自体は必要に応じて何度も生成されては破棄されます、その時必ずしも描画する必要があるとは限らないのです。

例えばこんな簡単なアプリを作ってみましょう：

```swift
import SwiftUI

struct Helper {
    init(by initializer: String) {
        print(initializer)
    }
}

struct ChildView: View {
    let storedHelper: Helper = Helper(by: "stored")
    var computedHelper: Helper {
        Helper(by: "computed")
    }

    var body: some View {
        let helper = computedHelper
        Text("Some View")
    }
}

struct ContentView: View {
    @State private var y: CGFloat = 0
    var body: some View {
        ScrollView {
            LazyVStack {
                Text("\(y)")
                ForEach(0..<100) {
                    Text("\($0)")
                }
                ChildView()
                    .reading(\.maxY, in: .global) { y = $0 } // 簡単にいうと `ChildView` の縦座標を取得して `y` に代入するだけのものです、詳しくは下記のコードブロックをご参照ください
            }
        }
    }
}
```

```swift
struct PositionReader<T: Equatable>: ViewModifier {
    var coordinateSpace: SwiftUI.CoordinateSpace
    var position: KeyPath<CGRect, T>
    var didReadAction: (T) -> Void

    private func positionValue(from geometry: GeometryProxy) -> T {
        return geometry.frame(in: coordinateSpace)[keyPath: position]
    }

    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .onChange(of: positionValue(from: geometry), initial: true) { _, newValue in
                            didReadAction(newValue)
                        }
                }
            )
    }
}

extension View {
    func reading<T: Equatable>(
        _ keyPath: KeyPath<CGRect, T>,
        in coordinateSpace: SwiftUI.CoordinateSpace,
        _ action: @escaping (T) -> Void
    ) -> some View {
        self.modifier(PositionReader(coordinateSpace: coordinateSpace, position: keyPath, didReadAction: action))
    }
}
```

さて、このアプリを立ち上げて、コンソール出力を見てみましょう、今のところこれしか出力がないはずです：

```console
stored
```

そう、`ChildView` がまだ描画されていないにも関わらず、すでに `storedHelper` が作られています。理由は単純です、この段階ですでに `ContentView` が描画され、この描画レシピに `ChildView` が含まれているから、`ChildView` をインスタンス化したのです。でもまだ `ChildView` 自身の描画がされていないので、`computedHelper` はまだ作られていないです。

「でもどうせスクロールしたら `ChildView` も描画されるから、今のうちに作っといて損はないじゃん？」と思うかもしれません。では、いざ最後 `ChildView` が表示されるまですごい勢いでスクロールしてみましょう、結果こうなるはずです：

```console
stored
computed
stored
stored
stored
stored
stored
stored
//...
```

なぜか、`stored` がめっちゃ出力され、つまり `storedHelper` がめちゃくちゃの回数で作られていますね、`computedHelper` は1回しか作られていないのに。

そうなんです。なぜこうなるのでしょうか、これは `SwiftUI.View` の描画仕組みをまず理解しないといけないですね。

今回のコードの場合、Helper を利用している `ChildView` は `ContentView` の `body` の中に含まれており、さらにこの `ChildView` には縦座標を取得して、親ビューの `ContentView` のプロパティーに書き込む Modifier が適用されています。

するとどうなるのかというと、まずアプリ立ち上げる時、`ContentView` が描画されるため、`body` 内の `ChildView` 自身がインスタンス化されますが、この段階では `ChildView` はまだ表示されていないので、とりあえず生成時の `storedHelper` だけ先に作られます。

次にすごい勢いで `ContentView` をスクロールすると、`ChildView` が初回表示される瞬間、ようやく `ChildView` の `body` が呼ばれるので、初めて `computedProperty` が呼ばれます。そしてこの `ChildView` に紐づいた `.reading` Modifier が働いて、自分自身の縦座標を親ビューの `ContentView` のプロパティーに書き込みます。

ところがその状態変化は親ビューの `ContentView` の描画トリガーにもなってしまうため、`ContentView` の `body` は再び呼ばれ、再度 `ChildView` のインスタンスが作成されます。だからインスタンス時に呼ばれる　`storedHelper` も作られます。しかし `ChildView` 自身の内容は特に変わったわけではないので、残念（ではないが）ながら `ChildView` の `body` は呼ばれず、だから `computedHelper` も作られないです。

そして忘れてはならないのは先ほど私は「すごい勢いで」と言いました、これはどういうことかというと、スクロールビューはすごいスピードで動いているため、次の瞬間また `ChildView` の縦座標が変わるんです。だからまた同じ理屈で `storedHelper` がまた作られます。その次の次の、さらに次の次の次の瞬間も同じです。だから `storedHelper` がすごい勢いで作られちゃうんです。

ちなみに余談ですが、今回のような純粋関数の塊の Helper の場合、`@State private var stateHelper = //...` で作るのも考えられる手の一つですが、個人的にはあまりお勧めはしません。なぜなら、まず `struct` の生成コストが非常に低いから、それを保持する `ChildView` 自身が何度も再描画されるような場面でない限り、わざわざ生成コストの高い `@State` で作る必要性があまりないですし、Helper 自身に状態がないから SwiftUI にライフサイクル管理される必要性も全くないからです。

というわけで、今度 SwiftUI.View でこのような Helper を作る時があったら、安心して Computed Property で作りましょう :muscle: 

---

## 2025年3月11日更新

`@Environment` で取得する場合はどうだろうと気になって、試してみました：

```swift
extension EnvironmentValues {
    @Entry var storedHelper: Helper = .init(by: "Environment Stored")
    var computedHelper: Helper {
        .init(by: "Environment Computed")
    }
}
```

```swift
struct ChildView: View {
    let storedHelper: Helper = Helper(by: "Stored")
    var computedHelper: Helper {
        Helper(by: "Computed")
    }
    @Environment(\.storedHelper) var environmentStoredHelper
    @Environment(\.computedHelper) var environmentComputedHelper

    var body: some View {
        let _ = storedHelper
        let _ = computedHelper
        let _ = environmentStoredHelper
        let _ = environmentComputedHelper
        Text("Some View")
    }
}
```

そして同じように起動してスクロールしてみました：

```console
Stored
Environment Stored
Environment Computed
Computed
Environment Stored
Environment Computed
Stored
Environment Stored
Environment Computed
Stored
Stored
Stored
Stored
Stored
Stored
Stored
Stored
// ...
```

見ての通り、純粋なComputed Propertyとはちょっと違い、Environmentは `@Entry` 使うものもそうでないものも、最初だけなぜか2回くらい無駄な生成はありました（おそらく `EnvironmentValues` の設計が原因かと）が、それ以降は特に無駄な生成はありませんでした。なのでいろんな画面に使ってもらいたいものなら、このように `@Environment(\.keyPath)` のような作り方もアリかもです。`@Environment(\.keyPath)` の活用法についてはこんな記事も書きましたので、よろしければぜひご参考に：

[@Environment(\\.keyPath)実践入門](https://qiita.com/lovee/items/0893d3ed7813e66d8188)