SwiftUI Previewの仕組み

·
Cover for SwiftUI Previewの仕組み

SwiftUIを愛している😍なら、おそらくSwiftUI Previewも嫌い😡なはずです。

なぜなら、SwiftUIを使用するほとんどの開発者が、このようなインターフェースに遭遇したことがあるからです:

クラッシュ以外にも、Xcode Previewはしばしば原因不明のフリーズを起こし、プレビュー効果を表示できなくなることがあります。

このような状況に遭遇した時、Previewの仕組みを理解していないため、明らかなプロジェクトのコンパイルエラーを処理する以外に、キャッシュをクリアしたりXcodeを再起動したりする方法でしか対処できないように思えます。

これらの問題の根本的な原因をよりよく理解するために、この記事ではSwiftUI Previewの仕組みを探っていきます。SwiftUI Previewの問題を完全に解消することはできませんが、少なくともエラーログを理解することで、日々の開発プロセスに何らかの洞察を提供できればと思います。

TLDR:結論から先に

  1. Xcode 16では、Previewの動作メカニズムが大きく変更されました。Xcode 16以前のPreviewの仕組みに興味がある場合は、安定したプレビュービューの構築 — SwiftUI Previewの仕組みをご覧ください。
  2. Xcode 16から、通常のBuild and RunプロセスとPreviewはビルド成果物を共有するようになりましたが、実行プロセスは異なり、PreviewはJITを使用して成果物を実行します。
  3. Previewには、ソースコードファイルの修正度合いに応じた3つの異なるレベルの再ビルド操作があります。Small、Middle、Largeという3つの操作を使って、その違いを次の表で示します:
再ビルドレベル典型的なシナリオ再ビルド範囲Previewアプリの更新方法
Smallメソッド内の文字列リテラルの修正再ビルドなし元のアプリプロセスを保持し、Previewマクロで定義されたメソッドを再実行
Middleメソッド内の他の内容の修正修正されたメソッドを含むソースコードファイルのみ再ビルド元のアプリプロセスを終了し、新しいアプリインスタンスを起動してPreviewマクロで定義されたメソッドを実行
Largeクラスや構造体のプロパティ、グローバル変数の修正プロジェクト全体の再ビルド(キャッシュ付きのbuild and runと同等)元のアプリプロセスを終了し、新しいアプリインスタンスを起動してPreviewマクロで定義されたメソッドを実行

これらの詳細について、以下のセクションで詳しく見ていきましょう。

研究方法:ビルド成果物を通じた調査

Previewの仕組みを研究するために、次のような仮定を立てましょう:Previewは動作中にXcodeのDerivedDataフォルダに痕跡を残すはずです。そこで、DerivedDataをGit管理に追加し、各操作がDerivedDataフォルダにもたらす変更を観察することができます。

研究を容易にするため、SwiftUIPreviewSampleというプロジェクトを作成し、プロジェクトのDerivedDataフォルダを.xcprojectと同じレベルに配置して簡単に確認できるようにしました。各コミットの差分を確認することで、異なる修正がDerivedDataにどのような影響を与えるかを理解することもできます。

復習:Build and Runアプリケーションはどのように起動するか?

Xcode 16から、SwiftUI Previewの動作メカニズムが変更され、最も重要な変更点は:Build and RunとPreviewが同じビルド成果物を共有するようになったことです。これにより、PreviewとBuild and Runのコンパイル成果物を再利用でき、Previewのビルド効率が向上します。

Playボタンをクリックすると、Xcodeはプロジェクト全体をビルドし、中間成果物と最終成果物は~/Library/Developer/Xcode/DerivedData/xxx/Buildの下のBuild/Intermediates.noindexBuild/Productsフォルダに格納されます。

最終的な.appでは、通常このような内容が見られます:

XXX.app
  |__ XXX
  |__ __preview.dylib
  |__ XXX.debug.dylib

Appleの公式ドキュメントによると、PreviewとBuild and Runがビルド成果物を共有できるようにするため、プロジェクトでENABLE_DEBUG_DYLIBが有効になっている場合、Xcodeは元々XXX.app/XXXにあった主要なコンテンツをXXX.debug.dylib動的ライブラリに分割し、元のバイナリファイルはトランポリンとしてのみ機能する「シェル」実行ファイルになります。

