RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

既存プロジェクトへの Swift Concurrency 導入戦略

はじめに

こんにちは akihiyo76 です。Swift Concurrency が WWDC で発表されてから 2 年になりました。各プロダクトではサポートバージョンがアップデートされ、実際に導入が進み始めているプロダクトも多いのではないでしょうか。一方で新規で開発する場合は、前提となる技術だと考えています。弊社でも Swift Concurrency への移行対応を行いましたが、今回は実際に行った導入戦略を紹介したいと思います。

導入するメリット

では、動いている既存コードを修正して Concurrency を導入するメリットは何でしょうか。

実際に対応を進める場合、実装コストだけではなく、品質を担保するためのテストコストも必要になります。更にプロダクトによってはリリースコストが必要になるため、そのコストに合うメリットが要求されます。

そこで、実際に Concurrency を導入するメリットについて考えてみたいと思います。

1. 並行処理を簡潔・安全に記述できる

まず、Concurrency のメリットとして「並行処理を簡潔・安全に記述できる」という点が挙げられます。

実際のコードを比較してみましょう。以下は従来の Block 構文で実装した通信周りのコード例です。

func fetchImageData(request: URLRequest, completionHandler: @escaping (UIImage?) -> ()) {
    self.session.dataTask(with: request) { data, response, error in
        // Image をロードする
        self.loadImage(data: data) { image in
            // Image サイズが適切かチェックする
            self.checkImage(image: image) {
                completionHandler(image)
            }
        }
    }.resume()
}

この実装例では completionHandler() でコールバックを繋げる実装になっており、それぞれの処理がネスト構造を形成しています。これによりコードの複雑性が増し、分岐処理やエラー処理が追加されると更に実装が複雑になり、可読性も低下し、品質にも影響を及ぼす可能性があります。複雑性のために completionHandler() の記述を忘れた場合、特定の処理でコールバックが得られずアプリの処理が止まるリスクも考えられます。

それでは、この Block 構文で記述されたコードを Concurrency を使用して書き換えてみましょう。

func request(url: URL) async throws -> UIImage? {
    let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
    let image = try await loadImage(data: data)
    let result = try await checkImageSize(image: image)
    return result
}

async / await を使って書き換えることで、Block 構文の多段ネストが解消され、非同期の実装を簡潔に記述できるようになります。

ただし、コードスタイルや可読性の向上だけで Concurrency 移行のコストを検討するのは、割に合わないかもしれません。

2. データの競合やデッドロックを回避(品質向上)

Concurrency 導入のメリットは、データ競合やデッドロックを回避できることです。具体的には Sendable や Actor といった機能の恩恵によるものですが、これについては後ほど具体的に説明します。Concurrency を導入し、これらの機能に準拠することで、品質の確保・改善に期待できる点が大きなメリットだと考えています。

async / await

関数の先頭に async(async throws) を定義することで、その関数を非同期関数として定義できます。定義した非同期関数を実行するためには、await を使用する必要があります。

func execute() {
    Task.detached {
        do {
            let url = URL(string: "https://api.example.com")!
            // 実行完了まで待機する
            let response = try await self.request(url: url)
            // 後続処理
        } catch {
            print(error.localizedDescription)
        }
    }
}

// 非同期関数として定義する
func request(url: URL) async throws -> HTTPURLResponse {
    // 通信処理
    return result
}

このように、async / await を使って定義することで、非同期関数の定義と実行が可能になります。

Sendable

Sendableはデータ競合が起こらないことをコンパイル時に保証してくれる型で、データ競合を避けて安全に渡せるデータを表す概念として導入されました。Sendable プロトコルに準拠することによって、その型が Sendable であることがコンパイラに伝えられます。

final class Valid: Sendable {
    // 定数定義
    let name: String
    init(name: String) {
        self.name = name
    }
}

final class Invalid: Sendable {
    // 変数定義
    var name: String
    init(name: String) {
        self.name = name
    }
}

Valid と Invalid は共に Sendable プロトコルに準拠していますが、name については定数と変数で定義しており、Invalid の name は公開されているため変更が可能であり、データ競合が生じる可能性があります。この状態でコンパイルすると、Stored property 'name' of 'Sendable'-conforming class 'Invalid' is mutable というコンパイラのデータ競合警告が発生します。

Actor

Swift 5.5 から導入された Actor は Concurrency の一部として、データ競合を防ぐ型です。Actor により作成されたインスタンスは、一つのデータへのアクセスが同時に行われないように制御されます。これを Actor isolated と呼び、複数のタスクがデータにアクセスする際にもデータ競合を防ぐことができます。

actor MainActor {
    // 変数定義
    var number: Int = 1
}

func increment() {
    let act = MainActor()
    Task.detached {
        // number を更新
        await act.number = 100
    }
}

このように Actor で生成されたインスタンスの number を変更しようとすると、Actor-isolated property 'number' cannot be mutated from a Sendable closure というコンパイルエラーが発生します。

Task

Task は並行処理を実行する単位で、複数のタスクの構造化(Task Tree)も可能になります。以下のコードのように、withTaskGroup を使用してタスクグループ(親タスク)を作成し、要素となる子タスクを追加・実行することができます。

