Swift 6に向けた準備:Strict Concurrency CheckingをTargeted設定にした際に発生した問題と解決方法

Strict-Concurrency-Checkingのタイトル画像

こんにちは、フロントエンド部の中島です。FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」です。「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」で2022年8月に正式ローンチしました。

はじめに

FAANS iOSチームではAPI通信においてSwift Concurrencyを利用しています。Swiftに限らず並行処理を扱う場合には実装次第でデータ競合を起こす恐れがあるのに対して、Swiftではデータ競合を防ぐ仕組みとしてActorが導入されています。そして、Actor間で扱うデータがデータ競合を起こさない型であるかコンパイラでチェックされます。Swift 6ではこのデータ競合のチェックにより既存のコードでコンパイルできなくなる可能性があります。Xcode 14ではSwift 6までの間に段階的な移行ができるようにStrict Concurrency Checkingでコンパイラのチェックレベルを指定できるようになりました。本記事ではFAANS iOSチームで実施したStrict Concurrency Checkingの対応と、その過程で得られた知見について紹介します。

目次

Strict Concurrency Checkingについて

Strict Concurrency CheckingはMinimal, Targeted, Completeの3つのレベルがあります。XcodeのBuild Settingsで設定が可能です。 Strict-Concurrency-Chcekingの設定

Minimal, Targeted, Completeの定義は次の通りです。

  • Minimal: Swift Concurrencyの利用箇所で、明示的にSendableと書いている箇所でSendable制約とアクター隔離のチェックをします。
  • Targeted: Swift Concurrencyの利用箇所で、Minimalに加え、明示的にSendableと書いていない箇所でもSendable制約とアクター隔離のチェックをします。
  • Complete: Swift Concurrencyの利用に関係なく、モジュール全体を通してSendable制約とアクター隔離のチェックをします。

参考:https://developer.apple.com/documentation/xcode/build-settings-reference#Strict-Concurrency-Checking

Minimalはデフォルト設定です。Sendableを書いていない場合はチェックされず今までと変わらず問題なくビルドできます。Targetedでは並行処理を使用している部分でチェックが入ります。Completeでは全ての箇所でチェックが入ります。レベルによってSendableに準拠しているかどうかのチェックの範囲が広がります。Completeではコンパイルエラーが多く問題の切り分けが難しかったため、FAANS iOSチームではTargetedに変更することから始めました。

Minimal-Targeted-Complate設定

Sendableについて

Sendableは並行タスク間でデータ競合が起こらないよう、安全に共有できる型を表すプロトコルです。暗黙的にSendableに準拠するケースと、明示的にSendableを付与するケースがあります。

  • 暗黙的にSendableに準拠するケース

    • publicではないstruct, enumでSendableなプロパティのみを保持
    • Int, String, Dictionay, Arrayなど
    • actor
    • @MainActorを付与したclass
  • 明示的にSendableを付与するケース

    • class
    • publicなstruct、enum

classは次の条件を満たすことで準拠できます。

  • Sendableを明示的に付与
  • finalを付与
  • mutableであるvarを利用しない
  // mutableなvar nameを持つと警告が出る
  final class SendableClass: Sendable {
    var name = "FAANS" // ⚠️ Stored property 'name' of 'Sendable'-conforming class 'SendableClass' is mutable
  }

  // 条件を全て満たすのでSendableに準拠しており、警告が出ない
  final class SendableClass: Sendable {
    let name = "FAANS"
  }

また、クロージャーに@Sendableを明示的に付与した場合、そのクロージャーでキャプチャする値はSendableに準拠する必要があります。Task.initなど、Sendableなクロージャーで定義されているケースもあります。

Sendableに準拠していないケースは次の通りです。

  • NSAttributeString等のSendableに準拠していないプロパティを保持する値型
  • mutableな値を持つ参照型(classなど)

Strict Concurrency Checkingの設定により、アクター間でやり取りされるデータがSendableに準拠しているかどうかをコンパイラでチェックできます。

参考:https://developer.apple.com/documentation/swift/sendable

