Highlight
SwiftUI の
Viewプロトコルは標準で@MainActor隔離が付与されており、Shape.path(in:)、visualEffect、Layout、onGeometryChangeなどの API のクロージャは@Sendableとしてマークされ、SwiftUI がバックグラウンドスレッドにスケジューリングして実行します。
核心となる内容
Data race は SwiftUI の長年のユーザーにとって最もなじみ深いトラブルの種です。アニメーションのカクつき、状態の不整合、さらにはデータの欠損まで引き起こします。Swift 6 以前は、開発者が経験と注意深さを頼りに、どのコードがメインスレッドで動くのか、どれがそうでないのかを自分で判断するしかありませんでした。一度書き間違えると、バグはほぼランタイムでしか再現できないという厄介さがありました。
Daniel は「カラー抽出 app」を題材に、SwiftUI が Swift concurrency 体系のなかでどう位置づけられているかを 3 つのステーションに分けて解説していきます。第 1 ステーションは Main Actor Meadows です。View プロトコル自体に @MainActor が付いているため、開発者が書く struct、body、@State はすべて標準でメインスレッド上にあり、@Observable の model にも手動でアノテーションを付ける必要はありません。第 2 ステーションは Concurrency Cliffs です。Shape.path(in:)、visualEffect、Layout プロトコルのメソッド、onGeometryChange といった API について、SwiftUI は能動的にクロージャをバックグラウンドスレッドへ送って計算します。これらの引数が @Sendable としてマークされているのはそのためで、コンパイラが代わりに data race を検出してくれます。第 3 ステーションは Camp です。なぜ Button の action が同期クロージャなのか、その理由が説明されます。長時間処理の前後で UI 状態を切り替える(たとえばローディングアニメーションの表示)には同期で発火する必要があり、async 関数内の await は suspension を導入してしまうため、次フレームの描画タイミングを逃しかねないからです。
詳細な内容
このセッションは全体を通して ColorExtractorView という view を中心に展開されます(02:45)。
struct ColorExtractorView: View {
@State private var model = ColorExtractor()
var body: some View {
ImageView(
imageName: model.imageName,
isLoading: model.isExtracting
)
EqualWidthVStack {
ColorSchemeView(
isLoading: model.isExtracting,
colorScheme: model.scheme,
extractCount: Int(model.colorCount)
)
.onTapGesture {
guard !model.isExtracting else { return }
withAnimation { model.isExtracting = true }
Task {
await model.extractColorScheme()
withAnimation { model.isExtracting = false }
}
}
Slider(value: $model.colorCount, in: 3...10, step: 1)
.disabled(model.isExtracting)
}
}
}
ポイントは以下の通りです。
Viewプロトコルが@MainActorを宣言しているため、ColorExtractorViewは conformance によって@MainActor隔離が推論され、bodyも@Stateもメインスレッド上で動きます。modelのインスタンスは view 宣言内の@Stateで生成されており、Swift が自動的に@MainActor隔離に取り込んでくれます。そのため@Observableclass であるColorExtractorに個別のアノテーションは不要です。- タップジェスチャ内では
withAnimationで同期的にローディング状態へ切り替え、Taskで async コンテキストに入ってextractColorScheme()を呼び出し、結果が返ってきたら同期的に元の状態に戻します。このクロージャも同様に@MainActorを継承します。
バックグラウンドスレッドの入口は次のような形です(08:26)。
struct SchemeContentView: View {
let isLoading: Bool
@State private var pulse: Bool = false
var body: some View {
ZStack {
Circle()
.scaleEffect(isLoading ? 1.5 : 1)
VStack {
Text(isLoading ? "Please wait" : "Extract")
}
.visualEffect { [pulse] content, _ in
content
.blur(radius: pulse ? 2 : 0)
}
.onChange(of: isLoading) { _, newValue in
withAnimation(newValue ? kPulseAnimation : nil) {
pulse = newValue
}
}
}
}
}
ポイントは以下の通りです。
visualEffectのクロージャには@Sendableが付いており、SwiftUI はバックグラウンドスレッドからこれを呼び出す可能性があります。狙いは、頻度の高いビジュアル計算をメインスレッドから引き剥がすことです(11:12)。- クロージャ内で
pulseを読みたい場合、直接pulseと書くとself.pulseと等価になり、コンパイラがエラーを出します。selfは@MainActorのViewであり、それを非隔離なクロージャに渡したうえで@MainActorプロパティへアクセスすると data race のチェックに引っかかるためです。 - 解決策はキャプチャリスト
[pulse]を使ってコピーを取ることです。Boolは値型かつ sendable なので、クロージャ内ではそのローカルコピーだけを読めば、もうselfには依存しません。これでアクター越しのアクセスを回避できます(15:52)。
第 3 ステーションでは、なぜ非同期な状態変更を view 内に直接書くべきではないのかが解説されます(19:15)。await は suspension point を挿入するため、ランタイムは任意の長さで処理を中断したのち再開できます。再開のタイミングではすでにそのフレームの deadline を過ぎているかもしれず、結果としてアニメーションがジェスチャに追従しません。Daniel が推奨するのは、state を橋渡しに使う方法です。state が Task を発火し、async の処理が終わったら同期代入で state を更新し、UI は @Observable を介して自動で反応するという形です。view 側のコードはあくまで同期のままになります。副次的なメリットとして、async ロジックを view から切り離せるため、ユニットテストで SwiftUI を import しなくても動かせるようになります(23:48)。
核となる気づき
-
@MainActorのデフォルトを信じる。Swift 6.2 の新しい language mode では、module 全体に暗黙的に@MainActorが付与されます。既存 app をアップグレードする際はまずこれを有効化し、view 層に手書きされている@MainActorを削除してみるとよいでしょう。冗長なアノテーションが大量に消える可能性が高いです。 -
クロージャに外部の値が必要なら、まずキャプチャリストでコピーを取る。
@Sendableクロージャ内でself.someStateにアクセスしてコンパイルエラーが出たときの第一の選択肢は[someState]であって、プロパティをnonisolatedに変えることではありません。前者は必要なデータだけを複製しますが、後者はデータ安全性の責任を自分で背負い込むことになります。 -
async ロジックを view の外に追い出し、state でブリッジする。view には同期的な state の読み書きだけを置き、長時間処理は model や manager に任せ、結果は
@Observable経由の同期的な mutation として返ってきます。view 内のTaskの役割は「model に UI イベントを通知すること」だけにとどめ、Task内でタイミングに敏感な UI 変更を行うのは避けましょう。 -
アニメーションに関わる state 変更は必ず同期で発火する。
withAnimation { isLoading = true }はonTapGestureのような同期 callback に書くべきで、awaitの後ろに置いてはいけません。awaitの後ろの再開タイミングは制御できず、フレーム deadline を逃す可能性があります。 -
Mutexでカスタム class を sendable にする。並行タスク間で参照型を共有せざるを得ない場合は、Synchronization.Mutexが Apple 公式ドキュメントで推奨されている手段で、自前で lock を書くより堅実です。
関連 Session
- Embracing Swift concurrency — Swift concurrency モデルの入門ガイドで、本 session でも前提として何度も参照されています。
- Elevate an app with Swift concurrency — 重い処理をメインスレッドからどう降ろすかを扱った内容で、本 session の Concurrency Cliffs と合わせて読むのがおすすめです。
- What’s new in SwiftUI — SwiftUI 2025 の新機能の総覧で、本 session で取り上げられた
onScrollVisibilityChangeなどの API も含まれます。 - Code-along: Cook up a rich text experience in SwiftUI with AttributedString — 同じく
@Observableの model と@Stateで async 処理をブリッジする実践例です。
コメント
GitHub Issues · utterances