取り組みの背景:なぜ今 Swift Concurrency を学ぶのか
Rork Max で iOS アプリを本格開発していると、必ずといっていいほど直面するのが「非同期処理の複雑さ」です。API 呼び出し、データベースアクセス、画像の読み込み——これらをすべて安全・効率的に扱うには、Swift Concurrency の深い理解が不可欠です。
Swift 5.5(iOS 15以降)で導入された Swift Concurrency は、単なる糖衣構文ではありません。async/await、Actor、Structured Concurrency、TaskGroup、AsyncStream といった機能が有機的に連携し、データ競合を型システムレベルで防ぎながら 、直感的に読めるコードを実現する仕組みです。
この記事で学べること
async/await の仕組みと、GCD(Grand Central Dispatch)との根本的な違い
Actor によるデータ競合の防止と @MainActor の正しい使い方
TaskGroup を使った並列処理と、適切なエラーハンドリング
AsyncStream でリアルタイムデータを扱うパターン
Rork Max のネイティブモジュールとの統合方法
対象読者
Rork Max でネイティブ(Swift)コードを書いたことがある方
GCD や OperationQueue には慣れているが、Swift Concurrency への移行を検討中の方
データ競合やクラッシュの根本原因を理解したい方
async/await の基礎と GCD との違い
GCD が抱える本質的な問題
Swift Concurrency を理解するには、まず GCD が「何を解決できなかったか」を知る点が肝心です。
// GCD の典型的な書き方(Rork Max のネイティブモジュール内)
@objc func fetchUserData ( _ userId: String , resolve : @escaping RCTPromiseResolveBlock, reject : @escaping RCTPromiseRejectBlock) {
DispatchQueue. global ( qos : .userInitiated). async {
// ① ここからバックグラウンドスレッド
guard let url = URL ( string : "https://api.example.com/users/ \( userId ) " ) else {
// ② reject はメインスレッドで呼ぶ必要があるのか?バックグラウンドで呼んでいい?
reject ( "INVALID_URL" , "Invalid URL" , nil )
return
}
URLSession.shared. dataTask ( with : url) { data, response, error in
// ③ このクロージャはどのスレッドで動く?
if let error = error {
reject ( "FETCH_ERROR" , error.localizedDescription, error)
return
}
guard let data = data else {
reject ( "NO_DATA" , "No data received" , nil )
return
}
DispatchQueue.main. async {
// ④ UI更新のためにメインスレッドに切り替え
resolve ( String ( data : data, encoding : . utf8 ))
}
}. resume ()
}
}
このコードには潜在的な問題が複数あります。スレッドの切り替えを手動で管理しなければならず、コールバックのネストが深くなると「どのスレッドで動いているか」の追跡が困難になります。また、reject や resolve を二重に呼び出してしまうバグも起きやすい構造です。
async/await で書き直す
// Swift Concurrency での書き方
@objc func fetchUserData ( _ userId: String , resolve : @escaping RCTPromiseResolveBlock, reject : @escaping RCTPromiseRejectBlock) {
Task {
do {
let data = try await UserAPI. fetchUser ( userId : userId)
resolve (data)
} catch {
reject ( "FETCH_ERROR" , error.localizedDescription, error)
}
}
}
// 実際のデータ取得ロジック(再利用可能な形で分離)
enum UserAPI {
static func fetchUser ( userId : String ) async throws -> String {
guard let url = URL ( string : "https://api.example.com/users/ \( userId ) " ) else {
throw APIError.invalidURL
}
// URLSession は async/await に対応済み
let (data, response) = try await URLSession.shared. data ( from : url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return String ( data : data, encoding : . utf8 ) ?? ""
}
}
// 期待する動作: データが取得できれば resolve が呼ばれ、エラーがあれば reject が呼ばれる
// スレッドの切り替えは Swift ランタイムが自動的に最適化する
async/await の重要な特性として、await は「スレッドをブロックしない」という点があります。GCD の semaphore.wait() や DispatchGroup.wait() とは根本的に異なり、現在のスレッドを解放して他の処理を実行させつつ、再開条件が満たされたら効率よく再開します。
Actor によるデータ競合の根絶
データ競合が引き起こす問題
マルチスレッド環境でもっとも厄介なバグが「データ競合(Data Race)」です。複数のスレッドが同時に同じメモリ領域に書き込もうとしたとき、クラッシュや予期しない挙動が発生します。
// 危険なコード:複数スレッドから同時にアクセスされる可能性がある
class UserCache {
var cache: [ String : User] = [ : ] // ⚠️ スレッドセーフではない
func store ( _ user: User) {
cache[user.id] = user // 複数スレッドから呼ばれると競合する
}
func retrieve ( id : String ) -> User ? {
return cache[id] // 読み書きが同時に起きるとクラッシュの可能性
}
}
Actor で解決する
// Actor を使ったスレッドセーフなキャッシュ
actor UserCache {
// Actor 内のプロパティへのアクセスは自動的に直列化される
private var cache: [ String : User] = [ : ]
private var lastAccessTime: [ String : Date] = [ : ]
// Actor メソッドはすべて async として扱われる
func store ( _ user: User) {
cache[user.id] = user
lastAccessTime[user.id] = Date ()
}
func retrieve ( id : String ) -> User ? {
return cache[id]
}
// キャッシュの古いエントリを削除するメソッド
func pruneExpiredEntries ( olderThan threshold: TimeInterval) {
let cutoff = Date (). addingTimeInterval ( - threshold)
let expiredKeys = lastAccessTime
. filter { $0 . value < cutoff }
. map { $0 .key }
expiredKeys. forEach { key in
cache. removeValue ( forKey : key)
lastAccessTime. removeValue ( forKey : key)
}
}
}
// 使い方
let userCache = UserCache ()
// Actor の外からアクセスするときは await が必要
Task {
await userCache. store ( User ( id : "u1" , name : "Masaki" ))
let user = await userCache. retrieve ( id : "u1" )
print (user ? .name ?? "not found" ) // "Masaki"
}
Actor は「このクラスへのアクセスは一度に1つの呼び出しだけ」という保証をコンパイラレベルで提供します。await を付けることで「アクセスの順番待ちが発生する可能性がある」ことを明示し、他の処理をブロックせずに待機します。
@MainActor で UI 更新を安全に行う
// ViewModel に @MainActor を付けることで UI スレッドの安全性を保証
@MainActor
class ArticleViewModel : ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var errorMessage: String ?
private let apiClient = ArticleAPIClient ()
func loadArticles () async {
isLoading = true
errorMessage = nil
do {
// nonisolated なバックグラウンド処理から結果を受け取る
let fetchedArticles = try await apiClient. fetchAll ()
// ここは @MainActor コンテキストなので UI の更新が安全
articles = fetchedArticles
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// APIClient は @MainActor でなくてよい(バックグラウンドで動く)
struct ArticleAPIClient {
func fetchAll () async throws -> [Article] {
let url = URL ( string : "https://api.rorklab.net/articles" ) !
let (data, _ ) = try await URLSession.shared. data ( from : url)
return try JSONDecoder (). decode ([Article]. self , from : data)
}
}
@MainActor は「このクラスのすべてのメソッドとプロパティはメインスレッドで動く」という宣言です。SwiftUI の @Published プロパティへの変更は必ずメインスレッドから行う必要があるため、ViewModel に @MainActor を付けるのはベストプラクティスです。
Structured Concurrency と TaskGroup
Structured Concurrency の考え方
Swift Concurrency の「Structured」という言葉は、非同期処理の生存期間が明確な構造(スコープ)に縛られることを意味します。子タスクは親タスクより長く生き続けることができません。
// withTaskGroup を使った並列処理
func fetchMultipleUsers ( userIds : [ String ]) async throws -> [User] {
try await withThrowingTaskGroup ( of : User. self ) { group in
// 複数のユーザーを並列で取得
for userId in userIds {
group. addTask {
return try await UserAPI. fetchUser ( userId : userId)
}
}
// すべてのタスクが完了するのを待って結果を集める
var users: [User] = []
for try await user in group {
users. append (user)
}
return users
}
// ここまで到達した時点で、group 内のすべてのタスクは完了している
// キャンセルや例外が発生した場合も、子タスクはすべて適切にクリーンアップされる
}
withThrowingTaskGroup の重要な特性として、グループ内のどれか1つのタスクがエラーをスローすると、他のタスクに自動的にキャンセルシグナルが送られます。GCD の DispatchGroup では実現が難しかった「エラー発生時の全タスククリーンアップ」が自動的に行われます。
実践的な並列処理パターン
Rork Max で API から複数のリソースを並列取得する実装例です。
// Rork Max ネイティブモジュール:ダッシュボードデータの並列取得
actor DashboardDataManager {
struct DashboardData {
let user: User
let recentArticles: [Article]
let notifications: [Notification]
let analytics: Analytics
}
func fetchDashboardData ( userId : String ) async throws -> DashboardData {
// 4つのAPIを並列で呼び出す
async let user = UserAPI. fetchUser ( userId : userId)
async let articles = ArticleAPI. fetchRecent ( limit : 10 )
async let notifications = NotificationAPI. fetchUnread ( userId : userId)
async let analytics = AnalyticsAPI. fetchSummary ( userId : userId)
// すべての結果を同時に待つ
// どれか1つが失敗すると、全体が失敗として処理される
return try await DashboardData (
user : user,
recentArticles : articles,
notifications : notifications,
analytics : analytics
)
// 期待する動作: 4つのAPI呼び出しが並列で実行され、
// 直列実行より最大4倍高速になる(各APIが500msかかる場合、合計500ms程度で完了)
}
}
async let は「この値の計算を今すぐ始めるが、値を使う時まで待たない」という宣言です。withTaskGroup が動的な数のタスクを扱うのに対し、async let は静的に決まった数の並列処理に向いています。
キャンセル処理の実装
// キャンセル可能なダウンロードタスク
class ImageDownloadManager {
private var activeTasks: [ String : Task<UIImage, Error >] = [ : ]
func downloadImage ( url : URL, cacheKey : String ) async throws -> UIImage {
// 既存のタスクがあればキャンセルして新しいタスクに置き換える
activeTasks[cacheKey] ? . cancel ()
let task = Task {
// キャンセルチェックポイント:定期的にチェックする
try Task. checkCancellation ()
let (data, response) = try await URLSession.shared. data ( from : url)
// もう一度キャンセルを確認(長時間の処理の途中でも確認する)
try Task. checkCancellation ()
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 ,
let image = UIImage ( data : data) else {
throw ImageError.invalidData
}
return image
}
activeTasks[cacheKey] = task
do {
let image = try await task. value
activeTasks. removeValue ( forKey : cacheKey)
return image
} catch {
activeTasks. removeValue ( forKey : cacheKey)
throw error
}
}
func cancelDownload ( cacheKey : String ) {
activeTasks[cacheKey] ? . cancel ()
activeTasks. removeValue ( forKey : cacheKey)
}
}
Task.checkCancellation() は、タスクがキャンセルされていた場合に CancellationError をスローします。適切な間隔でキャンセルチェックを挿入することで、ユーザーが画面を離れた後もダウンロードが続くような無駄なリソース消費を防げます。
AsyncStream でリアルタイムデータを扱う
AsyncStream とは
AsyncStream は「時間をかけて複数の値を生成する非同期シーケンス」を実現します。WebSocket、Bluetooth、センサーデータなど、継続的に値が届くデータソースを Swift Concurrency の世界でエレガントに扱えます。
// WebSocket のメッセージを AsyncStream で扱う
class WebSocketManager {
private var webSocketTask: URLSessionWebSocketTask ?
func messageStream ( url : URL) -> AsyncStream< String > {
return AsyncStream { continuation in
// WebSocket 接続を開始
let session = URLSession ( configuration : .default)
let task = session. webSocketTask ( with : url)
self .webSocketTask = task
// メッセージを継続的に受信するための再帰的な関数
func receiveNext () {
task. receive { result in
switch result {
case . success ( let message) :
switch message {
case . string ( let text) :
// 新しい値をストリームに送る
continuation. yield (text)
case . data ( let data) :
if let text = String ( data : data, encoding : . utf8 ) {
continuation. yield (text)
}
@unknown default:
break
}
// 次のメッセージを待つ
receiveNext ()
case . failure ( let error) :
print ( "WebSocket error: \( error ) " )
// エラーが発生したらストリームを終了
continuation. finish ()
}
}
}
task. resume ()
receiveNext ()
// ストリームがキャンセルされたとき(例:画面が閉じられた)のクリーンアップ
continuation.onTermination = { _ in
task. cancel ( with : .goingAway, reason : nil )
}
}
}
}
// 使い方:SwiftUI View での統合
struct LiveMessageView : View {
@StateObject private var viewModel = LiveMessageViewModel ()
var body: some View {
List (viewModel.messages, id : \. self ) { message in
Text (message)
}
. task {
// .task モディファイアは View が消えると自動的にキャンセルされる
await viewModel. startReceiving ()
}
}
}
@MainActor
class LiveMessageViewModel : ObservableObject {
@Published var messages: [ String ] = []
private let wsManager = WebSocketManager ()
func startReceiving () async {
let url = URL ( string : "wss://api.rorklab.net/ws/messages" ) !
for await message in wsManager. messageStream ( url : url) {
// @MainActor コンテキストなので直接 @Published を更新できる
messages. insert (message, at : 0 )
// 表示件数を制限
if messages. count > 100 {
messages = Array (messages. prefix ( 100 ))
}
}
}
}
.task モディファイアの便利な点は、View のライフサイクルに自動的に紐づくことです。View が画面から消えると、task 内の処理は自動的にキャンセルされます。onAppear/onDisappear で手動管理していた時代に比べ、メモリリークやゾンビタスクのリスクが大幅に低下します。
AsyncStream と Combine の橋渡し
既存の Combine コードがある場合、AsyncStream との相互運用が必要になることがあります。
// Combine Publisher を AsyncStream に変換するヘルパー
extension Publisher where Failure == Never {
var asyncStream: AsyncStream<Output> {
AsyncStream { continuation in
let cancellable = sink { completion in
continuation. finish ()
} receiveValue : { value in
continuation. yield (value)
}
continuation.onTermination = { _ in
cancellable. cancel ()
}
}
}
}
// Combine の NotificationCenter を AsyncStream で使う例
func batteryLevelStream () -> AsyncStream< Float > {
NotificationCenter.default
. publisher ( for : UIDevice.batteryLevelDidChangeNotification)
. compactMap { _ in UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : nil }
.asyncStream
}
// 使い方
Task {
UIDevice.current.isBatteryMonitoringEnabled = true
for await level in batteryLevelStream () {
print ( "バッテリー残量: \( Int (level * 100 ) ) %" )
if level < 0.2 {
// 低バッテリー警告をユーザーに表示
await showLowBatteryAlert ()
}
}
}
よくあるエラーと対処法
エラー1:Sending 'X' risks causing data races
// ❌ データ競合の警告が出るコード
class DataService {
var data: [ String ] = []
func process () async {
// Warning: Sending 'self.data' risks causing data races
await Task. detached {
self .data. append ( "new item" ) // 危険:data は Sendable でない
}. value
}
}
// ✅ Actor を使って修正
actor DataService {
var data: [ String ] = []
func process () async {
data. append ( "new item" ) // Actor 内なので安全
}
}
エラー2:デッドロック(MainActor のネスト)
// ❌ 同一の Actor コンテキストからブロッキング呼び出しをしてしまうパターン
@MainActor
class ViewModel {
func badPattern () {
// ❌ MainActor から Task.init して同じ MainActor を await すると
// 状況によってデッドロックが発生することがある
let result = Task {
await someMainActorWork () // 同じ MainActor を待機
}
// result.value を同期的に待とうとすると問題が起きる
}
// ✅ 正しいパターン:async メソッドとして定義する
func goodPattern () async {
await someMainActorWork ()
}
}
エラー3:Task のリーク
// ❌ Task がキャンセルされない
class BadViewController : UIViewController {
override func viewDidLoad () {
super . viewDidLoad ()
// この Task は viewController が解放されても動き続ける
Task {
for await update in dataStream () {
self . updateUI (update) // self が解放されていても呼ばれる可能性
}
}
}
}
// ✅ Task を保持してキャンセルする
class GoodViewController : UIViewController {
private var streamTask: Task< Void , Never > ?
override func viewDidLoad () {
super . viewDidLoad ()
streamTask = Task { [ weak self ] in
guard let self else { return }
for await update in dataStream () {
await MainActor. run {
self . updateUI (update)
}
}
}
}
override func viewDidDisappear ( _ animated: Bool ) {
super . viewDidDisappear (animated)
streamTask ? . cancel ()
streamTask = nil
}
}
Rork Max ネイティブモジュールへの統合
React Native との境界での注意点
Rork Max(React Native)のネイティブモジュールは、JS スレッドから呼び出されます。Swift Concurrency と RCT(React Native)の橋渡しには特有の注意点があります。
// Rork Max ネイティブモジュール:Swift Concurrency を正しく使った実装
@objc ( RorkDataModule )
class RorkDataModule : NSObject {
// RCT_EXPORT_METHOD は同期コンテキストのため、Task でラップする
@objc func fetchData ( _ query: String ,
resolve : @escaping RCTPromiseResolveBlock,
reject : @escaping RCTPromiseRejectBlock) {
// detached Task を使うことで、呼び出し元の Actor 継承を避ける
Task. detached ( priority : .userInitiated) {
do {
let result = try await DataService.shared. search ( query : query)
// resolve/reject は JS スレッドで呼ばれることが期待されているため、
// メインスレッドからの呼び出しが安全
await MainActor. run {
resolve (result)
}
} catch {
await MainActor. run {
reject ( "SEARCH_ERROR" , error.localizedDescription, error)
}
}
}
}
}
// 中核のデータサービス:Actor で実装
actor DataService {
static let shared = DataService ()
private var cache: [ String : [SearchResult]] = [ : ]
func search ( query : String ) async throws -> [[ String : Any ]] {
// キャッシュヒット
if let cached = cache[query] {
return cached. map { $0 . toDictionary () }
}
// API 呼び出し
let results = try await SearchAPI. search ( query : query)
cache[query] = results
return results. map { $0 . toDictionary () }
}
}
Task.detached を使う理由は、@objc メソッドが呼ばれる時点の Actor コンテキスト(多くの場合、不定)を引き継がないためです。Task { } は現在のコンテキストを引き継ぐため、予期しない Actor 上で動く可能性があります。
パフォーマンス計測と最適化
Swift Concurrency に移行した後、効果を定量的に確認する点が肝心です。Xcode の Instruments(Time Profiler + Swift Concurrency テンプレート)を使うと、各 Task の実行時間や待機時間を可視化できます。
// パフォーマンス計測用のラッパー
func measureAsync < T >( _ label: String , operation : () async throws -> T) async throws -> T {
let start = CFAbsoluteTimeGetCurrent ()
let result = try await operation ()
let elapsed = CFAbsoluteTimeGetCurrent () - start
print ( "⏱ \( label ) : \( String ( format : "%.3f" , elapsed * 1000 ) ) ms" )
return result
}
// 使用例
let dashboardData = try await measureAsync ( "ダッシュボード並列取得" ) {
try await dashboardManager. fetchDashboardData ( userId : currentUserId)
}
// 期待する出力例: ⏱ ダッシュボード並列取得: 312.456ms
// (直列なら約1200ms かかるところを並列化で1/4 に短縮)
関連する実装については、Rork アプリ パフォーマンス最適化完全ガイド も参考になります。また、本番環境でのクラッシュやエラー追跡については Rork Max エラーハンドリング・デバッグ・本番監視の実践パターン と組み合わせることで、より堅牢なアプリが構築できます。
Swift Concurrency の理解
公式ドキュメントには書かれていない、運用で気づいた点
ここからは、Apple の公式ドキュメントや WWDC セッションでは触れられていないものの、実際に本番アプリへ Swift Concurrency を導入する過程で気づいた点を共有します。私は2014年から累計5,000万ダウンロードのアプリ群を個人で運用していますが、その中の壁紙アプリ1本を GCD ベースから Swift Concurrency へ全面移行したとき、想定していなかった挙動にいくつもぶつかりました。
@MainActor の伝播は想像以上に広がる
ViewModel に @MainActor を付けると、そのクラスが呼び出す非同期メソッドの一部まで暗黙的にメインスレッド実行を期待されるようになります。公式には「クラス単位で隔離される」とだけ書かれていますが、実運用では「@MainActor のクラスから呼ぶ Task { } は、デフォルトで @MainActor を継承する」という挙動が見落としやすい落とし穴です。
@MainActor
class FeedViewModel : ObservableObject {
@Published var items: [FeedItem] = []
func refresh () {
// ❌ この Task は @MainActor を継承するため、
// 重い JSON デコードまでメインスレッドで動いてしまう
Task {
let data = try? await api. fetchFeed () // ネットワークは OK(await で手放す)
let decoded = heavyDecode (data) // ⚠️ これがメインスレッドを占有する
items = decoded
}
}
func refreshCorrect () {
// ✅ デコードのような CPU バウンドな処理は detached に逃がす
Task {
let data = try? await api. fetchFeed ()
let decoded = await Task. detached ( priority : .userInitiated) {
heavyDecode (data) // バックグラウンドで実行
}. value
items = decoded // @MainActor に戻って UI 更新
}
}
}
スクロールが一瞬引っかかる症状の大半は、この「メインスレッドを継承した Task の中で重い処理を走らせていた」ことが原因でした。Instruments の Time Profiler で heavyDecode がメインスレッドのスタックに積まれているのを見て、ようやく腑に落ちました。
Task.detached の乱用は逆効果になる
データ競合を恐れて何でも Task.detached に逃がしたくなりますが、これはアンチパターンです。detached は現在のコンテキストを一切引き継がないため、優先度・キャンセル・Actor 隔離のすべてを自分で再設定する必要があります。私は当初すべての API 呼び出しを Task.detached で囲み、結果として「画面を閉じてもダウンロードが止まらない」リークを大量に作ってしまいました。原則は「Actor 隔離を断ち切りたい明確な理由があるときだけ detached を使う」です。
actor の再入(reentrancy)を理解しないとキャッシュが二重取得される
actor のメソッドは await の地点で中断され、その間に別の呼び出しが入り込めます。これを「Actor reentrancy」と呼びますが、公式ドキュメントでの説明は控えめです。素朴にキャッシュを書くと、同じキーへのリクエストが並行したときに API を二重に叩いてしまいます。
actor ImageLoader {
private var cache: [URL: UIImage] = [ : ]
private var inFlight: [URL: Task<UIImage, Error >] = [ : ]
func image ( for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
// ✅ 進行中のタスクを共有することで二重取得を防ぐ
if let existing = inFlight[url] { return try await existing. value }
let task = Task { try await download (url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let image = try await task. value
cache[url] = image
return image
}
}
inFlight で進行中タスクを共有する前は、リスト表示で同じ画像が画面に複数並ぶと、その枚数だけ同一 URL を取得していました。CDN のリクエスト数が体感で3〜4割減ったのは、この修正の効果です。
実測ベンチマーク:移行で何がどれだけ速くなったか
定性的な「速くなった気がする」では意味がありません。壁紙アプリのダッシュボード画面(ユーザー情報・おすすめ・通知・解析サマリの4 API を取得)で、GCD 直列実装と Swift Concurrency 並列実装を実機(iPhone 13, iOS 17.4, 同一 Wi-Fi)で各50回計測した中央値が以下です。
GCD(DispatchGroup で直列に近い実装): 1,180 ms
async let による4並列: 318 ms (約3.7倍高速)
並列化後にデコードを detached へ逃がす最適化を追加: 291 ms
計測には本文で紹介した measureAsync ラッパーを使いました。重要なのは、並列化の効果は「各 API のレイテンシが揃っているほど大きい」点です。1つだけ極端に遅い API(解析サマリが900 ms)がある場合、全体はその最遅 API に律速されるため、async let でも約950 ms 止まりになります。私はこの計測をもとに、解析サマリだけ別途キャッシュ+遅延ロードに切り替えました。
クラッシュ率にも明確な変化がありました。移行前のこのアプリは Crashlytics 上でデータ競合起因と思われる EXC_BAD_ACCESS がクラッシュの約8%を占めていましたが、キャッシュとミュータブルな共有状態をすべて actor に移したところ、3バージョンを跨いだ観測で該当クラッシュは実質ゼロになりました。型システムで競合を禁止する効果を、数字で実感できた瞬間でした。
GCD から Swift Concurrency への段階的移行チェックリスト
既存の大きなアプリを一度に書き換えるのは現実的ではありません。私が壁紙アプリで実際に踏んだ順序を、再現可能な手順として残します。
まず計測の足場を作る :measureAsync のような計測ラッパーと Crashlytics のカスタムキーを先に入れ、移行の前後を数字で比較できるようにします。これがないと「速くなった」が感覚論で終わります。
末端の API レイヤから着手する :UI に近い ViewModel から触りたくなりますが、依存の末端(ネットワーク層)を先に async 化したほうが波及が小さく済みます。withCheckedThrowingContinuation で既存のコールバック API を1つずつ async に包みます。
共有可変状態を actor に隔離する :キャッシュ・カウンタ・セッション管理など、複数スレッドから触られるものを洗い出して actor 化します。ここがデータ競合クラッシュの主因です。
ViewModel に @MainActor を付ける :@Published を持つクラスに付け、UI 更新の安全性を宣言します。このとき「重い処理を detached に逃がす」修正をセットで行います。
Task のライフサイクルを View に紐づける :手動の onAppear/onDisappear 管理を .task モディファイアへ置き換え、ゾンビタスクを根絶します。
Strict Concurrency Checking を段階的に上げる :Build Settings の SWIFT_STRICT_CONCURRENCY を minimal → targeted → complete と1段ずつ上げ、警告が出たモジュールから潰します。いきなり complete にすると警告が数百件出て心が折れます。
このチェックリストの肝は「2と3を6より先にやる」ことです。先に Strict Checking を最大にすると警告の洪水に飲まれますが、末端から型安全に積み上げてから検査レベルを上げると、警告は意味のある単位で1つずつ片付きます。
まとめ
Swift Concurrency は、Rork Max でのネイティブ開発品質を根本から変える強力な仕組みです。今回解説した内容を振り返ります。
async/await はコールバック地獄を解消し、線形に読めるコードを実現します。Actor はデータ競合をコンパイラレベルで防止し、@MainActor でUI更新の安全性を宣言的に保証できます。TaskGroup と async let で並列処理を表現し、AsyncStream でリアルタイムデータフローをエレガントに扱えます。そして Rork Max のネイティブモジュールとの統合では、Task.detached と withCheckedContinuation が橋渡し役として重要です。
これらを組み合わせることで、GCD 時代には手動で管理しなければならなかったスレッド管理・競合防止・キャンセル処理が、大幅に簡素化されます。状態管理のパターンについては Rork Max の状態管理を Zustand v5 で設計する も参考にしてみてください。