Sendableチェックによる警告と解消方法

MinimalからTargetedに変更したことで発生した警告は主に次の2つです。ともにSendableのチェックですが警告発生パターンが異なるのでコード例と共に説明します。

  • Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある
  • Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある

Case 1 : アクター隔離されたコンテキストにおいてアクター境界を超えてデータを取得する場合、取得データの型はSendableに準拠する必要がある

FAANSアプリはショップスタッフの情報である、StaffMemberをasync/awaitを利用して取得します。また、OSSライブラリを利用してAPIClientを実装しており、StaffMemberはpublicのstructになっています。

簡易的ではありますがViewModel経由でAPIClientの非同期関数を呼び出し、StaffMemberを取得する例で説明します。ViewModelで実行するviewDidLoad関数はviewとやり取りをするために@MainActorを付与してメインスレッドで実行しており、アクター隔離された関数になっています。APIClientの実装の詳細は省略しています。

FAANS-iOSのAPI通信のフロー

具体的なコードは次の通りです。StaffMemberを取得するところで警告が出ています。

import SwiftUI

struct ContentView: View {
    // ViewはViewModelを保持
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        Text(viewModel.staffMember?.name ?? "Loading")
        .onAppear() {
            // 画面表示時に実行
            viewModel.viewDidLoad()
        }
    }
}

final class ViewModel: ObservableObject {
    // APIClientで非同期通信を行う
    private let apiClient = APIClient()
    @Published private(set) var staffMember: StaffMember?

    // アクター隔離(main actor-isolated)のメソッド
    @MainActor func viewDidLoad() {
        Task {
            // 次の警告が出る
            // Non-sendable type 'StaffMember' returned by call from actor-isolated context
            // to non-isolated instance method 'getStaffMember()' cannot cross actor boundary
            staffMember = await apiClient.getStaffMember()
        }
    }
}

// StaffMemberを非同期で取得するAPIClient
final class APIClient {
    // アクター非隔離のメソッド
    func getStaffMember() async -> StaffMember {
        return StaffMember()
    }
}

// 外部コードであるためpublicがついている
public struct StaffMember {
    var name = "FAANS staff"
}

上記のコードで、Sendableに準拠していないStaffMember型がアクター境界を超えることはできないという警告が出ました。

// Non-sendable type 'StaffMember' returned by call from actor-isolated context
// to non-isolated instance method 'getStaffMember()' cannot cross actor boundary

アクター隔離されている状態(actor-isolated context)は密室な部屋にいる状態と例えることができます。部屋の中では自由にデータのやり取りが可能ですが部屋の出入りが必要な場合、つまりアクター境界を超える場合、データの型はSendableに準拠する必要があります。ViewModelのviewDidLoad関数は@MainActorが付与されてアクターに隔離されたコンテキストで関数が実行されます。APIClientのgetStaffMember関数はアクター境界の外で実行される関数です。

アクター境界をまたぐ例

解決1:public structをSendableに準拠させる

値型であるstructは暗黙的にSendableに準拠します。publicがつく場合は明示的にSendableをつけないといけないので、外部コードでpublic structを利用している場合は警告が出てしまいます。解決するにはpublicなstructをSendableに準拠させる必要があります。次のようにSendableを付与することで警告をなくすことができます。

// Sendableをつける
public struct StaffMember: Sendable {
  // プロパティはStringやInt等、Sendableに準拠したもの
}

解決2:@preconcurrencyアノテーションをimport文に付与する

直接ファイルを編集できない場合は、外部コードのimport箇所で次のように@preconcurrencyを付与することで警告を出さないようにできます。FAANS iOSではこの対応を実施しました。

// Sendableチェックを無視することができる
@preconcurrency import APIModels

Case 2 : @Sendableが付与されたクロージャーのキャプチャ対象の型はSendableに準拠する必要がある

Case 2はクロージャーのキャプチャ対象はSendableに準拠する必要があることについて考えます。Case 1では1つのリクエストでしたが、async letを利用して複数のタスクを並列で実行すると次の警告が出ます。

Sendableチェックの警告

