ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

【Xcode16】【iOS18】UIViewControllerを継承したクラスの同期的な静的メソッドを非同期呼び出しするとクラッシュする問題の解決

はじめに

記事を開いていただきありがとうございます。iOSエンジニアの坂田です。

今回は、Xcodeを15から16にアップデートした際に発生したクラッシュの原因とその対策方法についてご紹介させていただきます。

Xcode16にアップデートを予定している方、すでにアップデート対応中で同じようなエラーに悩まされている方の一助になれば幸いです。

実行環境

今回紹介する現象が発生した環境は以下になります。

  • mac OS Sonoma 14.6.1
  • Xcode 16.0 (16A242d)
  • Swift6 (ただし、Swift Language VersionをSwift5にして使用)
  • iOS18

発生した事象

Xcode16でビルドしたアプリをiOS18の端末で起動し、UIViewControllerを継承したサブクラスの同期的な静的メソッドをTaskの中で呼び出したところ、アプリがクラッシュしてしまう現象が発生しました。

実際のコードとは異なりますが、以下のような処理を行っていました。

final class SomeViewController: UIViewController {
    static function staticMethod() {
        // 何らかの処理
    }
}

Task {
    // 非同期処理の中で同期メソッドを呼び出す
    some()
}

func some() {
    SomeViewController.staticMethod()  // ここが実行される際にクラッシュ
}

Firebase Crashlyticsを見ると、以下のレポートが出力されていました。

クラッシュレポート

こちらのキーはメインスレッドで実行されることが期待されていた処理が他のスレッドで実行されようとした時に出力されるようです。

 BUG IN CLIENT OF LIBDISPATCH: Assertion failed: Block was expected to execute on queue [com.apple.main-thread (0xXXXXXXXXX)]

調査内容

クラッシュレポートのKeysに出力されているメッセージは「ブロックは、"com.apple.main-thread"キューで実行されることを期待されていた」と和訳する事ができるため、クラッシュの原因は実行スレッドに問題があると予測しました。また、UIViewControllerには@MainActorが付与されており、サブクラスもその性質を引き継ぐことから、メインアクター上で実行されない事が問題であると予測しました。

しかし、私はアクターに関する問題はコンパイル時にチェックされることを期待していたため、ランタイムエラーとなってしまうことに違和感を感じました。

そのため、コンパイルエラーが出なかった原因を探るべく、5つのパターンのコードを実装しそれぞれどういった挙動をするのか調査を行いました。

パターン1. @MainActorを付与した自作クラスを使用する場合

まず、@MainActorを付与した何も継承しないクラスHogeを作成し、そのクラスに静的メソッドstaticMethod()を定義しました。それをアプリがクラッシュを発生させていたメソッドと同じ箇所で呼び出しました。

結果、コンパイルエラーが出力され実行することができませんでした。

@MainActor class Hoge {
    static func staticMethod() {}
}

Task {
    some()
}

func some() { // アプリがクラッシュした時に、UIViewControllerを継承したサブクラスの静的メソッドを実行していたメソッド
    Hoge.staticMethod() // Call to main actor-isolated static method 'staticMethod()' in a synchronous nonisolated context というエラーが出力される
}

パターン2. @MainActorを付与した自作クラスを継承したサブクラスの場合

次に、@MainActorが付与されたクラスに直接静的メソッドを定義するのではなく、@MainActorを付与したクラスHogeを継承したサブクラスFugaに静的メソッドstaticMethod()を定義し、パターン1と同じ箇所で呼び出しました。

結果、パターン1と同じコンパイルエラーが発生しました。

@MainActor class Hoge {} // @MainActorを付与したベースクラス

class Fuga: Hoge { // Hogeを継承
    static func staticMethod() {}
}

Task {
    some()
}

func some() { // アプリがクラッシュした時に、UIViewControllerを継承したサブクラスの静的メソッドを実行していたメソッド
    Fuga.staticMethod() // Call to main actor-isolated static method 'staticMethod()' in a synchronous nonisolated context というエラーが出力される
}

パターン3. @MainActorを付与したベースクラスを外部モジュールで定義し、それを継承したサブクラスの場合

