kubell Creator's Note

株式会社kubellのエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

遂にConcurrency対応!Apollo iOS 2.0 を早速使ってみた

こんにちは! kubell Advent Calendar 2025 の15日目を担当させていただきます iOSアプリ開発グループ 基盤開発チームのterryです。

今回は待望のSwift Concurrencyに対応したGraphQLクライアント「Apollo iOS 2.0」について書きました!

www.apollographql.com

さっそくClaude Codeの力を借りて、既存プロジェクトのマイグレーションを一気に進めてみました。

まだ本格的に運用したわけではないので、実際に使い込むと別のつらみが出てくるかもしれませんが、移行作業を通じて感じたファーストインプレッションをまとめます。

まずやってみた感想

  • Swift Concurrencyに対応したのでasync/awaitがそのまま使えるようになり、キャンセルの実装がシンプルになりました。
  • 「ほぼ作り直しレベル」のメジャーアップデートなので、いきなり全部載せ替えると結構危険です。
  • 公式のマイグレーションガイドなどドキュメントが充実しているので、AI任せでもほぼ一発でビルドできるところまでは動きました。

1系における自前のConcurrency対応

withCheckedThrowingContinuationでラップしてasync化

「Chatwork」アプリでは一部のAPIにGraphQLが導入されていますが、他のREST APIでの実装と合わせるため、1系ではApolloClientProtocol.fetch(..., resultHandler: ...) -> CancellablewithCheckedThrowingContinuationで包み、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系のCancellableSendableに準拠していなかったため、キャンセル処理を適切に実装するのが困難でした。 「Chatwork」アプリでのユースケースでは、通信のキャンセル処理は必須の要件ではなかったため、実装コストなどを検討した結果、一旦Apollo側では「キャンセルしない」という方針を取りました。 しかし、将来的にはキャンセルが必要な場合も想定されるため、このキャンセルできない問題は解決すべき重要な課題でした。

Strict Concurrency対応の暫定処置

Swift6のStrict Concurrency Checkingによって、Apollo1系では多くの警告やエラーが発生しました。HTTPRequestHTTPResponseSendableに準拠していなかったため、以下のようなコードを書く必要がありました。

@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に加えてRequestConfigurationwriteResultsToCache: 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クライアントと言える進化でした。

参考リンク