class ViewModel: ObservableObject {
    private let apiClient = APIClient()
    @Published private(set) var staffMember: StaffMember?
    @Published private(set) var shop: Shop?

    @MainActor func viewDidload() {
        Task {
            // async letでリクエストを並列で実行
            async let staffRequest = self.apiClient.getStaffMember()
            async let shopRequest = self.apiClient.getShopInfo()
            self.staffMember = await staffRequest
            self.shop = await shopRequest
        }
    }

    final class APIClient {
        func getStaffMember() async -> StaffMember {
            return StaffMember()
        }

        // ショップ情報を取得
        func getShopInfo() async -> Shop {
            return Shop()
        }
    }

    public struct StaffMember {
        var name = "FAANS staff"
        var age = 25
    }

    public struct Shop {
        var name = "FAANS shop"
    }
}

async letの行で次の警告が出ます。

Capture of 'self' with non-sendable type 'ViewModel' in a `@Sendable` closure

Case 1では出ない警告がasync letを利用すると出るようになりました。Task {}のクロージャーはSendableに準拠する必要がありますが、キャプチャしたViewModelはSendableに準拠していないという警告です。Case 1で警告が出なかった理由は、Task {}のクロージャーは呼び出し元の実行コンテキストを引き継ぐ性質によるものです。

Taskで実行コンテキストを引き継ぐ例

一方でCase 2に関しては、async letは子タスクを作成して実行元と異なるコンテキストで実行されます。また、async letの右辺は暗黙的な@Sendableのクロージャーのような振る舞いをするので、Sendableチェックがされることで警告が出ています。

参考:https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md#proposed-solution

async-letによる警告

動的な個数のタスクを並列で実行するwithTaskGroupもasync letと同様に子タスクを作成し、addTask関数がSendableなクロージャーなので警告が出ます。

withTaskGroupの警告

対応方法

ViewModelはObservableObjectを継承したクラスでViewの値を監視する役割を持ち、staffMemberをmutableな値として保持しています。明示的にSendableを付与することでの解決が難しいです。Case 1では関数に@MainActorを付与していましたが、Case 2ではクラスのはじめに@MainActorをつけることで解決しました。@MainActorを付与したクラスは暗黙的にSendableに準拠するので警告をなくすことができます。

参考:https://developer.apple.com/documentation/swift/sendable#Sendable-Classes

// @MainActorをクラスのはじめにつける
@MainActor
final class ViewModel: ObservableObject {
    ...
}

この対応によりself参照におけるSendableチェックの警告をなくすことができました。しかし、今度はapiClientがSendableに準拠していないという警告に変わります。

Non-sendable type 'APIClient' in asynchronous access 
to main actor-isolated property 'apiClient' cannot cross actor boundary

ViewModelクラス全体をアクター隔離したのでViewModelが保持するapiClientもアクター隔離されます。しかし、async letを利用して異なるコンテキストで実行された結果、アクター境界を超えるのでapiClientがSendableに準拠する必要があります。今回の例ではAPIClientクラスはmutableな値を持たず、final classなのでSendableをつけることで解決ができます。

// Sendableをつける
final class APIClient: Sendable {
    func getStaffMember() async -> StaffMember {
        return StaffMember()
    }

    func getShopInfo() async -> Shop {
        return Shop()
    }
}

比較的簡単な例を示しましたが、APIClientがリクエストに必要なヘッダー等をmutableな値で持ち、classのままではSendableに準拠できない可能性があると思います。FAANS iOSではその箇所が外部コードになっており、@preconcurrencyをつけて解決していますが、直す場合はactorやstructを利用して解決する方法があると思います。今後ライブラリ側でどのような対応がされるかチェックしていきたいと思います。

まとめ

Swift Concurrency CheckingをTargetedに設定した際、かなりの警告が出ましたが紐解いてみると同じパターンで警告が出ているだけで、芋づる式に解決できることが多かったです。同じ問題に遭遇した方や今後同様の対応をする方でこの記事が参考になれば幸いです。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。

corp.zozo.com

カテゴリー