LoginSignup
12
14

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

Last updated at Posted at 2024-06-05

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

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 を利用している ChildViewContentViewbody の中に含まれており、さらにこの ChildView には縦座標を取得して、親ビューの ContentView のプロパティーに書き込む Modifier が適用されています。

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

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

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

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

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

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

12
14
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
12
14