🔄

Claude Codeと挑むAndroid通信基盤の刷新:「手本」の提示から自己レビュー、PR分割までAIと伴走した全記録

に公開

1. はじめに

長年運用してきているAndroidアプリにおいて、似た課題を解決するのにライブラリが混在してしまうことが発生し得ます。
これは後発のより良い選択肢に見えるライブラリを導入しつつも、元々入っていたライブラリと実装を完全に移行し切れなかった場合などによく起こるケースです。
事情がない限り、同じ問題を解決するのに依存するライブラリは少ない方がメンテナンスコスト、学習コスト等の観点からも望ましいと考えられるでしょう。

我々が運用開発してきているAndroidアプリでも、http(s)通信のためにRetrofitとKtorが混在している状況になってしまっていました。古めの機能であればRetrofitで実装されたものを利用し、新しめの機能であればKtorで実装されたものを利用するというような状態です。

Retrofitの実装例はこちら。

// ========================================
// Retrofit による実装例
// ========================================

// 1. リクエスト/レスポンスモデル(Moshi使用)
@JsonClass(generateAdapter = true)
data class LoginRequest(
    val email: String,
    val password: String,
)

@JsonClass(generateAdapter = true)
data class LoginResponse(
    val userId: String,
    val accessToken: String,
)

// 2. APIインターフェース定義
interface AuthApi {
    @POST("api/v1/auth/login")
    suspend fun login(@Body body: LoginRequest): LoginResponse
}

// 3. Retrofitインスタンスの構築
class RetrofitService {
    private val moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()

    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        })
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .client(okHttpClient)
        .addConverterFactory(MoshiConverterFactory.create(moshi))
        .build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
}

// 4. 呼び出し例
suspend fun performLogin(email: String, password: String): LoginResponse {
    val api = RetrofitService().create(AuthApi::class.java)
    return api.login(LoginRequest(email, password))
}

Ktorの実装例はこちら。

// ========================================
// Ktor による実装例
// ========================================

// 1. リクエスト/レスポンスモデル(kotlinx.serialization使用)
@Serializable
data class LoginRequest(
    val email: String,
    val password: String,
)

@Serializable
data class LoginResponse(
    val userId: String,
    val accessToken: String,
)

// 2. HttpClientの構築
class ApiClient(
    private val baseUrl: String = "https://api.example.com",
) {
    private val httpClient = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                encodeDefaults = true
            })
        }
        install(Logging) {
            level = LogLevel.BODY
        }
    }

    // 3. API呼び出しを拡張関数として定義
    suspend fun login(request: LoginRequest): LoginResponse {
        return httpClient.post("$baseUrl/api/v1/auth/login") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.body()
    }
}

// 4. 呼び出し例
suspend fun performLogin(email: String, password: String): LoginResponse {
    val client = ApiClient()
    return client.login(LoginRequest(email, password))
}

比較をすると以下のようになります。

観点 Retrofit Ktor
API定義 インターフェース + アノテーション 拡張関数 or メソッド
シリアライゼーション Moshi / Gson kotlinx.serialization
非同期処理 Coroutines対応(suspend fun) Coroutinesネイティブ

非同期処理での差異は利用側では感じられませんが、定義されたAPIの利用とシリアライゼーション周りは違いがあるため、実装を移行するのであれば修正工数は避けられません。

一方、こうした修正作業は単純ではあるものの影響範囲が広く膨大です。人間が手動でやるには退屈でミスが起きやすい所謂「泥臭い作業」でもあります。

そこで今回、「Claude Code(AIコーディングエージェント)」をパートナーとして、約30件のエンドポイント定義のRetrofitをKtorに差し替えるというリファクタリングに挑みました。
全て人力で行うのであれば対応にそれなりに時間がかかることが予想される内容でしたが、今回の取り組みでは比較的短い期間で移行を完了できております。

本記事ではその具体的なワークフローと、AIと協業する上での「勘所」を紹介できればと思います。

こちらの記事の執筆時点(2025年12月)での各種バージョン情報は以下の通りです。

  • Claude Code (Version: 2.0.62, Model: Opus 4.5)
  • Retrofit: 3.0.0
  • Ktor: 3.3.3

2. 移行プロセスの全体像

人間とAIの役割分担と移行プロセスは以下の図のようにしました。

主に以下の4段階に分けて考えられます。

  • 準備: 手本となるコミットの作成
  • 実装: AIによる全置換とビルド/テスト修正ループ
  • レビュー: AI自己レビューと人間による確認
  • 取り込み: PR分割とマージ

3. Step 1: 「手本」を見せる

まず、最初から全てをAIに丸投げするのではなく、1つのAPIエンドポイントだけ人間がKtorへ移行し、動作するcommitを作成しました。

