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

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

【Xcode16】【iOS18】CombineでcompactMapを使用するとアプリがクラッシュしてしまう問題とその解決方法

はじめに

こんにちは、iOSエンジニアの坂田です。 以前、【Xcode16】【iOS18】UIViewControllerを継承したクラスの同期的な静的メソッドを非同期呼び出しするとクラッシュする問題の解決 というタイトルでブログを投稿させていただきましたが、その他にもXcode16アップデート時に発生したクラッシュがありましたので、今回はそちらをご紹介させていただこうと思います。

実行環境

問題が発生した環境は以下になります。(前回投稿した時と同じです。)

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

発生した事象

ローソンアプリでは、MVVMアーキテクチャを採用しており、ViewModelとViewControllerのデータバインディングにはCombineを使用しています。 その際、ViewModelから発行された値をViewController側で購読する前にcompactMapを使用し加工していたのですが、Xcode16にアップデート後、iOS18の端末でアプリを動作させるとその購読処理の実行時にクラッシュしてしまうという現象が発生しました。

ローソンアプリではメインスレッドを指定して購読する処理を簡単に書けるよう、sinkWithMainThreadというメソッドを作成していたため、問題が発生した際の処理の順番は以下のようになっていました。

  1. compactMapで値を加工
  2. メインスレッド指定
  3. 購読処理

コードは以下になります。(具体的な処理はぼかしてあります。)

final class SomeViewModel {
    let publisher = PassthroughSubject<String?, Never>()
}

final class SomeViewController: UIViewController {
    let viewModel = SomeViewModel()
    
    func bind() {
        viewModel.publisher
            .compactMap { $0 }
            .sinkWithMainThread { /* なんらかの購読処理 */ }
    }
}

調査内容

購読前にメインスレッド指定を行っていることから、この問題もメインスレッドが関わる問題だと予想し、以下のパターンでクラッシュが発生するかの調査を行いました。

パターン1. compactMapの前にメインスレッド指定を行う

compactMapとsinkの間でメインスレッド指定が行われていたため、その指定処理をcompactMapの前に実行するように修正しました。結果、こちらは問題なく動作しました。

    func bind() {
        viewModel.publisher
            .receive(on: DispatchQueue.main) // ここでメインスレッド指定をする
            .compactMap { $0 }
            .sink { /* なんらかの購読処理 */ } // スレッドを指定しない通常のsinkメソッドに変更
    }
}

パターン2. compactMapを削除する

compactMapを使用せず、sinkクロージャ内でアンラップして使用するよう修正しました。結果、こちらは問題なく動作しました。

    func bind() {
        viewModel.publisher
            // compactMapを消す
            .sinkWithMainThread { value in
                guard let unwrapped = value else { return }
                /* なんらかの購読処理 */ 
            }
    }
}

パターン3. メインスレッド指定をなくす

購読前にメインスレッド指定していたものを外し、クラッシュの原因を切り分けるため購読処理中にUIを触らないように購読処理を変えて実行してみました。結果、こちらは購読処理実行時にアプリがクラッシュする結果となりました。

    func bind() {
        viewModel.publisher
            // メインスレッド指定をしない
            .compactMap { $0 }
            .sink { /* なんらかの購読処理 */ }
    }
}

パターン4. compactMapとメインスレッド指定をなくす

compactMapが原因なのかを切り分けるため、パターン3に加えcompactMapも削除しsinkのみの状態にしました。結果、こちらも購読処理実行時にアプリがクラッシュする結果となりました。

    func bind() {
        viewModel.publisher
            // メインスレッド指定をしない。compactMapも使用しない
            .sink { /* なんらかの購読処理 */ }
    }
}

上記の調査から、compactMapやsinkクロージャの処理がメインスレッドで行われていない時にクラッシュが発生していることがわかりました。

クラッシュの原因

続いて、クラッシュの原因ですが、こちらのクラッシュについても、前回投稿したブログと同様に Dynamic actor isolation enforcement from non-strict-concurrency contextsで提案された動的なアクター隔離のチェックが原因である可能性が高いです。

Combineの購読処理について、こちらのサイトに「The default scheduler uses the same thread from where the element was generated.」と書かれているように、特にスケジューラの指定をしない限りオペレータの処理や購読処理は値が発行されたスレッドと同じスレッドを使用します。ローソンアプリでは、ViewModel側で値を発行する際にメインスレッド指定をしていないのでViewController側で設定されたオペレータや購読処理を実行するスレッドもメインスレッド以外のスレッドで処理される可能性がある状態でした。