パターン2ではベースクラスHogeもサブクラスFugaもアプリターゲット内で作成していましたが、ベースクラスHogeのみ外部モジュールに定義し、それをアプリターゲット内でimportして使用し、パターン1,2と同じ箇所で呼び出しました。

結果、こちらもパターン1,2と変わらずコンパイルエラーが発生しました。

// 外部モジュールMyLibrary内で実装
@MainActor open class Hoge {} // @MainActorを付与したベースクラス

// アプリターゲット
import MyLibrary
class Fuga: Hoge { // Hogeを継承
    static func staticMethod() {}
}

func some() { // アプリがクラッシュした時に、UIViewControllerを継承したサブクラスの静的メソッドを実行していたメソッド
    Fuga.staticMethod() // Call to main actor-isolated static method 'staticMethod()' in a synchronous nonisolated context というエラーが出力される
}

パターン4. UIViewController以外のUIクラスを継承したサブクラスの場合

次に、iOSアプリ開発でよく使われている以下のクラスを継承したサブクラスSomeUIClassを作成し、SomeUIClassに静的メソッドを定義し、前のパターンと同じ箇所で呼び出しました。

結果、上記のどのクラスを継承していても、コンパイルエラーは出力されず実行時にアプリはクラッシュしました。

・ベースクラスとして試してみたクラス一覧

  • UIViewController
  • UINavigationController
  • UIView
  • UIImageView
  • UIStackView
  • UIButton
  • UILabel
  • UICollectionView
  • UITableView
  • UITableViewCell
  • UICollectionViewCell
class SomeUIClass: UIViewController { // ベースクラスを上記のリストに置き換えてそれぞれ実行
    static func staticMethod() {}
 }

func some() { // アプリがクラッシュした時に、UIViewControllerを継承したサブクラスの静的メソッドを実行していたメソッド
    SomeUIClass.staticMethod() // コンパイルエラーは出力されず、実行した時点でアプリはクラッシュ。
}

パターン5. @MainActor@preconcurrencyを付与したクラスの場合

最後に、静的メソッドを定義するクラスHogeに@MainActorに加えて@preconcurrencyとを付与し、同じ箇所で呼び出しました。

結果、コンパイルエラーは出力されず、実行時にアプリはクラッシュしてしまった。

@preconcurrency @MainActor class Fuga {
    static func staticMethod() {}
} 

func some() { // アプリがクラッシュした時に、UIViewControllerを継承したサブクラスの静的メソッドを実行していたメソッド
    Fuga.staticMethod() // コンパイルエラーは出力されず、実行した時点でアプリはクラッシュ。
}

調査結果

上記の調査内容から、コンパイルエラーが出ない原因は@preconcurrencyが付与されているかが関係していそうです。ですが、クラッシュが発生したUIViewControllerを継承したクラスには@preconcurrencyをつけていませんでした。 しかし、調査を続けた結果、こちらのプロポーザルに、

Objective-C declarations are always imported as though they were annotated with @preconcurrency.

と記載されていることからObjective-Cからの宣言は常に@preconcurrencyが付与された状態でimportされることがわかります。また、UIKitは主にObjective-Cで実装されている可能性が高いため、UIViewControllerを継承したクラスは@preconcurrencyが付与されているクラスと同じ状態になっていたと予測でき、それが原因でコンパイルエラーが出力されなかったと考えられます。

クラッシュの原因

コンパイルエラーが出力されない理由がわかりましたので、こちらのセクションではクラッシュの原因およびXcode16でビルドしたアプリかつiOS18で実行した場合のみ起こるのかの理由を説明します。結論として、Dynamic actor isolation enforcement from non-strict-concurrency contextsで提案された動的なアクター隔離のチェックが原因である可能性が高いです。

動的なアクター隔離チェックの対象は、こちら

dynamic actor isolation is only performed for synchronous functions that are witnesses to an explicitly annotated @preconcurrency protocol conformance, or that are compiled under the Swift 6 language mode.

と記載されてあることから、Swift6モードでコンパイルされた同期関数または@preconcurrencyが付与された同期関数が対象となります。

また、こちらのチェックはSwift6で実装されたため、Swift6が使用できるようになったXcode16より前のバージョンのXcodeでビルドしたアプリではチェックされません。

