Xcode のビルド設定で Strict Concurrency Checking を complete に切り替えた瞬間、警告が 217 件出ました。生成直後は 1 件も出ていなかったコードです。
Rork Max が書いた SwiftUI アプリは、素直で読みやすいコードでした。ただそれは Swift 5 言語モードでの話でした。Swift 6 の言語モードに上げると、その素直さがそのまま「どのスレッドから触られるか誰も宣言していない」という指摘に変わります。
個人開発でアプリを 6 本並行運用している立場からすると、217 件はそれ自体が怖い数字ではありません。怖いのは、直し方を誤ると実行時の挙動が静かに変わることです。@MainActor をひとつ足すだけで、これまで背景スレッドで走っていた画像デコードが UI スレッドに引き戻される。警告は消えて、スクロールが 12fps になる。
この記事は、その 217 件を 3 週間かけて 0 にした過程の記録です。手順よりも「どこで判断を間違えかけたか」に紙幅を割きました。
まず一括で通そうとして失敗した
最初の 2 日間、私はプロジェクト全体の Swift 言語モードを 6 に上げて、出てきたエラーを上から順に潰そうとしました。
これは筋の悪いやり方でした。理由は 2 つあります。
ひとつは、エラーが連鎖するためです。ある型を Sendable に適合させると、その型を持つ別の型で新しいエラーが出ます。全体を一度に赤くすると、いま自分が直しているのが根本原因なのか、それとも波及した結果なのかが判別できなくなります。
もうひとつは、ビルドが通らない状態が長引くと、途中で挙動を確かめられないためです。並行性の修正は「コンパイルが通った=正しい」ではありません。@MainActor の付与は正真正銘の実行時セマンティクスの変更です。ビルドが 2 日間赤いまま進めた変更は、後から一件ずつ検証し直すはめになりました。
3 日目にやり直しました。プロジェクト全体ではなく、ターゲット単位で complete にする方針です。
// Package.swift — SPM ターゲットごとに段階を刻む
.target(
name: "CoreModels",
swiftSettings: [
.swiftLanguageMode(.v6) // 依存の葉から先に v6 へ
]
),
.target(
name: "ImagePipeline",
dependencies: ["CoreModels"],
swiftSettings: [
.swiftLanguageMode(.v5), // まだ v5
.enableUpcomingFeature("StrictConcurrency") // 警告としてだけ観測
]
),
依存グラフの葉(他に依存しない層)から順に v6 へ上げます。上げ切ったターゲットは以降エラーが再発しません。まだ上げていないターゲットでは StrictConcurrency を upcoming feature として有効化し、警告としてだけ数を観測します。
このやり方に変えてから、赤い状態が続くのは 1 ターゲットぶんだけになりました。
| 週 | v6 済みターゲット | 残警告 | 備考 |
|---|---|---|---|
| 0(開始時) | 0 / 5 | 217 | 全体一括を断念 |
| 1 | 2 / 5 | 134 | CoreModels / Persistence |
| 2 | 4 / 5 | 41 | ImagePipeline / Networking |
| 3 | 5 / 5 | 0 | App ターゲット |
@MainActor は View ではなく状態層に置く
Rork Max が生成したコードは、SwiftUI の View に @State と非同期処理が同居する構造でした。よくある形です。
// 生成直後: View が状態と通信の両方を抱えている
struct WallpaperGridView: View {
@State private var items: [Wallpaper] = []
@State private var isLoading = false
var body: some View {
ScrollView { /* ... */ }
.task { await load() }
}
private func load() async {
isLoading = true
items = try! await WallpaperAPI.fetchAll() // 隔離が宣言されていない
isLoading = false
}
}complete にすると、WallpaperAPI.fetchAll() が返す [Wallpaper] を View の隔離コンテキストへ渡す箇所で警告が出ます。
ここで最も手軽な対処は、WallpaperAPI に @MainActor を付けることです。警告は消えます。そして fetchAll() の内部で走っていた JSON デコードが、メインスレッドに乗ります。
私はこれを一度やってしまい、初回ロード時のフレームドロップで気づきました。500 件のサムネイル情報をデコードする 180ms ぶんが、そのままメインスレッドを塞いでいました。
正しい置き場所は、View でも API でもなく、その間の状態層です。
// 移行後: 隔離の境界を Store に一本化する
@MainActor
@Observable
final class WallpaperStore {
private(set) var items: [Wallpaper] = []
private(set) var isLoading = false
private let api: WallpaperFetching // nonisolated なプロトコル
init(api: WallpaperFetching) { self.api = api }
func load() async {
isLoading = true
defer { isLoading = false }
do {
// await の向こう側はメインスレッドから離れる
items = try await api.fetchAll()
} catch {
items = []
}
}
}
// API 側は隔離を持たない。呼ばれたスレッドで走る
protocol WallpaperFetching: Sendable {
func fetchAll() async throws -> [Wallpaper]
}
struct WallpaperAPI: WallpaperFetching {
func fetchAll() async throws -> [Wallpaper] {
let (data, _) = try await URLSession.shared.data(from: Self.endpoint)
return try JSONDecoder().decode([Wallpaper].self, from: data) // 背景で走る
}
}WallpaperStore に @MainActor を付け、WallpaperFetching は隔離を持たないままにします。try await api.fetchAll() の呼び出しでメインアクターは一度手放されるため、デコードは背景スレッドで実行されます。戻り値を items に代入する時点でメインアクターに戻ります。
この形にすると、Wallpaper が Sendable である必要が生じます。それは正しい要求です。値がアクター境界を越えるからです。
判断基準として私が置いたのは次の 1 行でした。隔離注釈は「その値を最終的に誰が読むか」ではなく「その値の可変状態を誰が所有するか」に付ける。 View は読むだけです。所有しているのは Store です。