そして、こちらのissueにもあるように、@MainActorが指定されたメソッドから引数として別の関数に渡されるクロージャは@MainActorとして推論され、それ以外のアクターで実行されるとクラッシュしてしまうことが確認されています。このissueのコメントに、このクラッシュは動的なアクター隔離のチェックによるものだと記載があり、クロージャも関数と同じくアクター隔離チェックの対象となっているようです。UIViewControllerは@MainActor属性を持っているため、UIViewControllerのメソッド内で定義されたクロージャはメインアクターで処理をしないとクラッシュしてしまうことになります。

よって、今回のクラッシュは、UIViewControllerのメソッドで定義されているcompactMapに渡すクロージャはメインスレッドで実行する必要があるにもかかわらず、compactMapの前にメインスレッドを指定していなかったためcompactMapに渡すクロージャはメインアクター以外で処理される可能性があり、メインスレッド以外で購読処理を実行しようとした際に動的なアクター隔離のチェックに引っかかってしまい発生したと考えられます。

対応策

次に、こちらのクラッシュを回避する対応策を5つご紹介いたします。

対応策1. compactMapの前にメインスレッド指定を行う

上述した調査内容のパターン1の方法になります。ローソンアプリではこちらを実施しました。

    func bind() {
        viewModel.publisher
            .receive(on: DispatchQueue.main) // ここでメインスレッド指定をする
            .compactMap { $0 }
            .sink { /* なんらかの購読処理 */ } // スレッドを指定しない通常のsinkメソッドに変更
    }
}

対応策2. compactMapを削除する

こちらも上述した調査内容のパターン2の方法になります。ただし、こちらの方法だとcompactMapで加工していた内容によっては購読処理の変更量が大きくなってしまう恐れがあるので、この対応策よりは対応策1を実施することをお勧めいたします。

    func bind() {
        viewModel.publisher
            // compactMapを消す
            .sinkWithMainThread { value in
                guard let unwrapped = value else { return }
                /* なんらかの購読処理 */ 
            }
    }
}

対応策3. 購読処理を実装しているメソッドにnonisolatedを付与する

メソッドにnonisolatedを付与することでメインアクター隔離の対象外となるため、アクター隔離チェックに違反することがなくなりクラッシュせず処理を実行することができます。 しかし、メソッド内でViewControllerが保持するプロパティにアクセスしようとすると、メインアクター隔離されたプロパティにnonisolatedなメソッドからアクセスしているという警告(Swift6モードだとエラー)が出てしまいます。そのため、一時的な対応策にはなりますが恒久的な対応にはお勧めできません。

final class SomeViewController: UIViewController {
    let viewModel = SomeViewModel()
    
    // メソッドにnonisolatedを付与することで、このメソッド内はアクター隔離の対象外となる
    nonisolated func bind() {
        viewModel.publisher
            .compactMap { $0 }
            .sinkWithMainThread { /* なんらかの購読処理 */ }
    }
}

対応策4. ViewModel中の値を発行する処理をメインスレッド上で実行するようにする

特にスケジューラを指定しない場合、値の購読やオペレータでの加工処理は値を発行したスレッドと同じスレッドで実行されるので、発行側をメインスレッドで実行させることでクラッシュを回避することができます。 今回問題が発生した箇所では、Taskの中で値を発行していたため、Taskに渡すクロージャに@MainActorを付与することでクラッシュを回避できました。

final class SomeViewModel {
    let publisher = PassthroughSubject<String?, Never>()

    func bind() {
        Task { @MainActor in 
            publisher.send("何らかの文字列")
        }
    }
}

ただし、メインスレッドを乱用することはアプリのパフォーマンスを下げる原因になりますので、メインスレッド上で重い処理をしないなどの注意が必要になります。

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

前回投稿したブログにも書いたように、このフラグを有効にすることで、動的なアクター隔離チェック自体を無効にする事ができるためクラッシュを防ぐ事ができます。 ただし、このフラグは特定のコードではなくアプリ全体で有効になるため、影響範囲が広いことには注意が必要です。

最後に

Xcode16対応時の問題についてのブログはこちらで2つ目ですが、このアップデートによりアクターおよびスレッドの使い方に対して厳格になった印象を受けます。 今回のCombineのように、フレームワークを使用する場合はアクターやスレッドに対する細かい仕様について以前よりもっと気をつけて実装を進めていくべきだと強く感じました。

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