例えばビューの描画に必要な状態などを計算するために、純粋関数の塊のヘルパー型を作ったとします:
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 で作りましょう