これを行うと、プロンプトで長々と指示するよりも、「このcommitのようにやって」とdiffを見せる方が、AIにとってコンテキスト(プロジェクト固有の設計ルールや命名規則)の理解度が格段に高く、加えて人間側としても指示がやりやすくなります。

以下は1つのAPI定義をRepository層での利用まで含めて変更した際の一例です。この例では上記に記載した2つの実装例のうち、Retrofitでのインターフェース呼び出しをKtorに差し替えるような想定をしています。

Before: Retrofit による実装(削除されるファイル)

diff --git a/network/src/main/java/com/example/api/user/GetUserRequest.kt b/network/src/main/java/com/example/api/user/GetUserRequest.kt
deleted file mode 100644
--- a/network/src/main/java/com/example/api/user/GetUserRequest.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.example.api.user
-
-import com.squareup.moshi.JsonClass
-import retrofit2.http.Body
-import retrofit2.http.POST
-
-@JsonClass(generateAdapter = true)
-data class GetUserPost(val userId: String)
-
-@JsonClass(generateAdapter = true)
-data class UserPayload(
-    val id: String,
-    val name: String,
-    val email: String,
-)
-
-@JsonClass(generateAdapter = true)
-data class GetUserResponse(val payload: UserPayload)
-
-interface GetUserApi {
-    @POST("api/v1/user/get")
-    suspend fun post(@Body body: GetUserPost): GetUserResponse
-}
-
-suspend fun requestGetUser(userId: String): UserPayload {
-    return RetrofitClient.create(GetUserApi::class.java)
-        .post(GetUserPost(userId))
-        .payload
-}

After: Ktor による実装(追加されるファイル)

diff --git a/apiclient/src/main/kotlin/com/example/api/client/user/UserGet.kt b/apiclient/src/main/kotlin/com/example/api/client/user/UserGet.kt
new file mode 100644
--- /dev/null
+++ b/apiclient/src/main/kotlin/com/example/api/client/user/UserGet.kt
@@ -0,0 +1,24 @@
+package com.example.api.client.user
+
+import com.example.api.client.ApiClient
+import io.ktor.http.HttpMethod
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class UserGetRequest(
+    val userId: String,
+)
+
+@Serializable
+data class UserGetResponse(
+    val id: String,
+    val name: String,
+    val email: String,
+)
+
+suspend fun ApiClient.userGet(userId: String): UserGetResponse {
+    return requestV1(
+        path = "user/get",
+        httpMethod = HttpMethod.Post,
+        requestBody = UserGetRequest(userId),
+    )
+}

Repository層の変更

diff --git a/repository/src/main/java/com/example/repository/UserRepository.kt b/repository/src/main/java/com/example/repository/UserRepository.kt
--- a/repository/src/main/java/com/example/repository/UserRepository.kt
+++ b/repository/src/main/java/com/example/repository/UserRepository.kt
@@ -1,18 +1,20 @@
 package com.example.repository