これを確認するために、完全なプロジェクトの任意のバイナリファイルを開くと、サイズだけを見ても、コードが増えるにつれてXXX.debug.dylibの動的ライブラリだけが大きくなっていることがわかります。

バイナリを起動すると、lsof -p $(pgrep -f "")コマンドを使用して、バイナリが実行中にdebug.dylib動的ライブラリを読み込んでいることも確認できます。

lsof -p $(pgrep -f SwiftUIPreviewSample)
COMMAND     PID USER   FD   TYPE DEVICE   SIZE/OFF                NODE NAME
SwiftUIPr 77422 onee  cwd    DIR   1,18        416           315720871 /Users/onee/Library/Containers/spatial.onee.SwiftUIPreviewSample/Data
SwiftUIPr 77422 onee  txt    REG   1,18      57552           316066805 /Users/onee/Code/Playground/SwiftUIPreviewSample/Build/Products/Debug/SwiftUIPreviewSample.app/Contents/MacOS/SwiftUIPreviewSample
SwiftUIPr 77422 onee  txt    REG   1,18     290816           264469085 /Applications/Xcode.app/Contents/Developer/usr/lib/libBacktraceRecording.dylib

したがって、通常のBuild and Runプロセスでは、バイナリ全体のビルドと実行フローは次のようになります:

調査:Previewアプリケーションはどのように起動するか?

Previewを有効にすると、アプリケーションのビルドプロセス全体に変化が現れ始めます。まず、ビルドプロセス中に、XcodeはPreviewマクロを使用するSwiftソースコードファイルに対して特別な.preview-thunk.swiftファイルを生成し、元のSwiftファイルを前処理します。

Note

コンピュータサイエンスにおいて、thunkは一般的に異なるコードセグメント間のインターフェースの問題を解決するための技術を指します。典型的な例として、コールバックスタイルの非同期関数をasync/awaitスタイルの関数に変換することを、thunkifyと呼びます。

例えば、ソースファイルが次のような場合:

import SwiftUI

let myText = "Hello, world!"

struct ContentView: View {
    @State var item = Item(name: "Hello!")
    @State var count = 0
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("\(item.name) Foo, Bar, Baz")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

対応する.preview-thunk.swiftファイルは次のようになります:

import func SwiftUI.__designTimeFloat
import func SwiftUI.__designTimeString
import func SwiftUI.__designTimeInteger
import func SwiftUI.__designTimeBoolean

#sourceLocation(file: "/Users/onee/Code/Playground/SwiftUIPreviewSample/SwiftUIPreviewSample/ContentView.swift", line: 1)
// 1. ソースファイルの位置をマークします
//
// SwiftUIPreviewSample
// Created by: onee on 2025/1/10
//

import SwiftUI

let myText = "Hello, world!"

struct ContentView: View {
    @State var item = Item(name: "Hello!")
    @State var count = 0
    
