
-
SwiftUIの並行処理の詳細
SwiftUIで、Swiftの並行処理を活用して安全で応答性の高いアプリを構築する方法を紹介します。デフォルトでメインアクターを使用し、ほかのアクターにタスクをオフロードする、SwiftUIの機能について解説します。SwiftUIのイベントループを使用することで並行処理のアノテーションを解釈し、非同期タスクを管理して、スムーズなアニメーションやUIの更新を行う方法を学びます。セッションを通じて、データ競合を回避して安心してコードを記述する方法を理解できます。
関連する章
リソース
- Concurrency
- Mutex
- The Swift Programming Language: Concurrency
- Updating an App to Use Swift Concurrency
関連ビデオ
WWDC25
WWDC23
-
このビデオを検索
皆さんこんにちは 私のツアーへようこそ 本日のガイド Danielです SwiftUIチームに所属しています
今日は一緒に 並行処理の背景と SwiftUIによるアプリ開発について 見ていきたいと思います
今日ご参加いただいたのは データ競合バグという危険な生き物の 話を耳にされたからだと思います
これまでに何度か遭遇したことが あるかもしれません
本日の話題は アプリの予期しない状態、 アニメーションの不具合、そして 永久的なデータ損失です
このツアーは100%安全ですので ご心配なく SwiftとSwiftUIを使用すれば そのようなデータ競合アニマルを 置き去りにできます SwiftUIではさまざまな方法で コードを同時に実行できます このツアーでは SwiftUI APIの 並行処理の注釈を利用して それらのコードを識別する方法を解説します 最終的には皆さんがSwiftUIでの アプリ開発に自信を深め より大胆になれることを願っています
Swift 6.2では新しい言語モードが 追加され このモードでは
モジュール内のすべての型が 暗黙的に @MainActor注釈でマークされます
このツアーで紹介する内容はすべて この新モードの利用にかかわらず有効です このツアーには 3つのアトラクションがあります
まずメインアクターの 美しい牧草地から出発し SwiftUIでメインアクターが アプリのコンパイル時と実行時の デフォルトとなることを確認します
次に待つのは 並行処理の崖です SwiftUIがアプリにどのように役立ち メインスレッドからタスクをオフロードして UIのヒッチを回避し 同時に 野生のデータ競合バグから 私たちを守るのかを見ていきます
最後に キャンプ場に到着し 自分の位置付けを決めて 並列コード間の関係と SwiftUI APIを検討します
では最初の目的地 メインアクターの牧草地に行きましょう
ツアー中には 自然にインスパイアされた カラースキームを集めたいので そのためのアプリを作りました 写真を撮影したら 抽出したい色の数を選んで 抽出ボタンを押します するとアプリで 補完的な色が写真から抽出され 画面上に表示されます
下にスクロールすると 抽出した カラースキームがすべて表示されるので 好きな色を選んで エクスポートできます
抽出UIについては 構造体ColorExtractorViewを作りました
これは @MainActorの隔離を宣言する SwiftUIのViewプロトコルに準拠しています
Swiftはデータ隔離を使用して すべてのミュータブルステートの 安全性を理解し 検証します ツアー全体を通してこのような 多くの並行性の概念に遭遇するでしょう Swift Concurrencyを初めて使用する場合 または復習が必要な場合は 「Embracing Swift Concurrency」を ご覧ください SwiftUIでは@MainActorで Viewが隔離されるので 構造体をViewに適合させます
このため ColorExtractorViewは @MainActorに隔離されます この点線は 推測された隔離であることを 示します つまり この注釈はコンパイル時に 暗示されますが 実際 私が書いたコードには 含まれていません
@MainActorで 型全体が隔離される場合は そのすべてのメンバーが 暗黙的に隔離されます
これにはViewの要件を実装する bodyプロパティが含まれ
ここで宣言する他のメンバーも同様で この@State変数もそうです
ビューのbodyを閉じます ここでは他のメンバープロパティ 例えば モデルのschemeや モデルの colorCountへのバインディングを参照しています
これがコンパイラで許可されるのは 共有された@MainActorの隔離により これらのアクセスが安全であることが 保証されるからです
また直感的でもあります
@MainActorはSwiftUIのコンパイル時の デフォルトです つまり ほとんどの場合 アプリの機能の 構築に集中することができ 並行処理については あまり考える必要がありません
並行処理の目的でコードに 注釈を付与する必要はなく 自動的に安全性が保たれます
追加のコード用のスペースを確保するため これらの推測される隔離を 非表示にします
@MainActorを使用した このコンパイル時のデフォルトは このビューでは同期コードを超えて 拡張されます
データモデルの型には @MainActorの注釈は必要ありません
モデルはビューの宣言内で インスタンス化しているので Swiftによって モデルインスタンスが 適切に隔離されます
このSchemeContentViewには 色の抽出を 開始するための タップジェスチャが含まれています
色抽出の機能は非同期なので それを呼び出すために Taskを使用して 非同期コンテキストに切り替えます
ビュー本体は @MainActorに隔離されているため このタスクに付与したクロージャが メインスレッドでも実行されます これは本当に便利です
@MainActor隔離はSwiftUIの コンパイル時のデフォルトです
それにより ビューの記述が 便利にやりやすくなるだけでなく 他にも非常に実用的な理由があります AppKitとUIKitのAPIは 排他的に@MainActorに隔離されます
SwiftUIは これらのフレームワークと シームレスにやり取りします 例えば プロトコルUIViewRepresentableは Viewプロトコルを微調整します 構造体と同様に これは@MainActor上で UIViewRepresentableを隔離します
ですのでUIViewRepresentableに 準拠する型もViewです そのため @MainActorに隔離されます
UILabelのイニシャライザは @MainActor隔離を要求します それはmakeUIViewで機能します なぜなら makeUIViewは @MainActor隔離である Representable型のメンバーだからです
@MainActorを使用して注釈を 付ける必要はありません SwiftUIがそのAPIに @MainActorを使用して注釈を付けるのは 実装するデフォルトのランタイム動作が 反映されるからです
このような注釈は実行時に フレームワークで意図された セマンティクスの下流になります
SwiftUIの並行処理注釈は ランタイムセマンティクスを表します これはこれまでに見たコンパイル時の 便宜からすると 微妙な違いに思えるかもしれませんが 非常に根本的なことです この考えを裏付ける別の例を後で紹介します
次のアトラクションは エキサイティングなものになります シートベルトを締め 電子機器が 固定されていることをご確認ください
アプリの開発中に 多くのアプリ機能を導入すると メインスレッド内の処理が多すぎる場合 アプリでドロップフレームやヒッチが 発生することがあります タスクと構造化並行処理を使用すると メインスレッドから コンピューティング処理を オフロードできます 「Elevate an app with Swift Concurrency」セッションでは アプリのパフォーマンスを高めるための 実用的な手法をご紹介していますので ぜひご覧ください
このツアーのメインテーマは SwiftUIでSwiftの並行処理を活用して アプリのパフォーマンスを高めることです
SwiftUIチームは過去に 組み込みアニメーションでは バックグラウンドスレッドを使用して 中間状態が 計算されることを明らかにしています
それをSchemeContentView内の この円で確認してみましょう
色抽出ジョブの開始から終了までの間には 円が大きくなり そしてまた縮小して 元のサイズに戻る アニメーションが表示されます
ここではプロパティisLoadingに反応する scaleEffectを使用しています
このアニメーションの各フレームには 1から 1.5までの異なるスケール値が必要です
このスケールのようなアニメーション値には 複雑な計算が含まれるため その多くをフレームごとに計算すると コストがかかる可能性があります そのためSwiftUIでは この計算を バックグラウンドスレッドで実行し メインスレッドには 他の処理のための 容量を残しておきます
この最適化は 実装するAPIにも適用されます
そうです SwiftUIではコードがメインスレッドから 実行されることがあるのです
でもそれほど複雑ではないので 心配はいりません
SwiftUIは宣言型です UIViewとは異なり Viewプロトコルに準拠する構造体は メモリ内の固定された場所を 占有するオブジェクトではありません
実行時 SwiftUIでは ビューの個別の表現を作成します
この表現により さまざまな種類の 最適化を実行できるようになります 中でも重要なのは バックグラウンドの スレッドでビューの表現の一部を 評価することです
SwiftUIではこの技法を予約して 多くのコンピューティングが 実行される機会に備えます 例えば ほとんどの場合 この評価には 高頻度のジオメトリ計算が含まれます
Shapeプロトコルはその一例です
Shapeプロトコルには パスを返すメソッドが必要です
ホイール内で抽出された色を表現するために カスタムのくさび形を作成しました
そのパスメソッドを実装します
くさび形はそれぞれ向きが異なります このくさび形が アニメーション化されている間 このパスメソッドでは 呼び出しが バックグラウンドスレッドから取得されます
SwiftUIが自動的に実行する もう1つの カスタムロジックが クロージャ引数です
円の真ん中に ぼやけたテキストが見えます
これの実装には SwiftUIテキストで visualEffectを使用しています
パルス値がtrueとfalseの間で 反転する際に ぼかし半径が2つの値の間で 切り替わるのです ビューモディファイアvisualEffectは サブジェクトビュー つまりテキストへの 効果を定義するクロージャを受け取ります 視覚効果は装飾的で レンダリングに コストがかかる場合があります
SwiftUIはバックグラウンドスレッドから このクロージャを呼び出すことができます
これがバックグラウンドスレッドから コードを呼び出せる2つのAPIです
もう少し見てみましょう
Layoutプロトコルは メインスレッドから 要件メソッドを呼び出すことができます また visualEffectと同様に onGeometryChangeの最初の引数は バックグラウンドスレッドからも 呼び出される可能性があるクロージャです
バックグラウンドスレッドを使った このランタイム最適化は 長い間 SwiftUIの一部でした
SwiftUIでは Sendable注釈を使用することで コンパイラやユーザーに対し このランタイム動作 つまりセマンティクスを表現できます
ここでも SwiftUIの並行処理注釈は ランタイムセマンティクスを表します
別のスレッドでコードを実行すると メインスレッドが解放され アプリの応答性が高まります
そして Sendableキーワードは @MainActorのデータを共有する 必要があるときに データの競合状態が 生じる可能性があることを 思い出させるためのものです
Sendableは 崖の斜面の小道にある 「危険 立入禁止」という 標識のようなものです
でも この説明は少し大げさかもしれません 実際Swiftは コード内の 潜在的な競合状態を確実に検出し コンパイラエラーでそれを知らせます データ競合状態を回避する最善の戦略は 並行処理中のタスク間で データをまったく共有しないことです
SwiftUI APIでSendable関数を 記述する必要がある場合 フレームワークは 関数の引数として必要な 変数のほとんどを提供します 簡単な例を示します
先ほど説明しませんでしたが ColorExtactorViewでは カラーホイールとスライダーは同じ幅です それはこのEqualWidthVStack型のおかげです
EqualWidthVStackはカスタムレイアウトです
今回はレイアウトについては触れません ここでのポイントは SwiftUIによって渡される引数を使えば 外部変数を利用せずに このような 高度な計算を実行できるということです
しかし Sendable関数の外部の変数に アクセスする必要がある場合はどうでしょう
SchemeContentViewでは このvisualEffectに 状態pulseが必要です
しかしSwiftでは データ競合状態の 警告が表示されます
双眼鏡で コンパイラエラーの内容を 詳しく確認してみましょう
pulse変数はself.pulseの略です これはSendableクロージャで @MainActor隔離変数を共有する場合の 一般的なシナリオです
selfはViewで メインアクターで隔離されています ここから始めましょう 最終目標は Sendableクロージャに含まれる pulse変数にアクセスすることです これを達成するために 必要なことが2つあります
まず 値selfはメインアクターから バックグラウンドスレッドの コード領域へと 境界を越えて移動する必要があります
Swiftではこのことを「変数selfを バックグラウンドスレッドに 送信する」と言います
これには selfの型が Sendableである必要があります
selfが正しい場所に現れたので そのプロパティpulseをこの非隔離領域で 参照したいと思います でもコンパイラは プロパティpulseが どのアクターにも隔離されないのでない限り それを許可しません
コードをもう一度見てみると selfはViewであるため @MainActorによって保護されており
コンパイラには Sendableと見なされます
そのためSwiftは この参照が @MainActor隔離から Sendableクロージャへと 境界を越えて移動することを 問題なく受け入れます
そのためSwiftは実際 pulseプロパティに アクセスする試みに対して警告しています
もちろん pulseがViewのメンバーとして MainActorに 隔離されていることはわかっています
そのためコンパイラは selfをここに送信できても @MainActorに隔離された pulseプロパティへのアクセスは 安全でないことを伝えています
このコンパイルエラーを 修正するには Viewへの参照を通じた プロパティの読み取りを避けます
ここで記述した視覚効果には このView全体の値は必要なく pulseがtrueかfalseかを 知りたいだけです ですので代わりに クロージャの キャプチャリストでpulse変数の コピーを作成し それを参照します そうすれば このクロージャに selfを送信する必要はありません
pulseのコピーを送信するだけです Boolは単純な値型なので このコピーは送信可能です
このコピーは この関数の範囲内にのみ 存在するため アクセスしても データ競合の問題は発生しません
この例では Sendableクロージャの pulse変数は グローバルアクターによって 保護されているため アクセスできませんでした
これを機能させる別の戦略は 参照するすべてのものを 非隔離状態にすることです
さて皆さん キャンプ場に到着しました ここからは並行コードの 整理方法について説明します
経験豊富なSwiftUIデベロッパなら ボタンのアクションコールバックを含む 大半のSwiftUIのAPIが 同期的であることにお気付きでしょう
並行コードを呼び出すには まず Taskで非同期コンテキストに 切り替える必要があります
でもButtonはなぜ非同期クロージャを 受け入れないのでしょうか
同期的な更新は 優れたユーザー体験を 実現するために重要です それが特に重要なのは アプリに実行時間の長いタスクがある場合や ユーザーが結果を待つ必要がある場合です
async関数を使用して 実行時間の長いタスクを開始する前に UIを更新し タスクが進行中であることを 示すことが重要です
この更新は同期である必要があります 特に時間が重要なアニメーションを トリガーする必要がある場合はなおさらです
例えば 言語モデルに色の抽出を サポートしてもらうとします その抽出プロセスには少し時間がかかります このアプリでは withAnimationを使用して さまざまな読み込み状態を 同期的にトリガーします
タスクが完了したら 別の同期状態の変化を利用して これらの読み込み状態を逆にします
SwiftUIのアクションのコールバックは 読み込み状態などの UIの更新を設定するために必要な 同期的なクロージャを受け入れます
一方 非同期関数は 特別な考慮が必要です 特にアニメーションを処理する場合は なおさらです それでは それを見ていきましょう
このアプリでは 上にスクロールして 以前のカラースキームの履歴を 確認できます 各カラースキームが画面に表示されるときに その色をアニメーションで表現するとします
onScrollVisibilityChange ビューモディファイアを使うと カラースキームが画面に表示されたときに イベントが発生します それが発生したらすぐに 状態変数をtrueに設定して アニメーションを トリガーすると 各色のYオフセットが アニメーションで更新されます
SwiftUIが UIフレームワークとして 毎フレームでスムーズな操作を実現するには 各デバイスに特定の 画面リフレッシュレートが 必要だという現実に向き合わねばなりません
それが重要になるのは 例えば スクロールのような継続的な ジェスチャーにコードを 反応させたいときです このコードをタイムラインに配置します
この緑色の三角形を使用して SwiftUIがonScrollVisibilityChangeを 呼び出す瞬間をマークします 青い円は 状態の変化によって アニメーションをトリガーする 瞬間を示しています
この設定では その変化がジェスチャーのコールバックと 同じフレームで起こるかどうかによって 視覚的に大きな違いが生まれます
変化のアニメーションの前に 非同期処理をいくつか追加します 非同期処理が開始された瞬間を オレンジ色の線でマークして待機します
Swiftでは 非同期関数待機すると 一時停止ポイントが作成されます
Taskは同期関数を 引数として受け入れます
コンパイラは awaitを検出すると 非同期関数を2つに分割します
最初の部分を実行した後 Swiftランタイムはこの関数を一時停止し CPUで他の処理を行うことができます これは任意の時間 継続できます その後ランタイムは 元の非同期関数で再開し 残りの部分を実行します
このプロセスは 関数でawaitが 発生するたびに繰り返すことができます
タイムラインに戻ると この一時停止によって タスクのクロージャがいつまでも再開されず
デバイスに指示された更新期限を 過ぎてしまう可能性があります
ユーザーには アニメーションの動きが遅く ずれているように見えることになります このため 非同期関数の変更は 目標達成の役に立たない可能性があります
SwiftUIでは デフォルトで 同期コールバックが提供されます これにより 非同期コードの 意図しない中断を回避できます 同期アクションクロージャ内のUIの更新は 簡単に正しく行うことができます Taskを使用すると いつでも 非同期コンテキストにオプトインできます
アニメーションのように 時間が重要なロジックでは SwiftUIの入力と出力が 同期している必要があります 監視可能なプロパティの同期的な変更と 同期コールバックは フレームワークとの 最も自然なインタラクションです 優れたユーザー体験の実現に 多くのカスタム並行ロジックは不要です 同期コードは 多くのアプリにとって 優れた出発点であり 終着点でもあります
一方 アプリが多くの並行処理を行う場合は UIコードと非UIコードの間の境界に 注目してみてください
非同期処理のロジックとビューロジックを 隔離することが最適です
状態の一部を橋渡しとして使用できます 状態により UIコードが非同期コードから 切り離されます
すると非同期タスクが開始されます
一部の非同期処理が終了したら 状態に対して同期的な変更を実行します その変更に対する反応として UIが更新されます
このように UIロジックはほぼ同期的です
別の利点として 非同期コードのテストを 簡単に記述できるようになります このコードは UIロジックから 独立しているからです
ビューでは非同期コンテキストへの 切り替えをTaskで行えますが
この非同期コンテキストの コードはシンプルに保ってください ここで UIイベントについて モデルに通知します
時間が重要な変更が 多く必要になるUIコードと 実行時間の長い非同期ロジックとの 境界を見つけることは アプリの構造を改善するための 優れた方法です
これは ビューの同期と応答性を 維持するのに役立ちます また 非UIコードを 適切に整理することも重要です
そうした作業の自由度を広げるため 今回ご紹介したヒントをぜひご活用ください
Swift 6.2は 優れたデフォルトの アクター隔離設定を備えています 既存のアプリをお持ちの場合は ぜひお試しください ほとんどの@MainActor注釈は 削除できるようになります
ミューテックスは クラスを送信可能にする 重要なツールです 使用方法については 公式ドキュメントを確認してください
ぜひアプリで非同期コードの ユニットテストを記述してみてください SwiftUIをインポートせずに できるかどうかお試しください
それではみなさん 以上が SwiftUIで Swiftの並行処理を活用して データ競合が発生しない高速アプリを 構築する方法になります
このツアーにご参加いただいたことで SwiftUIの並行処理に関して 確かな メンタルモデルが得られたかと思います
ご視聴ありがとうございました ぜひ素晴らしい旅をお続けください
-
-
2:45 - UI for extracting colors
// UI for extracting colors struct ColorScheme: Identifiable, Hashable { var id = UUID() let imageName: String var colors: [Color] } @Observable final class ColorExtractor { var imageName: String var scheme: ColorScheme? var isExtracting: Bool = false var colorCount: Float = 5 func extractColorScheme() async {} } 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) } } } }
-
5:55 - AppKit and UIKit require @MainActor: an example
// AppKit and UIKit require @MainActor // Example: UIViewRepresentable struct FancyUILabel: UIViewRepresentable { func makeUIView(context: Context) -> UILabel { let label = UILabel() // customize the label... return label } }
-
6:42 - UI for extracting colors
// UI for extracting colors struct ColorScheme: Identifiable, Hashable { var id = UUID() let imageName: String var colors: [Color] } @Observable final class ColorExtractor { var imageName: String var scheme: ColorScheme? var isExtracting: Bool = false var colorCount: Float = 5 func extractColorScheme() async {} } struct ColorExtractorView: View { @State private var model = ColorExtractorModel() var body: some View { ImageView( imageName: model.imageName, isLoading: model.isExtracting ) EqualWidthVStack(spacing: 30) { 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) } } } }
-
8:26 - Animated circle, part of color scheme view
// Part of color scheme view struct SchemeContentView: View { let isLoading: Bool @State private var pulse: Bool = false var body: some View { ZStack { // Color wheel … Circle() .scaleEffect(isLoading ? 1.5 : 1) VStack { Text(isLoading ? "Please wait" : "Extract") if !isLoading { Text("^[\(extractCount) color](inflect: true)") } } .visualEffect { [pulse] content, _ in content .blur(radius: pulse ? 2 : 0) } .onChange(of: isLoading) { _, newValue in withAnimation(newValue ? kPulseAnimation : nil) { pulse = newValue } } } } }
-
13:10 - UI for extracting colors
// UI for extracting colors 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) } } } }
-
13:47 - Part of color scheme view
// Part of color scheme view struct SchemeContentView: View { let isLoading: Bool @State private var pulse: Bool = false var body: some View { ZStack { // Color wheel … Circle() .scaleEffect(isLoading ? 1.5 : 1) VStack { Text(isLoading ? "Please wait" : "Extract") if !isLoading { Text("^[\(extractCount) color](inflect: true)") } } .visualEffect { [pulse] content, _ in content .blur(radius: pulse ? 2 : 0) } .onChange(of: isLoading) { _, newValue in withAnimation(newValue ? kPulseAnimation : nil) { pulse = newValue } } } } }
-
17:42 - UI for extracting colors
// UI for extracting colors 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) } } } }
-
18:55 - Animate colors as they appear by scrolling
// Animate colors as they appear by scrolling struct SchemeHistoryItemView: View { let scheme: ColorScheme @State private var isShown: Bool = false var body: some View { HStack(spacing: 0) { ForEach(scheme.colors) { color in color .offset(x: 0, y: isShown ? 0 : 60) } } .onScrollVisibilityChange(threshold: 0.9) { guard !isShown else { return } withAnimation { isShown = $0 } } } }
-