
はじめに
こんにちは、FAANS部フロントエンドブロックの中島です。普段はFAANSのiOSアプリ開発を担当しています。FAANS iOSチームではSwift 6移行の取り組みをしています。以前、Strict Concurrency CheckingをTargetedに変更した過程で得た知見を紹介しました。今回TargetedからCompleteに変更するとXcodeで約1400個の新たな警告が出ました。機械的に対応できる警告もありますが、曖昧な知識だと修正が難しいケースもありました。本記事では、Swift 6移行時の警告やエラー解決を通じて得た知見を共有します。実際に遭遇した警告への対処法など、移行作業を始める前に押さえておきたかったポイントを中心に解説します。
移行当初はXcode 16.4だったので、最新のXcodeでは警告がエラーとなる可能性もありますが、本記事では警告で統一します。また、Swift 6でビルドをするとそれまで警告だったものがエラーになりビルド自体が通らなくなるため、まずはSwift 5の段階ですべての警告を解消しました。その後、Swift 6へ切り替えてビルドが通ることを確認し、新たに不具合が発生していないかを検証しました。
目次
Completeに変更後、新しく発生した警告の分類
Strict Concurrency CheckingをTargetedからCompleteへ変更後、新たに発生した警告を分類して集計しました。Xcode 16.4でMinimum Deployment TargetをiOS 16、Swift Language VersionをSwift 5に設定してビルドした際に発生した警告です。
| 警告概要 | 警告例 | 分類 | 割合 |
|---|---|---|---|
| Sendable非準拠 | type '...' does not conform to |
Sendable | 約46% |
| nonisolatedで MainActorを利用 |
call to main actor-isolated initializer '...' in a synchronous nonisolated context |
コンテキスト不一致 | 約44% |
| SendableクロージャでMainActor を利用 |
main actor-isolated property '...' can not |
コンテキスト不一致 | 約2% |
| SendableクロージャでNonSendable を利用 |
capture of '...' with non-sendable |
Sendable / コンテキスト不一致 |
約2% |
| nonisolatedプロトコル要件の不一致 | main actor-isolated instance method |
コンテキスト不一致 | 約2% |
| sending引数の データ競合 |
sending '...' risks causing data races |
Sendable / コンテキスト不一致 |
約2% |
| static/class変数のデータ競合 | static property '...' is not |
Sendable / コンテキスト不一致 |
約2% |
上の表を見てわかるように次の2つに関連する警告がほとんどでした。
- Sendableに非準拠
- コンテキストの不一致
Sendableに準拠できていないことによる警告は、Non-SendableのクラスなどをActor境界を超えて利用したり、Sendable指定の引数に渡したりしたときに発生します。コンテキストの不一致は、あるActorから別のActorのプロパティやメソッドを利用していると出る警告です。すべての警告を本記事で解説するのは難しいですが、警告内容が異なっても同じような直し方や考え方が非常に多いです。
Sendable非準拠の警告原因
FAANSでSendable非準拠の警告が最も発生したのはAPIレスポンスモデルでした。API通信でOpenAPI(Swagger)を用いており、自動生成されたコードを利用しています。レスポンスの型はpublicなstructやenumでしたが、publicな型は暗黙のSendable準拠が行われないため、明示的にSendableを付与する必要があります。そのため、次のコードのようにUICollectionViewで指定するアイテムの型がSendableに準拠する必要があるので、警告が出ていました。

解決方法
移行当初、Swift 6用の自動生成テンプレートがBeta版だったため利用を見送りました。テンプレートのコードにSendableが付与されていることを確認し、暫定対応としてimport文に@preconcurrencyを付与して警告を抑制しました。現在はすでにStable版がリリースされているため、今後対応する場合はSwift 6用のテンプレートを利用することで解決可能です。FAANSアプリにおいても近いうちに対応を予定しています。
コンテキストの不一致について
他の警告も簡単に解決できるとよいのですが、これから登場するコンテキストの不一致にはさまざまなパターンがあり、一筋縄ではいかないケースもありました。しかし、このコンテキストの不一致を理解すれば、Swift 6対応において発生する警告のほとんどを解決できます。次の3つについて実例を交えて説明します。
- nonisolatedのコンテキストでMainActorを利用するケース
- クロージャ内がnonisolatedになるケース
- nonisolatedなプロトコルをMainActorで実装するケース
1. nonisolatedのコンテキストでMainActorを利用するケース
nonisolatedのコンテキストでMainActorのメソッドやプロパティを利用したことが原因で発生する警告を解説します。FAANSでは一覧表示のためにUICollectionViewを多くの画面で利用しており、セクションごとにレイアウトを切り替える実装をしています。セクション名をenumで管理してその中でレイアウトを生成するメソッドを定義しています。しかしenum自体はnonisolatedなコンテキストであるため、MainActorのUICollectionViewLayoutなどを扱うと警告が発生しました。

