😊

SwiftUIのレンダリングループについて

2023/09/19に公開

はじめに

レンダリングループとはアプリが起動している間常に起動している処理です。
例えば、タッチイベントはOSからアプリに伝えられ、アプリはUIの変更をOSに伝え、OSがフレームを最終的にレンダリングして私たちの目に見える変化となって現れます。

SwiftUIはこの内部実装がよく隠されていて、そうしたレンダリングループのことを一切考えずに次のようにViewを構築できます。

struct TestView: View {
    var body: some View {
        VStack {
	    Image(systemName: "face.smiling")
            Text("Hello World!")
        }
    }
}

このようにレンダリングに関する意識を関心から外すことで、私たちはViewの構築に集中することができます。一方で完全にレンダリングを意識の外に追いやると、コミットヒッチやレンダーヒッチなどユーザー体験を下げる遅延が生じかねません。レンダリングループを理解することは、実際に遅延が発生した場合はもちろん、発生を未然に防ぐことにも役立ちます。

レンダリングループについてはすでに多くの記事が出ていますが、rens breur氏のブログポストが一番詳細に書かれています。

https://rensbr.eu/blog/swiftui-render-loop/

そこで本稿では、上記リンクに従ってCFRunLoopから始まるアプリ全体の実行ループから、CoreAnimation、SwiftUIの関連性を示し、内容を補足しつつレンダリングループの全体像を明らかにします。

実行ループ

ハードウェアで検知されたイベントはOSを経由してアプリに伝えられます。OSはタッチイベントがあればアプリに伝えて画面を必要に応じて更新させ、端末が回転すればそれをアプリに伝えて必要な処理をさせます。

従って、OSから伝えられるこうしたイベントをアプリが起動してから待機しつつ、検知したらそれをアプリに伝える仕組みが必要になります。

CFRunLoopについて

アプリが起動するとシステムによってメインスレッド内で実行ループが生成されタッチ操作などのイベントを検知してスレッドに伝えます。この実行ループの中心はCFRunLoopであり、CFRunLoopはスレッドによって生成されると、イベントの受信 -> 待機 -> 処理のループロジックを実行します。

実行ループ自体の役割は単純で

  1. 必要ない時はスレッドを待機させる
  2. イベントが来たらスレッドをビジーにしオブザーバーに通知する

の2つの仕事をしています。CFRunLoopはこれより高度な仕事をしていますが、基本的な仕事は変わりません。

CFRunLoopの存在を実際に確かめることができます。
一番最初に示したサンプルアプリにonApperを追加し、そこでブレークを貼ります。

スタックトレースを確認するとここにCFRunLoopがいることがわかります。
bodyの評価の際にブレークを置いても、onTapGestureで確認しても同じです。メインスレッド内にいるこのCFRunLoopからイベントが来ているのがわかります。

CFRunLoopのイベントソース

CFRunLoopは上に見たように、イベントを受信 -> 処理 -> 待機します。
このときCFRunLoopに対してイベントを発信する入力ソースは全部で4種類です。

Source 概要
Machポートソース ハードウェアから送られた情報をカーネルのMachポートを通じてCFRunLoopに伝えます。 Machポートを入力ソースとして送られるイベントには、タッチイベント(厳密には違う)やCADisplayLinkがあります。
カスタム入力ソース カスタムソースは、CFRunLoopに対して独自のインプットソースを定義してイベントを送ります。カスタムソースを入力ソースとする場合は、別スレッドから送る必要があります。
タイマーソース これは、そのままTimerによるイベント送信です。これもCFRunLoopによって処理されます。
メインディスパッチキューソース メインディスパッチキューにディスパッチされたコード、およびメインディスパッチキューに関連付けられたディスパッチソースも入力ソースを形成します。

それぞれの入力ソースからの呼び出しは、スタックトレースから呼び出し先を確認できます。これらの大文字関数名はスタックトレースの比較的上流で見つけられるはずです。

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();

タッチイベント

試しに一番身近なタッチイベントを確認してみます。タッチイベントはMachポートソースとカスタムソースを組み合わせてイベントがRunLoopに送られます。アプリを起動するとメインスレッドとは別にcom.apple.uikit.eventfetch-threadというスレッドが生成します

タッチイベントが発生する処理で適当にブレークを置くと、com.apple.uikit.eventfetch-threadに対してMachポートからイベントが送られてきているのが分かります。