さらに、iOS18でのみ発生する理由としては、 こちらのサイト

The runtime version is the version of the Swift runtime you're using, which is determined by the version of iOS your app is actually running on.

と記載されていることから端末で使用されるランタイムはiOSのバージョンによって異なり、かつiOS17リリース時点でSwift6はまだリリースされていないことから、iOS17以下の端末ではSwift6ランタイムを使用しておらず、このチェックが働かないからだと考えられます。

よってXcode16でビルドしたアプリかつiOS18で実行した場合のみチェックが働き、対象となる同期関数を実行する際に実行するアクターが想定と異なった場合、違反とみなされアプリがクラッシュします。

今回クラッシュが発生したクラスはUIViewControllerを継承しているため@MainActor@preconcurrencyの性質を引き継ぎ、かつ静的メソッドは同期関数だったためこのチェックの対象となる事がわかりました。

実際に、このアクター隔離チェックを無効にする-disable-dynamic-actor-isolationフラグを有効にして実行した場合、クラッシュが発生しなくなっている事が確認できました。

対応策

次に、この問題についてクラッシュを回避するための対応策を4つご紹介します。

対応策1. 何も継承せず、@MainActorも付与されていないクラスを作成し、メソッドをそちらへ移す

メインアクターで隔離されないクラスを作成しそちらにメソッドを定義することで、実行中にメインスレッドで実行されることを保証されないようにしました。

ローソンアプリではこちらの対応策を実施しました。

class XXViewController: UIViewController {
-    static func someMethod() { /* ... */ } // 対象のメソッドをUIViewControllerのサブクラスから削除
}

+class XXHelper {
+    static func someMethod() { /* ... */ } // UIViewControllerのサブクラスから新規作成したクラスにメソッドを移動
+}

func some() { // クラッシュが発生していたメソッド
-    XXViewController.someMethod()
+    XXHelper.someMethod()  // 呼び出し元を新規作成したクラスに変更
}

対応策2.アクター外で使用したいメソッドにnonisolatedを付与

メソッドにnonisolatedを付与することでメインアクター隔離の対象外とする事ができるのでこちらの方法も有効です。

対応策1と違ってメソッド定義用のクラスを増やさずに済みますが、nonisolatedをつけ忘れると同じクラッシュが発生してしまうため、管理コストが高くなる可能性があります。

class XXViewController: UIViewController {
-    static func someMethod() { /* ... */ } 
+    nonisolated static func someMethod() { /* ... */ } // 対象のメソッドにnonisolatedを付与
}

対応策1,2に関してはどちらもメソッドをメインアクター隔離の対象から外すアプローチになるため、メソッドがミュータブルな変数にアクセスしている場合同時アクセスが発生する可能性があることに注意が必要です。

対応策3.-disable-dynamic-actor-isolationフラグを有効にする

※この対応は個人的にはお勧めしません。

このフラグを有効にすることで、動的なアクター隔離チェックを無効にする事ができるためクラッシュを防ぐ事ができます。

しかし、このフラグは特定のコードではなくアプリ全体で有効になるため、影響範囲が広がることには注意が必要です。また、アクター隔離に違反しているコードを無理矢理動かしていることになるため、将来的には-disable-dynamic-actor-isolationフラグを無効にし、コードを修正することをお勧めします。

対応策4. Swift Language VersionをSwift6にアップデートする

アプリターゲットのSwift Language VersionをSwift6にアップデートすることでコンパイルエラーとして検出できるため、実行時にクラッシュして初めて気づくといったことは無くなります。しかし、Swift6にするにはConcurrency Checkingへの対応などマイグレーションが必要なため、すぐに対応を完了させる事ができないアプリも多いと思います。

早急にクラッシュを回避する必要がある場合は先に挙げた対応策1,2いずれかで対応することをお勧めします。

最後に

およそ5年ぶりのSwiftのメジャーアップデートによって、Swift Concurrencyに関するものを中心に様々な機能の追加や変更がリリースされました。

それらに対応する事はとても大変だとは思いますが、1つ1つ対処しできるだけ早くSwift6での開発環境を整える必要があると強く感じました。

今後もローソンデジタルイノベーションでは技術ブログを更新していきますので、是非「読者になる」で応援していただけますと幸いです。