また、UINavigationControllerを設定するためのヘルパー関数があります。UINavigationControllerはMainActorに隔離されているため、そのヘルパー関数をnonisolatedなstructに定義すると警告が出ました。

解決方法
enumやstructに@MainActorを付与してMainActorと同じコンテキストにそろえました。このように単にMainActorにできていなかったというケースは非常に多く、機械的に修正可能です。
2. クロージャ内がnonisolatedになるケース
次に、クロージャ内がnonisolatedになるケースを紹介します。クロージャ内のコンテキストは利用側と同じ場合もあれば、異なる場合もあります。コード例を用いて説明します。

通常のクロージャは利用側のクラスのコンテキストを引き継ぎます。上記のコード例はMainActorのクラスなのでクロージャ内もMainActorです。一方、Sendableを付与したクロージャはnonisolatedと判断されます。nonisolatedのコンテキストでMainActorのプロパティを変更しているので警告が出ます。この警告を解決するにあたって、実務で遭遇したケースを紹介します。
Sendableクロージャにおけるケースの実例
FAANSアプリではKingfisherという画像ダウンロードライブラリを利用しています。downloadImageメソッドで画像をダウンロードし、後続処理をクロージャ内で実装しています。発生した警告についてコード例とともに説明します。

クロージャ内でCapture of 'self' with non-Sendable type 'Downloader?' in a '@Sendable' closureの警告が出ました。はじめに示した例ではMainActorのクラスでSendableクロージャを利用しましたが、このケースではクラスにMainActorが付与されていません。Sendableクロージャで扱う値はSendableである必要があるので、Non-Sendableのselfをキャプチャしたことで警告が出ました。
ViewModelクラスはダウンロードした値を格納し、状態を持つのでSendableにするのは難しい状況です。実際のコードではUIViewControllerがこのクラスを保持しています。UIViewControllerはMainActorなのでViewModelクラスもMainActorにしました。
しかし、Sendableのクロージャはnonisolatedと判断されるため、nonisolatedのコンテキストでMainActorのプロパティを変更できないという警告が新たに出ました。