func getUser() async -> UserInfo {
    // 親タスク
    await withTaskGroup(of: DataType.self) { group in
        // 子タスク A
        group.addTask {
            let friends = await self.getFriends()
            return DataType.friends(friends)
        }
        // 子タスク B
        group.addTask {
            let notes = await self.getNotes()
            return DataType.notes(notes)
        }
    }
    return UserInfo(friends: friends, notes: notes)
}

こうした async / awaitSendableActorTask といった機能を使用することで、Swift Concurrency を活用した効果的な非同期処理を実現できるようになります。

既存プロジェクトへの導入

ここからは既存プロジェクトへの導入方法について紹介します。導入の主な流れとしては、以下のようになります。

  • PoCコードの実装(Concurrency 設定と実装方針の決定)
  • スコープ分割
  • 各スコープごとの対応
  • リグレッションテスト

大まかな対応方法としては、実装方針をFIXしてチーム全体で一気に対応していくという流れです。

1. PoCコードの実装

Strict Concurrency Checking の設定

導入に際しては、実装方針(Concurrency の記述方法)を決定する必要があります。まず最初にプロジェクトの設定として、Strict Concurrency Checkingの設定を行います。

スクリーンショット 2023-08-23 9.28.26.png

設定には Minimal、Targeted、Complete の 3 種類がありますが、Targetedに設定します。

  • Minimal
    • Sendable の制約を、明示的に採用された場所にのみ強制し、コードが並行性を採用した場所では actor-isolated チェックを実行します
  • Targeted
    • Sendable の制約を強制し、コードが並行性を採用した場所( Sendable を明示的に採用した場所も含む) actor-isolated チェックを実行します
  • Complete
    • モジュール全体で Sendable の制約と actor-isolated の分離チェックを強制します

Completeはモジュール全体に大きな影響を及ぼすため、導入時の制約としては厳しすぎると判断しました。

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

実装方針の決定

Strict Concurrency Checking の設定後は、基本的な Concurrency の実装パターンを作成します。

通信実装などのコアな部分が多く対象になると考えられるため、同じ責務の Concurrency 関数を準備します。最終的には既存の実装を削除するため、 @available で deprecated を指定しておきます。以下は withCheckedThrowingContinuationを使用して Concurrency に変換していますが、async / await を使用しても同様に変換可能です。

@available(*, deprecated, message: "This function is deprecated. Use the requestAsync() instead.")
func request(request: URLRequest, completionHandler: @escaping (Data?) -> ()) {
    self.session.dataTask(with: request) { data, response, error in
        completionHandler(data)
    }.resume()
}
func requestAsync(request: URLRequest) async throws -> Data? {
    return try await withCheckedThrowingContinuation { continuation in
        self.session.dataTask(with: request) { data, response, error in
            if let error = error {
                continuation.resume(throwing: self.handleNSURLError(error: error as NSError))
                return
            }
            continuation.resume(returning: data)
        }.resume()
    }
}

また、requestAsync() の呼び出し元クラスも変更します。以下の fetchUserData() は View 側から実行される想定なので、@MainActorを付けて Main Thread から実行できるようにし、またnonisolatedを指定して非分離メソッドとして指定します。

@MainActor
class ViewModel {
    private let apiService = ApiService()

    nonisolated
    func fetchUserData() async throws -> Data? {
        do {
            let urlRequest = await self.createUrlRequest()
            return try await apiService.requestAsync(request: urlRequest)
        } catch {
            throw await self.handleError(error)
        }
    }
}

最後に fetchUserData() の呼び出しを Task 内で行います。Task の定義は、プロジェクトのアーキテクチャに合わせて行うのが良いでしょう。

override func viewDidLoad() {
    super.viewDidLoad()
        Task {
            do {
                let userData = try await viewModel.fetchUserData()
                self.updateUser(userData)
            } catch {
                // エラー処理
            }
        }
    }
}

2. スコープ分割

導入の基本方針が確定したら、具体的な進行方法を検討します。大規模なプロジェクトでは修正が多岐にわたることが予想されますが、一度に行う変更が大きいと、レビューや品質管理が難しくなる可能性があります。そのため、一定の粒度で分割し、対応を進めることで迅速な対処が可能です。チーム全体で進行する場合、各メンバーが実装とレビューを同時に行うことが望ましいでしょう。もし修正すべき箇所が多い場合は、スプレッドシートで一覧化しておき、ファイルごとやクラスごとなど明確な区分け方法にすることで、作業の分担が容易になります。

3. 横展開・テスト

修正すべきスコープが確定したら、あとは一気に修正を進めます。チームの状況によっては、徐々に移行する方法ありますが、他の機能の実装も進行中の場合、コンフリクトが発生する可能性があるため、一気に対応する方法をオススメします。最後に、@available deprecated を指定した関数を削除することを忘れずに行いましょう。導入実装が完了したら、リグレッションテストを実施して機能デグレがないことを確認します。

まとめ

Swift Concurrency の主要な機能に焦点を当てながら、既存のプロジェクトにおける導入戦略を紹介しました。今回紹介した導入戦略は、私自身が実践した方法ですが、既存の実装を保ちながら進行したため、多少冗長な箇所があるかもしれません。これはあくまで一つの戦略として、Swift Concurrency の導入を支援するための手助けとなることを願っております。

参考

developer.apple.com

Copyright © RAKUS Co., Ltd. All rights reserved.