-import com.example.api.user.requestGetUser
-import com.example.api.user.UserPayload
+import com.example.api.client.ApiClient
+import com.example.api.client.user.UserGetResponse
+import com.example.api.client.user.userGet

 class UserRepository(
+    private val apiClient: ApiClient,
     private val errorHandler: ErrorHandler,
 ) {

-    suspend fun getUser(userId: String): UserPayload? {
+    suspend fun getUser(userId: String): UserGetResponse? {
         return try {
-            requestGetUser(userId)
+            apiClient.userGet(userId)
         } catch (e: Exception) {
             errorHandler.handle(e)
             null
         }
     }
 }

この例ではレスポンスモデルが UserPayload? から UserGetResponse? に変更されています。そのため、実際には利用元のViewModelなどでのドメインモデル変換などをにも手を加える必要が出てくるかもしれません。

そうした1まとまりの変更を1commitにまとめてAIに提示します。

ポイントとして、この時点でAPI利用時の共通処理やエラーハンドリングの方針も含めておくと、後の手戻りが減るため有効です(今回の移行時にはRetrofitの HttpException に依存するハンドリングがいくつかあり、移行途中で手戻りや方針再考が必要となりました……)

プロンプト例.

  • ${commitID} を元に ${API定義のファイルパス} をKtorに置き換えてください。

4. Step 2: 実装と修正のイテレーション

「先ほどのcommitを参考に、残りのRetrofit実装をすべてKtorに置き換えて」とClaude Codeに指示を行います。
ここからビルドエラー・テスト失敗との戦いになります。一発で完動することは稀です。
ビルドが通らなければエラーログを読ませて修正させ、テストが通らなければ失敗したテストケースを提示して修正させます。

内容によっては単純な置き換えの繰り返しとなり、人力で行うと辟易としそうな作業になりますが、こうした作業はAIと相性がよく何度でもトライしてくれます。

プロンプト例.

  • Assemble Projectのエラーを修正してください。
  • Gradle TaskのtestDebugUnitTestの実行時エラーを修正してください。
  • テストが実行できるようになりました。ただテストのアサーションで失敗しているようです。これを修正し、正しくテストがパスするようにしてください。

5. Step 3: レビューの多層化

ある程度変更がまとまったら、Draft PRを出した後、Claude Code Actionsを使って、AI自身に自分の書いたコードをレビューしてもらいます。
変更量が多いため、人がレビューするのはどうしても大変な面がありますが、AIによるレビューであれば迅速にレビューと修正のイテレーションを回すことが可能です。

また、レビューの修正対応については内容を人が確認し、修正すべきと判断した項目については適宜Claude Codeに指示を出し対応をしてもらいました。
レビュー内容を参照するときにはGitHub公式のMCPサーバーとの連携を行い、AIエージェントとGitHubプラットフォームを接続することが一般的ではありますが、内容の参照だけであればghコマンドの利用だけでも事足ります。

このサイクルにより単純な見落としや、ロジック不備、デッドコードなどがフィルタリングできます。
また、並行して人間によるレビューも挟みます。最終的なロジックの正当性や、ビジネスロジックへの影響は人間がチェックします。
AIが書いたコードは「もっともらしく間違っている」ことがあるため、特に異常系の挙動や複雑な実装周りを中心に確認を行います。

プロンプト例.

  • ${レビューコメントのURLリンク} のレビュー対応をしてください。

6. Step 4: 巨大PRの分割戦略

さて、無事に全移行がまとまったとしても、出来上がっているPRはとても巨大なものになっています。
これを人間が改めて確認するには少し厳しいサイズ感になっていることでしょう。

そこで、PRを有効な範囲に分割し、少しずつレビュー & マージをしていきます。
このとき、AIと「この変更を安全にマージするために、どのような単位でPRを分割すべきか?」ということを相談しつつ決定します。

今回は以下のような単位で分割を行いました。

  • リクエスト、レスポンスパラメータ等の関連するデータクラスの定義変更
  • 差し替え先KtorでのWebAPI呼び出しの実装
  • 呼び出しを行う各種機能の移行

これら合意した方針に基づき、AIにブランチを切り直させ、小分けにしてPRを作成、人の目によるレビューを改めて実施した上でマージを進めていきます。

プロンプト例.

  • このブランチは変更量がとても膨大です。人間のレビュワーの負荷低減と、万が一問題が発生したときの影響範囲を小さくするために、少しずつ分割してPull Request化したいと思っています。各種変更を解析し、どの粒度で分割、Pull Request化するとよいか提案をしてください。

7. 成功のためのポイント

  • AIへの「お手本」を入念に作成する
    • 0から1を作らせるより、1(手本)から100に展開させる作業がAIは得意。最初の1commitの質が全体の質を左右する。
    • 初めから様々なケースを想定したお手本を用意するのは難しいこともあるので、ある程度は手戻りも容認して少しずつ移行を進める。
  • AI自身にレビューさせる
    • 書いた本人(AI)に客観的な視点でレビューさせることで、セルフ修正能力を引き出す。
    • レビュー内容をAIエージェント側が閲覧できる環境は用意しておく。
  • 「人の目」は必須
    • AIはコンパイルやテストが通る嘘をつくことがある。責任を持つのは人間。
    • 特にテストコード側の修正は不正なものになっていないかという意識を持ってレビューする。
  • PR分割もAIとやる
    • 巨大な変更の依存関係を解析し、適切な粒度に切り分ける作業こそ、コンテキストを把握したAIの得意分野である。
  • 検証レイヤーを複数用意する
    • ユニットテスト等の自動テストは移行が問題なく進行しているかを担保する手段の一つ。
    • またAIと人がレビューし、実際の動作確認も行うことで複数の観点から検証ができる。

8. おわりに

技術的負債の解消という、重要だが後回しにされがちなタスクにおいて、AIエージェントは効果的に活用できる場面がいくつもあります。今回のようなライブラリ移行は特に効果を感じました。

一方、どうしても変更量が膨大となるため、その品質の担保と取り込みの粒度のコントロールが肝要です。
今回の我々のケースではユニットテストを元に人の目でのレビューや動作確認を中心として挙動を担保しましたが、優秀なE2Eテストなどがあれば、更に人の手間をかけること無くAIドリブンで移行作業を完結させられるかもしれません。

Androidに限らず、運用するシステムでライブラリ移行がなかなか進まないといった問題を抱えている方は、是非AIエージェントの利用を検討してみてください。

この記事はREALITYのアドベントカレンダー2025の15日目の記事として投稿されました。

https://qiita.com/advent-calendar/2025/reality_inc

REALITY Tech

Discussion