解決方法1. Task { @MainActor in } の利用
nonisolatedのクロージャ内でTask { @MainActor in }を利用してMainActorのコンテキストで値をセットするように変更しました。
@MainActor class ViewModel { var coverImage: UIImage? func setImage(url: String) { guard let imageURL = URL(string: url) else { return } KingfisherManager.shared.downloader.downloadImage( with: imageURL ) { [weak self] result in // クロージャ内はnonisolatedとして推論 switch result { case .success(let value): // MainActorのコンテキストに切り替える Task { @MainActor in self?.coverImage = value.image } case .failure: break } } } }
解決方法2. async関数の利用
ライブラリによっては同じ内容のメソッドのasync版が提供されている場合があります。KingfisherのdownloadImageメソッドにもasync版があります。ネストを減らせて可読性が向上し、Sendableクロージャを考慮する必要がなくなります。
@MainActor class ViewModel { var coverImage: UIImage? func setImage(url: String) { guard let imageURL = URL(string: url) else { return } // Taskはコンテキストを引き継ぐのでTask { @MainActor in } としなくてもよい Task { [weak self] in do { let result = try await KingfisherManager.shared.downloader .downloadImage(with: imageURL) self?.coverImage = result.image } catch { // エラー処理 } } } }
FAANSアプリはasync対応を進めているので、この解決方法2を採用しました。一方、解決方法1を採用しているケースも存在します。例えば、KVOのobserveで値を監視し、そのクロージャ内でViewの更新処理を行っている箇所がその一例です。observeのchangeHandlerはSendableクロージャなので、クロージャ内でTask { @MainActor in }を使ってMainActorのコンテキストに切り替えました。
private var observation: NSKeyValueObservation? private func setupCollectionViewContentSizeObserver() { // UICollectionViewのcontentSizeをobserveで監視 observation = collectionView.observe( \.contentSize, options: [.new] ) { _, change in // クロージャ内はnonisolatedなのでMainActorのコンテキストに切り替える Task { @MainActor [weak self] in guard let contentSize = change.newValue else { return } self?.onCollectionViewContentHeightDidChange?(contentSize.height) } } }
3. nonisolatedなプロトコルをMainActorで実装するケース
次は、nonisolatedなプロトコルを利用するケースです。プロトコルがnonisolatedとして定義されていますが、実装側がViewControllerなどでMainActorになっている場合に出る警告です。具体例を見ていきましょう。
QRコードの読み取りのためにQRScannerというライブラリを利用しています。QRScannerViewDelegateをMainActorのQRCodeScannerViewControllerで実装すると2つの警告が出ました。

1つ目はQRScannerViewDelegateプロトコルへの適合に関する警告です。MainActorに隔離されたコードを跨いでおり、データ競合を引き起こす可能性があると指摘されています。
2つ目はMainActorに隔離されたインスタンスメソッドがnonisolatedの要求を満たせていない、という警告です。
つまり、プロトコルはnonisolatedであるため実装側のMainActorにコンテキストをそろえられません。その解決方法を自作プロトコルと、ライブラリのプロトコルの2つのケースで説明します。
自作プロトコルのケース
QRScannerViewDelegateは変更できませんが、自分で定義したプロトコルだと比較的簡単に解決できるケースが多いです。キーボードの表示/非表示に合わせて処理を実行するKeyboardShowableプロトコルの例をコードとともに説明します。キーボードに関する操作なのでMainActorのViewControllerで利用しています。しかしprotocol側はMainActorではなくてnonisolatedなので同じように警告が出ました。

解決方法として、protocol KeyboardShowableにMainActorを付与します。
// @MainActorを付与してKeyboardShowableをMainActorのコンテキストにする @MainActor protocol KeyboardShowable { func keyboardWillShow(_ notification: Notification) func keyboardWillHide(_ notification: Notification) } extension UploadCodeViewController: KeyboardShowable { func keyboardWillShow(_ notification: Notification) { buttonsBackgroundView.isHidden = true } func keyboardWillHide(_ notification: Notification) { buttonsBackgroundView.isHidden = false } }
KeyboardShowableプロトコルはUIに関することなのでMainActorで利用されると考えてよく、MainActorを付与することで利用側とコンテキストを一致させました。このケースのように自作プロトコルにおいてはプロトコル側の修正をすることで対応が可能です。
ライブラリのプロトコルのケース
QRScannerViewDelegate等、ライブラリ側の修正ができないケースについて説明します。MainActorのコンテキストに合わせられないので、実装側のメソッドにnonisolatedを付与してライブラリ側のコンテキストに合わせました。しかしnonisolatedのコンテキストでMainActorのメソッドを呼び出している箇所があり、コンテキスト不一致の警告が新たに発生しました。

解決方法として、Sendableクロージャのケースで紹介した対応と同じように、Task { @MainActor in } を利用してMainActorで実行すると良いでしょう。一方で、Taskを使う処理は非同期に実行されるため、呼び出し元が同期的な完了を前提としている場合は注意が必要です。
extension QRCodeScannerViewController: QRScannerViewDelegate { // nonisolatedを付与 nonisolated func qrScannerView( _ qrScannerView: QRScannerView, didSuccess code: String ) { // Task { @MainActor in ... }を使ってMainActorのコンテキストで非同期実行 // delegateメソッドの呼び出し元が同期的な完了を前提としていないかは確認が必要 Task { @MainActor in ... qrScannerView.stopRunning() ... } } }
実行時にクラッシュするケースの紹介
nonisolatedを付与して問題を解消する方法に加えて、@preconcurrencyによって警告を抑える選択肢もあります。ライブラリ側のconcurrency対応待ちや既存実装の動作確認が済んでいて挙動を変えたくない場合に、Swift 6移行を進めるための暫定策として利用できます。Swift 5のビルドでは問題なく動作していましたが、Swift 6のビルドで実行時にクラッシュするケースがあったので紹介します。
FAANSアプリでは文字を認識する機能があります。AVCaptureSessionを利用して、AVCaptureVideoDataOutputSampleBufferDelegateのcaptureOutput(...)で出力処理の実装をしています。nonisolatedを付与してTask { @MainActor in … }で必要に応じてMainActorのコンテキストに切り替えました。しかしcaptureOutputの引数がNon-SendableでMainActorに渡せない問題がありました。そのため、@preconcurrencyをつけて対応しました。
// @preconcurrencyをつけて警告を消す extension CameraViewController: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate { // nonisolatedをつける必要がなくなる func captureOutput( _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection ) { guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // MainActorのメソッドの処理 detectText(buffer: cvBuffer) } }
Swift 6のビルドで動作確認したところ、クラッシュが発生しました。
クラッシュの原因
クラッシュの原因を説明します。AVCaptureVideoDataOutputSampleBufferDelegateはMainActorのViewControllerで利用されています。一方でcaptureOutput(...)はAVCaptureVideoDataOutputに設定した出力キュー上で呼び出されるため、このメソッドはバックグラウンドスレッドで実行されます。
@preconcurrencyを付与するとnonisolatedをつけなくても警告を抑制できるため、captureOutput(...)をMainActorに隔離されたまま実装できてしまいます。しかし@preconcurrencyはコンパイル時のチェックの緩和であり、実行コンテキストまで変えるわけではありません。
Swift 6でビルドすると、どのスレッドで実行されているかのチェックが強化されています。MainActorに隔離されたメソッドが実際にはバックグラウンドスレッドから呼ばれると、実行コンテキストの不一致としてクラッシュしてしまいました。実際のクラッシュログは次の通りです。
// frame#3, frame#4, frame#5がSwift Concurrencyの実行時チェックに関するフレーム // 現在の実行コンテキストが、そのメソッドに要求されるexecutorと一致しているかを検証 (lldb) bt * thread #35, queue = 'cameraQueue', stop reason = EXC_BREAKPOINT (code=1, subcode=0x1052ff8e4) * frame #0: 0x00000001052ff8e4 libdispatch.dylib`_dispatch_assert_queue_fail + 120 frame #1: 0x000000010533601c libdispatch.dylib`dispatch_assert_queue$V2.cold.1 + 116 frame #2: 0x00000001052ff868 libdispatch.dylib`dispatch_assert_queue + 108 frame #3: 0x0000000186fc903c libswift_Concurrency.dylib`_swift_task_checkIsolatedSwift + 48 frame #4: 0x0000000187028744 libswift_Concurrency.dylib`swift_task_isCurrentExecutorWithFlagsImpl(swift::SerialExecutorRef, swift::swift_task_is_current_executor_flag) + 356 frame #5: 0x0000000186fc8d88 libswift_Concurrency.dylib`_checkExpectedExecutor(_filenameStart:_filenameLength:_filenameIsASCII:_line:_executor:) + 60 frame #7: 0x00000001af2249c4 AVFCapture`-[AVCaptureVideoDataOutput _processSampleBuffer:] + 300 frame #8: 0x00000001af2246f8 AVFCapture`__47-[AVCaptureVideoDataOutput _updateRemoteQueue:]_block_invoke + 88 frame #9: 0x00000001b2b1c1d8 CMCapture`__FigRemoteOperationReceiverCreateMessageReceiver_block_invoke + 104 frame #10: 0x00000001b2fb7424 CMCapture`__rqReceiverSetSource_block_invoke + 260 frame #11: 0x00000001053162e0 libdispatch.dylib`_dispatch_client_callout + 16 frame #12: 0x00000001053000d8 libdispatch.dylib`_dispatch_continuation_pop + 672 frame #13: 0x000000010531618c libdispatch.dylib`_dispatch_source_latch_and_call + 448 frame #14: 0x0000000105314cd4 libdispatch.dylib`_dispatch_source_invoke + 872 frame #15: 0x0000000105304988 libdispatch.dylib`_dispatch_lane_serial_drain + 344 frame #16: 0x00000001053057d4 libdispatch.dylib`_dispatch_lane_invoke + 432 frame #17: 0x0000000105311b20 libdispatch.dylib`_dispatch_root_queue_drain_deferred_wlh + 344 frame #18: 0x00000001053111c4 libdispatch.dylib`_dispatch_workloop_worker_thread + 752 frame #19: 0x00000001e56b13b8 libsystem_pthread.dylib`_pthread_wqthread + 292
安易に@preconcurrencyをつけて警告を無視すると、実行時にクラッシュするリスクがあるので注意が必要です。
Combineでコンテキスト不一致によるクラッシュのケース
別のクラッシュ事例として、Combineの使用箇所で発生したケースを紹介します。API通信は基本的にasyncメソッドへ移行していますが、一部ではまだCombineを使っています。バックグラウンドで発火したPublisherの結果をMainActor隔離のViewModelでsinkしている箇所がありました。このsinkのクロージャ実行直前でクラッシュが発生しました。
URLSession.shared.dataTaskPublisherでバックグラウンドから取得した値を、MainActor隔離クラスのsinkで受け取るコード例を紹介します。
struct ImageDownloader { // バックグラウンドスレッドでダウンロード後にPublisherを返す func dataTaskPublisher(for url: URL) -> AnyPublisher<Data, Error> { return URLSession.shared.dataTaskPublisher(for: URLRequest(url: url)) .tryMap { (output) -> Data in guard let urlResponse = output.response as? HTTPURLResponse else { throw ImageDownloaderError.unknown } switch urlResponse.statusCode { case 200..<300: return output.data default: throw ImageDownloaderError.responseError } } .eraseToAnyPublisher() } } @MainActor class MainActorViewModel { private var cancellables: Set<AnyCancellable> = [] func download() { // dataTaskPublisherを実行するだけではクラッシュしない let publisher = ImageDownloader().dataTaskPublisher( for: URL(string: "...")! ) // sinkのタイミングでクラッシュする publisher .sink( receiveCompletion: { _ in }, receiveValue: { data in // .. ダウンロード後の処理 } ) .store(in: &cancellables) } }
クラッシュの原因は、メインスレッドに戻すための.receive(on: DispatchQueue.main)をsinkの前に挟み忘れていたことでした。Swift 6でビルドすると、Combineにおいても実行時のコンテキストのチェックが強化されています。しかし、sinkのクロージャの実行コンテキストはコンパイラが静的に追跡できないため、警告が出ないケースもあります。.receive(on: DispatchQueue.main)をsinkの直前に挿入することで、sinkクロージャの実行コンテキストをメインスレッドに切り替え、クラッシュを回避できます。
@MainActor class MainActorViewModel { private var cancellables: Set<AnyCancellable> = [] func download() { let publisher = ImageDownloader().dataTaskPublisher( for: URL(string: "...")! ) // .receive(on: DispatchQueue.main)でメインスレッドに切り替える publisher .receive(on: DispatchQueue.main) .sink( receiveCompletion: { _ in }, receiveValue: { data in // .. ダウンロード後の処理 } ) .store(in: &cancellables) } }
sendingキーワードについて
次に、sendingキーワードを説明します。警告の数自体はそれほど多くないのですが、理解がやや難しい警告であるため、ぜひ触れておきたい内容です。FAANSではCombineを用いたAPI通信のasync対応でwithCheckedThrowingContinuationを利用しています。クロージャでcontinuationを受け取り、resumeメソッドを実行する部分があります。リクエストした結果の値(value)をresumeメソッドに渡している箇所で警告が出ました。

Task-isolatedとsending parameterの意味がわかりにくいかもしれません。まずはsendingキーワードをコード例と一緒に説明します。
sendingキーワードは関数の引数に付与できます。sendingを付与した場合、Non-Sendableな値でもActor境界を超えられます。しかし引数として渡した値を呼び出し元で利用できなくなります。実際にコード例として、sendingを付与したreceiveWithSendingメソッドにNon-Sendableのクラスを渡すケースを紹介します。

useCounterAfterSendingメソッドで、counterがsendingパラメータとして渡された後に使用されており、後続の使用によるデータ競合の可能性を示す警告が出ました。一方、sendingメソッドでは呼び出し元でcounterを利用していないので警告が出ていないことを確認できます。
Region Based Isolationについて
sendingに関連する話題として、Region Based Isolationについて説明します。sendingを付与しているとNon-Sendableの値を送れると説明しましたが、実はsendingキーワードをつけなくてもコンパイラが同じように判断します。次のコードのようにsendingを付与していないreceiveWithoutSendingメソッドにNon-Sendableのクラスを渡しても警告が出ません。また、useCounterAfterSendingメソッドでは呼び出し元でcounterを利用しているので先ほどと同じ警告が出ます。

コンパイラが判断するならばsendingキーワードが不要に見えますが、必要になるケースを紹介します。上のコード例ではNonSendableCounterクラスをその場で初期化して渡しているため、安全に受け渡せるとコンパイラが判断します。しかし、少しコードを複雑にするとコンパイラが安全性を判断できなくなるケースがあります。
例えばNonSendableCounterを戻り値の型とするメソッドで値を取得してからその値を渡すと警告が発生しました。そこで、sendingをメソッドの戻り値の型に付与すると警告が解消されることを確認できました。戻り値にsendingをつけることで、その値が所有権ごと安全に受け渡されることを明示できます。

Task-isolatedな値について
次に、Task-isolatedを見ていきましょう。もう一度、はじめに紹介したwithCheckedThrowingContinuationのコード例を紹介します。

continuation.resumeメソッドの引数にsendingキーワードが付与されているので、sending parameterに関する警告が出ています。

しかし、コードを見る限りvalueを受け取りresumeに渡した後は後続で使っていないように見えます。ここで、Task-isolatedなvalueの意味が重要なので説明します。Task-isolatedの警告の別のケースを見てみましょう。

Task-isolatedの警告が出ました。Taskの外から中に送ると、Task-isolatedになるとわかります。Uses in callee may race ... の警告は、Task-isolatedの利用と呼び出し先の利用でデータ競合が起きるかもしれないという意味です。つまり、呼び出し元でどのように使われるかをコンパイラが判断できないので警告が出ます。一方で、Taskの中で変数を定義した場合はスコープが明確なので警告が出ません。
Combineのsinkする値について
Combineのsinkで利用する値はsinkの外で初期化されているため同じくTask-isolatedと判断されます。次のCombineのコード例のように、FutureをsinkするとTask-isolatedの警告を確認できます。

すでに紹介したwithCheckedThrowingContinuationのコード例でも同様にCombineのsinkを利用していたため、Task-isolatedの警告が出ました。警告の解決方法として、送る値をSendableにするか、利用するクラスをMainActorにします。sink内外で同じコンテキストにそろえることで警告が消えます。
その他の警告
最後に、これまでに説明したことを踏まえて解決できる警告を2つ紹介します。
1. passing closure as a 'sending' parameter risks causing data races
sendingパラメータの警告で警告内容がわかりにくいケースを紹介します。

クロージャ自体の原因ではなくキャプチャしている値が原因です。TaskのクロージャにNon-Sendableなselfを渡していますが、selfの利用をTaskの中のみに限定できないので警告が出ています。ClassをMainActorにするか、Sendableに準拠すれば解決します。
2. static property '...' is not concurrency-safe because it is nonisolated global shared mutable state
シングルトンクラスなど、static変数/class変数をnonisolatedのコンテキストで利用しているケースです。

定数のみ利用しているならばstructにするといったリファクタリングで対応可能です。状態を持つケースではMainActorを付与して利用側とコンテキストをそろえることを検討してください。
まとめ
本記事ではSwift 6対応を始める前に知っておくと役立つポイントを紹介しました。警告の大半はSendable非準拠とコンテキストの不一致の2種類でした。特にコンテキストの不一致がSwift 6移行における最重要ポイントです。また、Swift 6でビルドが成功しても、実行時のクラッシュが発生するリスクは残っているので動作確認も大事です。本記事が移行作業の一助になれば幸いです。
さいごに
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。