内部でUIkitフレームワークがなんらかの処理を入れているのかは不明ですが、メインスレッドのCFRunLoopに対してカスタムソースとしてタッチイベントを送っています。

オブザーバー

RunLoopのライフサイクル全体像は次のようになっています。


https://suelan.github.io/2021/02/13/20210213-dive-into-runloop-ios

CFRunLoopでは入力ソースの追加とは別にCFRunLoopがこのサイクルの特定の処理に達した時に通知を受けることができるオブザーバーを追加することができます。

このオブザーバーには例えばCoreAnimationがあり、beforeWatingのタイミングでCFRunLoopから通知が来るようになっています。

オブザーバーはCFRunLoopObserverCreateWithHandlerなどで生成して自分で設定することもできます。CFRunLoopの挙動をデバッグするのに便利なのでアプリの起動時からactivityに従ってログを出力してくれるオブザーバーを追加してみます。

AppDelegateを準備しobserverを追加する処理を入れます。

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        addRunLoopObserver() // 🟢 observerの追加
        return true
    }
}

CFRunLoopObserverCreateWithHandlerは次のようなインターフェースになっており

public func CFRunLoopObserverCreateWithHandler(_ allocator: CFAllocator!, _ activities: CFOptionFlags, _ repeats: Bool, _ order: CFIndex, _ block: ((CFRunLoopObserver?, CFRunLoopActivity) -> Void)!) -> CFRunLoopObserver!

このようにCFRunLoopActivityに従った処理を挟むことができます。

    let observer = CFRunLoopObserverCreateWithHandler(
        kCFAllocatorDefault,
        CFRunLoopActivity.allActivities.rawValue,
        true,
        .zero
    ) { _, activity in
        switch activity {
        case .entry:
            print("CFRunLoopActivity.entry")
	    
	case .beforeTimers:
            print("CFRunLoopActivity.beforeTimers")
	    
        case .beforeSources:
            print("CFRunLoopActivity.beforeSources")
	    
        case .beforeWaiting:
            print("CFRunLoopActivity.beforeWaiting")
	    
	case .afterWaiting:
            print("CFRunLoopActivity.afterWaiting")
	    
        case .exit:
            print("CFRunLoopActivity.exit")
	    
        default:
            break
        }
    }
    CFRunLoopAddObserver(RunLoop.current.getCFRunLoop(), observer, .defaultMode)

これで、RunLoopの特定のタイミングになったらログを出力してくれるようになります。

Core Animation

先の記述で少し触れましたがCore AnimationはCFRunLoopのオブザーバーです。

RunLoop.current.debugDesciptionをアプリの起動中print出力すると、RunLoopに登録されたobserverを確認することができ、_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPvという見慣れないシンボルがありますが、これがCore Animationのオブザーバーです。

...
    "<CFRunLoopObserver 0x600003300820 [0x1b9b327e8]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2000000, callout = _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv (0x188423f98), context = <CFRunLoopObserver context 0x0>}",
...

UIViewまたはCALayerで見た目の変更があったビューオブジェクトは都度都度処理されているのではなく、CFRunLoopのbeforeWatingのタイミングで通知を受けるとCATransactionにまとめられた分がcommitされバッチ処理されます。

commitされると、更新が必要なViewはlayoutSubViews、drawRectなどの処理が行われ最終的にレンダーサーバーによって実際の描画が行われディスプレイに反映されます。

SwiftUI

これまで確認したレンダーループの流れがSwiftUIでもそうなのか確認してみましょう。
デバッグのためカスタムオブザーバーもRunLoopに設定しておきます。

ついで、View上で行われた変更が実際にCore Animationを通るのか確認したいですが、ここにはオブザーバーなどを追加する機構がありません。そこで、シンボリックブレークポイントを設置してそこからログを出力するようにします。

シンボルの探索にはlldbのimage listを使用します。

https://lldb.llvm.org/use/symbolication.html

シンボルを確認するため、アプリを適当な位置でブレークさせたらlldb上で

image list

として、シンボルを確認したいフレームワークを探索します。
今回の場合、CoreAnimationはQuartsCore内にあるので QuartzCore.frameworkのパスを探します。

コマンドプロンプトに移動して、目的のフレームワークまで移動したらnmコマンドでシンボルを一覧にします。

nm QuartzCore