    var body: some View {
        VStack {
            Image(systemName: __designTimeString("#2282_0", fallback: "globe"))
            // 2. 冒頭でインポートしたプライベート関数を使用します
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("\(item.name) Foo, Bar, Baz")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Xcodeは主に2つの点で元のSwiftファイルを処理します:まず、#sourceLocationを使用してソースファイルの位置をマークし、エラー報告の最適化を行います。次に、テキストリテラルを__designTimeStringに置き換えて、テキストが変更された時の直接的な修正を容易にします。

これらの.preview-thunk.swiftファイルの生成以外に、残りのビルドプロセスは基本的に通常のBuild and Runビルドプロセスと同じです。Xcodeはプロジェクト全体を完全にビルドし、最終的に通常のBuild and Runと同じ.appファイルを生成します。また、XcodeのReport Navigatorでビルドプロセス全体の詳細情報を確認することもできます。

Warning

フレームワーク名からPreviewがJITを使用してコードを実行していることは推測できますが、この調査では具体的なJIT実行の詳細、例えばエラーログの???が表すバイナリコンテンツの正確な位置などは見つかっていません。そのため、現在のバージョンの図では、???で表されるバイナリの前にクエスチョンマークを置いています。

この点についてより深い知見をお持ちの方は、コメント欄で意見を共有するか、直接私に連絡してください。

ここまでで、Previewが最初に実行される時の様子がわかりました。では、コードを修正した時、Previewはどのように再ビルドするのでしょうか?

Previewの3層再ビルド戦略

記事の冒頭で述べたように、Previewにはアプリケーションを再ビルドするための3つの異なる戦略があります。具体的な例を使って、それぞれを説明していきましょう。

Small

最も小さな変更から始めましょう:メソッド内のリテラルの修正です。例えば、この修正では、ContentViewText内のリテラルをHello, world!からHello, world!, Foo, Bar, Bazに変更しました:

この前に、ContentViewに対応するthunk.swiftファイルでは、Textのコードは次のようになっていました:

Text(__designTimeString("#25104_1", fallback: "Hello, world!")

コミット記録から、この修正の下ではDerivedDataフォルダの内容が全く変化していないことがわかります。また、pgrep -f SwiftUIPreviewSampleのPIDを比較することで、元のSwiftUIPreviewSampleプロセスが破棄されていないことも確認できます。

さらに、@Stateを使用して変数を保存している場合、Smallレベルの修正ではこれらの変数の状態を保持できません。例えば、countを0から1に変更した場合、リテラルを修正した後、countの値は0にリセットされます。

したがって、この戦略の下では、Xcodeはおそらくソースコードからリテラルコンテンツを直接読み取り、新しいリテラルコンテンツを既存のSwiftUIPreviewSampleプロセスに更新し、さらにPreviewマクロによって作成された一連のメソッドを再実行することでビュー全体を更新していると合理的に推測できます。

Previewが再実行される時、__designTimeString()は更新された文字列リテラルの最新値を返し、それによってビュー上のテキストが更新されます。

Middle

次に、メソッド内の他の非リテラルコンテンツを修正する場合を見てみましょう。例えば、変数を補間付き文字列に変更するこの修正のような場合です:

コミット記録から、この場合、Xcodeは.preview-thunk.swiftファイルと対応する.oファイルを再生成しますが、再生成の範囲は修正されたファイルに限定され、.app下のバイナリファイルと動的ライブラリは再生成されないことがわかります。

また、pgrep -f SwiftUIPreviewSampleのPIDを通じて、元のSwiftUIPreviewSampleプロセスが終了し、Xcodeが新しいSwiftUIPreviewSampleプロセスを生成したことがわかります。

したがって、この戦略の下では、Xcodeはメソッドの内容が修正されたソースファイルのみを再コンパイルし、新しいSwiftUIPreviewSampleプロセスを実行することでビュー全体を更新すると合理的に推測できます。

Large

最後に、クラスや構造体のプロパティ、グローバル変数の修正、@Stateの追加などを行う場合を見てみましょう。例えば、この修正のような場合です:

コミット記録から、このような修正は.app下のバイナリファイルと動的ライブラリも更新することがわかります。このような修正が発生すると、XcodeのReport Navigatorでプロジェクト全体の再コンパイルログを確認できます:

同様に、pgrep -f SwiftUIPreviewSampleのPIDを通じて、元のSwiftUIPreviewSampleプロセスが終了し、Xcodeが新しいSwiftUIPreviewSampleプロセスを生成したことがわかります。

したがって、この戦略の下では、Xcodeはプロジェクト全体を再コンパイルし、新しいSwiftUIPreviewSampleプロセスを実行することでビュー全体を更新すると合理的に推測できます。

他から学ぶ:FlutterのHot Reloadとの比較

Xcode 16でPreviewにいくつかの最適化が施されましたが、FlutterのHot Reloadのような他のフレームワークのHot Reload機能と比較すると、現在のバージョンのPreviewにはまだ改善の余地があります:

  1. ブレークポイントデバッグのサポートがない。この点において、FlutterのHot Reloadはブレークポイントデバッグをサポートするだけでなく、コード修正後もブレークポイントの位置がずれないため、優れた体験を提供していると言えます。
  2. ビューの状態がリセットされる。対照的に、FlutterのHot Reloadはほとんどの場合でビューの状態を保持するため、状態が必要なUIのデバッグがより便利です。
  3. 全体的な実装がよりブラックボックス化されている。Flutterのドキュメントは、DartVMがHot Reloadのプロセスと原理をどのようにサポートしているかを詳しく説明していますが、Appleはこの点で比較的説明が不足しています。

しかし、おそらくXcode 16のPreviewメカニズムの改善は始まりに過ぎず、Xcodeの後続バージョンではPreviewにさらなる最適化が施されることを期待しましょう。

参考文献