こんにちは! kubell Advent Calendar 2025 の15日目を担当させていただきます iOSアプリ開発グループ 基盤開発チームのterryです。
今回は待望のSwift Concurrencyに対応したGraphQLクライアント「Apollo iOS 2.0」について書きました!
さっそくClaude Codeの力を借りて、既存プロジェクトのマイグレーションを一気に進めてみました。
まだ本格的に運用したわけではないので、実際に使い込むと別のつらみが出てくるかもしれませんが、移行作業を通じて感じたファーストインプレッションをまとめます。
まずやってみた感想
- Swift Concurrencyに対応したのでasync/awaitがそのまま使えるようになり、キャンセルの実装がシンプルになりました。
- 「ほぼ作り直しレベル」のメジャーアップデートなので、いきなり全部載せ替えると結構危険です。
- 公式のマイグレーションガイドなどドキュメントが充実しているので、AI任せでもほぼ一発でビルドできるところまでは動きました。
1系における自前のConcurrency対応
withCheckedThrowingContinuationでラップしてasync化
「Chatwork」アプリでは一部のAPIにGraphQLが導入されていますが、他のREST APIでの実装と合わせるため、1系ではApolloClientProtocol.fetch(..., resultHandler: ...) -> CancellableをwithCheckedThrowingContinuationで包み、async/awaitが使えるようにしています。以下は現在の実装例です。
func fetch<Query: GraphQLQuery>( query: Query, cachePolicy: CachePolicy ) async throws -> GraphQLResult<Query.Data> { try await withCheckedThrowingContinuation { continuation in _ = client.fetch(query: query, cachePolicy: cachePolicy) { result in continuation.resume(with: result) } } }
この実装にはキャンセルができないという問題点があります。
Apollo1系のCancellableはSendableに準拠していなかったため、キャンセル処理を適切に実装するのが困難でした。
「Chatwork」アプリでのユースケースでは、通信のキャンセル処理は必須の要件ではなかったため、実装コストなどを検討した結果、一旦Apollo側では「キャンセルしない」という方針を取りました。
しかし、将来的にはキャンセルが必要な場合も想定されるため、このキャンセルできない問題は解決すべき重要な課題でした。
Strict Concurrency対応の暫定処置
Swift6のStrict Concurrency Checkingによって、Apollo1系では多くの警告やエラーが発生しました。HTTPRequestやHTTPResponseがSendableに準拠していなかったため、以下のようなコードを書く必要がありました。
@preconcurrency import Apollo extension HTTPRequest: @retroactive @unchecked Sendable {} extension HTTPResponse: @retroactive @unchecked Sendable {}
@preconcurrencyはコンパイラの警告を抑制するだけで、実際のスレッド安全性を保証するものではありません。@unchecked Sendableも同様に、開発者が安全性を担保する必要がありました。
2.0での公式Concurrency対応
2.0では公式にtry await client.fetch(...)がサポートされました。これまでの実装だとConcurrencyの世界でApolloを使おうとすると、Continuationを使ったり独自の状態管理が必要だったりしました。公式にasync/awaitがサポートされたことにより、そういったコードが不要になり一気にシンプルな実装になります。
let result = try await client.fetch(query: GetUserQuery(id: "123"))
また、.cacheAndNetworkのような複数レスポンスを返すケースも改善されました。1系ではコールバッククロージャが複数回呼ばれる仕組みで、単一レスポンスの場合とインターフェースが同じため扱いにくい問題がありました。2.0ではfor await構文で順序立てて処理できるようになりました。
for try await response in client.fetch(query: MyQuery(), cachePolicy: .cacheAndNetwork) { print(response.data) }
1系では諦めていたキャンセル処理も、2.0ではTaskのキャンセルに任せるだけで動作します。
Structured Concurrencyでは「親Taskがキャンセルされると子Taskも自動的にキャンセルされる」という原則があり、Task.cancel()が自動的に伝播します。これにより、複雑な独自実装が不要になり、スコープを跨ぐような場合でも確実にフレームワーク側がキャンセルを管理してくれるため処理漏れが起きにくくなります。
2.0ではApollo全体がSendableに対応したため、@preconcurrency importや@unchecked Sendable拡張が不要になりました。
// 1系で必要だったコード @preconcurrency import Apollo extension HTTPRequest: @retroactive @unchecked Sendable {} // 2.0では普通にimportするだけ import Apollo
Swift 6のStrict Concurrency Checkingでも警告が出ず、安心してビルドできるようになりました。
また、RequestContextがTaskLocal化されました。1系ではRequestContextもSendableに準拠していなかったため、async/await環境で利用するには一工夫が必要でした。2.0ではTaskLocalを活用することで、トラッキングIDなどのリクエスト固有データを引数なしでRequest Chain全体で参照できるようになりました。
主な変更ポイント
実際に移行する過程で特に大きな変更ポイントをまとめます。
他にも破壊的変更が必要な箇所は多くありますが、詳細はマイグレーションガイドをご確認ください。
インターセプターが4種類に分割
1系では単一のApolloInterceptorでしたが、2.0では責務に応じて4種類に分割されました。
| 新プロトコル | 用途 |
|---|---|
GraphQLInterceptor |
GraphQLRequest/GraphQLResponseの前処理・後処理。認証ヘッダーの追加やエラーハンドリングなど |
HTTPInterceptor |
URLRequest/HTTPResponseレベルでの処理。生データの操作など |
CacheInterceptor |
キャッシュの読み書き処理 |
ResponseParsingInterceptor |
HTTPレスポンスをGraphQLResponseにパース |
// GraphQLInterceptorの例: 認証ヘッダーを付与するインターセプター struct AuthHeaderInterceptor: GraphQLInterceptor { let authToken: String func intercept<Request: GraphQLRequest>( request: Request, next: NextInterceptorFunction<Request> ) async throws -> InterceptorResultStream<Request> { var authenticatedRequest = request authenticatedRequest.additionalHeaders["Authorization"] = "Bearer \(authToken)" return await next(authenticatedRequest) } }
型の変更
| 項目 | 1系 | 2.0 |
|---|---|---|
| レスポンス型 | GraphQLResult<O.Data> |
GraphQLResponse<O>(Operationごとのメタデータも含む) |
| GraphQL Int | Swift Int |
Swift Int32(GraphQL仕様に準拠) |
キャッシュポリシー
| 1系 | 2.0 |
|---|---|
returnCacheDataElseFetch |
.cacheFirst |
returnCacheDataAndFetch |
.cacheAndNetwork |
fetchIgnoringCacheData |
.networkOnly |
fetchIgnoringCacheCompletely |
.networkOnly + writeResultsToCache: false(後述) |
returnCacheDataDontFetch |
.cacheOnly |
| - | .networkFirst(新規) |
2.0では、キャッシュを取得する時の挙動と取得したデータをキャッシュに保存するかどうかを分けて指定できるようになりました。
fetchIgnoringCacheCompletelyの注意点
1系のfetchIgnoringCacheCompletelyは「キャッシュを読まないかつ書き込まない」という挙動でした。2.0で同じ挙動を実現するには、.networkOnlyに加えてRequestConfigurationでwriteResultsToCache: falseを指定する必要があります。
// 1系 client.fetch(query: query, cachePolicy: .fetchIgnoringCacheCompletely) { ... } // 2.0(単に.networkOnlyだとキャッシュに書き込まれてしまう) try await client.fetch( query: query, cachePolicy: .networkOnly, requestConfiguration: RequestConfiguration(writeResultsToCache: false) )
単純に.networkOnlyに置き換えるだけだと、結果がキャッシュに書き込まれるため、意図しない挙動になる可能性があります。
また、2.0では.networkFirstが新規追加され、「ネットワーク優先・オフライン時のみキャッシュを使う」という挙動が簡単に書けるようになりました。
その他の注意点
- 依存管理: CocoaPods非対応、SPM必須
- 対応OS: iOS15+
- WebSocket未対応: 2.0時点ではWebSocketベースのSubscriptionがまだサポートされていません(近日対応予定)
まとめ
Apollo iOS 2.0は、1系における@preconcurrencyや@unchecked Sendableといったコード、Continuationラップ、キャンセル処理の難しさがすべて解決され、Swift6時代のGraphQLクライアントと言える進化でした。