目的とする関数名は今回の場合、CATransactionのcommitとわかっているので

nm QuartzCore | grep commit_transaction

として、シンボルを確認できたらXCodeのシンボリックブレークポイントを利用します。

一々ブレークポイントで止まってしまうと(頻繁に呼ばれて)困るので、ログを出すようにします。Automaticaly Continue after evaluating actinonのチェックをオンにしておけば止まらずにログ出力だけしてくれます。

これらの準備が整ったら、次のようにしてカウントアップとViewの更新を確認してきます。

struct TestView: View {
    @State var count: Int = .zero
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            Image(systemName: "face.smiling")
            Text("Hello World \(count)")
                .onTapGesture {
                    print("before")
                    count += 1
                    print("after")
                }
        }
    }
}

これを確認すると次のようになります。

SwiftUIの挙動と実行ループのタイミング、Core Animationのコミットタイミングが確認できるようになりました。また、必要に応じてcommit_transactionのタイミングやオブザーバーのswitchでブレークすることで挙動を逐次確認できます。

今このようなViewでボタンを押下すると、

struct TestView: View {
    @State var count: Int = .zero
    
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            Image(systemName: "face.smiling")
                Text("Hello World \(count)")
                    .onTapGesture {
                        print("onTapGesture before") // 🟥 Tapped!
                        count += 1
                        print("onTapGesture after")
                    }  
        }
    }
}

値の変更が行われますがまだ反映されません。

...
CFRunLoopActivity.beforeSources
CFRunLoopActivity.beforeWaiting
CFRunLoopActivity.afterWaiting
CFRunLoopActivity.beforeTimers
CFRunLoopActivity.beforeSources
onTapGesture before
onTapGesture after

bodyが評価されますが

	...
        VStack {
            let _ = Self._printChanges() // 🟥 evaluate body
	...

まだ画面は切り替わりません。

...
onTapGesture before
onTapGesture after
CFRunLoopActivity.beforeTimers
CFRunLoopActivity.beforeSources
CFRunLoopActivity.beforeWaiting
TestView: _count changed.

先ほど貼ったシンボリックブレークポイントを通過するとようやくレンダリング(レンダーサーバーに送られてディスプレイに反映)されます。

...
TestView: _count changed.
commit_transaction // 🟢 Render!

暗黙的TransactionはCFRunLoopActivity.beforeWaitingにスケジュールされています。

もう一つ状態変数を増やして、onChangeの中でそれを変化させるようにすると

struct TestView: View {
    @State var count: Int = .zero
    @State var showText: Bool = false
    
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            Image(systemName: "face.smiling")
                Text("Hello World \(count)")
                    .onTapGesture {
                        print("onTapGesture before")
                        count += 1
                        print("onTapGesture after")
                    }
            Text(showText ? "count is multiple of 3" : "")
        }
        .onChange(of: count) { _, _ in
            showText = count.isMultiple(of: 3)
        }
    }
}

bodyを評価した後で実行ループに処理を返すのではなく、onChange(onAppearなどもそう)を呼び出します。そこで値の変更がさらにあるとbodyを再度評価してからcommitします。

...
onTapGesture before
onTapGesture after
CFRunLoopActivity.beforeTimers
CFRunLoopActivity.beforeSources
CFRunLoopActivity.beforeWaiting
TestView: _count changed.
TestView: _showText changed.
commit_transaction

値の変更が他の値の変更につながるといった場合、bodyの再評価は続くためその分画面への反映が遅くなります。

まとめ

さて、ここまでのことをまとめるとレンダーループの構成は次のようにまとめられます。


https://rensbr.eu/blog/swiftui-render-loop

UIKit上でbackgroundを変更したら暗黙的TransactionによりCore Animationがアニメーションをしてくれていたのと同じで、UIKitから元々存在していた仕組みをSwiftUIは利用しているようです。

SwiftUI、RunLoop、CoreAnimationの関係性がわかったことで、これまで追跡不可能なだった状態変更とレンダリングの間の問題をさらに詳細に見ることができるようになりました。Instrumentsと併用して問題をブレークしたりログ出力することでデバッグに役立てることができます。

参考資料

https://rensbr.eu/blog/swiftui-render-loop/

https://suelan.github.io/2021/02/13/20210213-dive-into-runloop-ios/

https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/Mach/Mach.html

Discussion