SwiftUIのSubViewは画面更新ごとに生成と破壊を繰り返す


この記事の目的

SwiftUIのSubViewはその親Viewを更新されるタイミングで繰り返し生成と破棄されます。画面を更新されるたびにstructであるSubViewは破棄されて作り変えられているわけです。これを数字で理解するのがこの記事の目的です。

SubViewは描画が必要なタイミングで生まれ変わっている、というのを言葉ではなく数字で分かりたいわけです。

(SubViewでない親Viewは繰り返し生成されなくても画面更新されます。これは例えばUIHostingController(rootView:)で指定したSwiftUI.Viewなどです)

具体的な前提

前提を説明すると、とあるContentViewが@ObservedObjectを持つ場合にその@Publishedなプロパティが更新された場合、ContentViewは更新のためにvar body: some View {}プロパティが呼び出され、そこに記述されているSubViewはその都度生成されているわけです(もちろんこれは@Stateもしくは@Bindingが更新された場合にも同じです)。

上記SwiftUIのレンダリングシステムについてはBOOTHで電子書籍として販売している内容にその情報源を詳しく書いています。

SwiftUIガイドブック - レンダリングシステムの考察とデータの使い分け
https://booth.pm/ja/items/1829015

ここまでが前提の確認です。

記事の目的のための本題

このSwiftUIのSubViewの更新はどこまでのスピードまで耐えられるのかというのが本題です。例えば@Publishedなプロパティの更新時間が例えば0.0001秒間隔ならその間隔に併せてViewも更新するのでしょうか。しないでしょ。無駄です。なぜならそんな間隔で画面を更新されても目で見て分からないからです。

この予想としては60fpsもしくは120fpsだろうと考えます。この数字は2019年までに発売されたiPhoneが60fpsで、iPad Proが120fpsだからです。

60fpsの場合、つまり1秒間あたり60回画面を更新しているなら、1回の画面更新秒数は 1 / 60 で 0.001666 ... 秒となります。そのためこれを検証するのに 0.001666 ... の更新タイミングで@ObservedObject@Publishedプロパティを書き換え、1秒間に60回SubViewがinitメソッドを呼んでいるということが分かれば、つまり60fpsで画面更新できるということが確認できます。

  • 0.001666 ... 秒ごとに@ObservedObject@Publishedプロパティを書き換える
    • SubViewが更新される
      • SubViewのinit時の回数を数える
        • 1秒後 にinit回数が60であればインターバル0.001666 ... 秒でViewは作り変えられている

もちろん1秒後よりも10秒後にするほうが精度的には良いと思います。が、精度はあんまり求めていないので今回は1秒にしています。

さらにインターバルを小さくしていき、1秒間に最大何回initが実行されているか分かれば面白いところです。

実験

まずはPlaygroundで試す

  • Xcdoe 11.4 beta
  • Playground

  • SubViewを青い背景としてTextでinitの回数を表示
    • init回数は何度かやると56か57になる
import SwiftUI
import UIKit
import PlaygroundSupport

// SubViewがinitされるたびにそのタイミングを保持
var dates = [Date]()

struct ContentView: View {
    @ObservedObject var myTimer = MyTimer()

    var body: some View {
        VStack(alignment: .center)  {
            Text("interval: \(myTimer.interval.description)")
            Text("limit: \(myTimer.limit)")

            SubView(myTimer: self.myTimer)
                .background(Color.blue)
                .foregroundColor(Color.white)
        }
    }
}

struct SubView: View {
    @ObservedObject var myTimer: MyTimer
    init(myTimer: MyTimer) {
        self.myTimer = myTimer
        dates.append(Date())
    }

    var body: some View {
        VStack {
            Text("SubView: init count \(dates.count)")
        }
    }
}

class MyTimer: ObservableObject {
    // これを更新するとViewがreloadされる
    @Published var text: String = ""
    // タイマーを更新する間隔
    let interval: TimeInterval = (1 / 60) // 0.0166...
    // タイマーを止めるlimit時間
    let limit = 1.0

    private lazy var timer: Timer = {
        Timer.scheduledTimer(
            withTimeInterval: self.interval,
            repeats: true
        ) { timer in
            self.text = timer.fireDate.description
        }
    }()

    init() {
        timer.fire()

        Timer.scheduledTimer(
            withTimeInterval: limit,
            repeats: false
        ) { _ in
            self.timer.invalidate()
        }
    }
}

let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController

Playgroundのシミュレータ上では1/60の0.001666 ... 秒のインターバルにすると56か57あたりになります。60近ければ想定通りなのでまあそんなところでしょう。

ちなみに1秒間あたりのinit上限を探っていったところ、インターバルを240(0.0041秒間隔)より細かくすることはできませんでした。OS的にはゲーミングディスプレイの240fpsを想定しているのでしょう。Macbookのディスプレイで240fpsなんて描画してるわけないですが、1秒間にSwiftUIのinitは240回までできます

実機でやってみる

PlaygroundからプロジェクトをSwiftUI用の[Single View App]に作り直し、iPhone 11Pro実機でインターバルを240(0.0041秒間隔)で試すと、だいたい115~125あたりです。

iPhone 11 Proのディスプレイは120Hzで駆動すると書かれた公式仕様がないため、60fpsがハードウェア的な上限だとは思いますが、システム的な上限は120に近い値が出せるということがわかります。

結論

やはりViewは@ObservedObject@Publishedなプロパティが更新されるとSubViewは再生と破壊を繰り返す。

そしてリミッターが存在しそうでありそれはシミュレータと端末では違いがある。

  • Playground(というかシミュレータでは)
    • 1秒間に60回近くプロパティを更新すればViewはその都度initされている
    • インターバルは240回の更新が限界
      • (もちろん画面更新が1秒間に240回やってるとは思えない)
      • 240はゲーミングディスプレイの更新周波数と同じでキリが良すぎる...
  • 実機のiPhone 11 Pro
    • インターバルは120回付近の更新が限界
      • (もちろん実機画面更新が1秒間に140回やってるとは思えない)

念の為書いておくこと

SwiftUIのレンダリングシステムが描画をサボっているかどうかはわからない

Viewが0.001666 ... 秒ごとに作り変えられていたとしても、本当に画面が更新されているのかSwiftUIのレンダリングシステムによってスキップされているかはわかりません。わかるのはinitの回数だけです。initが動作するとbodyが更新されますが、処理的に重かったりすると次のinitタイミングが動作してしまい前のbody更新する前に次の処理が入る、とかもあるでしょう。今回はそんなに重い処理ではないのでそうならないとは思いますが。

おそらく基本的にはOSの描画タイミングに合わせて画面を更新しており、その時間までにsetNeedsDisplayのようにフラグを立てられているものを回収して描画しているのだと思います。

本当の描画回数が分かればもっと興味深い結果になるかもしれません...