例えばビューの描画に必要な状態などを計算するために、純粋関数の塊のヘルパー型を作ったとします:
struct ViewLayoutHelper {
func somePosition(accordingTo aState: CGFloat) -> CGPoint {
// ...
}
// ...
}
さて、このヘルパーを View の中でインスタンス化する必要がありますね。どのようにインスタンス化すればいいでしょうか?
UIKit に慣れた方、もしくは SwiftUI.View の描画仕組みを理解していない方なら、「何度もインスタンス化したくないから、Stored Propertyで作った方がパフォーマンスがいい」と考えるではないでしょうか?
struct MyView: View {
let helper = ViewLayoutHelper()
var body: some View {
//...
}
}
衝撃な事実かもですが、実は大抵の場合逆なんです。MyView の一度の描画で何度もこの Helper を呼び出さなくてはならない場合を除き、むしろ Computed Property の方がパフォーマンスいいです。
struct MyView: View {
var helper: ViewLayoutHelper {
.init()
}
var body: some View {
//...
}
}
理由は SwiftUI.View の描画仕組みを理解すれば簡単です:SwiftUI.View はビューそのものではなく、描画するためのレシピ、すなわち手順書でしかないです。だから SwiftUI.View インスタンス自体は必要に応じて何度も生成されては破棄されます、その時必ずしも描画する必要があるとは限らないのです。
例えばこんな簡単なアプリを作ってみましょう:
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` に代入するだけのものです、詳しくは下記のコードブロックをご参照ください
}
}
}
}
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))
}
}
さて、このアプリを立ち上げて、コンソール出力を見てみましょう、今のところこれしか出力がないはずです:
stored
そう、ChildView がまだ描画されていないにも関わらず、すでに storedHelper が作られています。理由は単純です、この段階ですでに ContentView が描画され、この描画レシピに ChildView が含まれているから、ChildView をインスタンス化したのです。でもまだ ChildView 自身の描画がされていないので、computedHelper はまだ作られていないです。
「でもどうせスクロールしたら ChildView も描画されるから、今のうちに作っといて損はないじゃん?」と思うかもしれません。では、いざ最後 ChildView が表示されるまですごい勢いでスクロールしてみましょう、結果こうなるはずです:
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 で作りましょう ![]()
2025年3月11日更新
@Environment で取得する場合はどうだろうと気になって、試してみました:
extension EnvironmentValues {
@Entry var storedHelper: Helper = .init(by: "Environment Stored")
var computedHelper: Helper {
.init(by: "Environment Computed")
}
}
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")
}
}
そして同じように起動してスクロールしてみました:
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) の活用法についてはこんな記事も書きましたので、よろしければぜひご参考に: