Android アプリの各通信処理を Retrofit に移行するためにやったこと

[N予備校 Android](https://play.google.com/store/apps/details?id=nico.ed.nnn.zane&hl=ja&gl=US&pli=1)チームの鎌田です。

N予備校 Android チームでは、アプリの各通信処理を[Retrofit](https://square.github.io/retrofit/)に移行しました。

本記事では、コード例を交えてこれまでの実装からどのように Retrofit に移行したかの経緯、移行にあたってつまづいたポイントについてまとめます。

また、Retrofit の細かい解説に関してはしませんのでご承知ください。

# これまでの実装

これまでは、OkHttp をベースにした独自の`ApiClient`クラスを作成し、以下のように実装していました。

```kotlin:ExampleRepository.kt
class ExampleRepository(private val apiClient: ApiClient) {
    fun postExampleInfo(id: String, name: String): Single<ExampleData> {
        apiClient.post("example/$id/info", mapOf("name" to name)) { jsonObject ->
            // JSONをExampleDataに変換する。
            ExampleData.from(jsonObject)
        }
    }
}
```

そして、以下のように`JSONObject`からデータクラスに変換していました。

```kotlin:ExampleData.kt
data class ExampleData(data: String) {
    companion object {
        fun from(jsonObject: JSONObject): ExampleData {
            return ExampleData(data = jsonObject.getString("data"))
        }
    }
}
```

上記のデメリットとしては以下があると考えています。

- JSON からデータクラスへの変換処理を実装しないといけない
- テストコードも必要になりコード量が増える
- 実装から API の仕様が読み取りにくい

# Retrofitへの移行にあたって

Retrofit への移行にあたって、チーム内での知見が少なかったため、以下の Codelab であらかじめ学習しました。

<https://developer.android.com/codelabs/basic-android-kotlin-training-getting-data-internet#0>

JSON ライブラリとして、Retrofit にデフォルトで対応していて、かつ比較的パフォーマンスが高い[Moshi](https://github.com/square/moshi)を採用しました。

## エラーハンドリングの実装

元々`ApiClient`内部でエラーハンドリングを共通化しており、それを実現するために以下のサンプルコードを参考にエラーハンドリングを実装しました。

<https://github.com/square/retrofit/blob/master/samples/src/main/java/com/example/retrofit/ErrorHandlingAdapter.java>

実装としては以下のような感じです。

```kotlin:MyCall.kt
interface MyCall<T : Any> {
    fun cancel()
    fun clone(): MyCall<T>

    @Throws(IOException::class)
    fun execute(): Single<T>
}
```

```kotlin:MyCallImpl.kt
class MyCallImpl<T : Any>(
    private val call: Call<T>
) : MyCall<T> {
    override fun cancel() {
        call.cancel()
    }

    override fun clone(): MyCall<T> {
        return MyCallImpl(call.clone())
    }

    override fun execute(): Single<T> {
        var response: Response<T>? = null
        return Single.create { subscriber ->
            try {
                response = call.clone().execute()
                subscriber.onSuccess(response!!)
            } catch (e: Exception) {
                subscriber.onError(e)
            }
        }
            .retryWhen { // 通信エラー時のエラーハンドリングをここで行う }
            .flatMap { Single.just(response!!.body()) }
            .retryWhen { // レスポンスが空だった時、ステータスコードに応じたエラーハンドリングをここで行う }
            .subscribeOn(Schedulers.newThread())
    }
}
```

サンプルコードでは`enqueue`メソッドをオーバーライドしていましたが、もともと`execute`メソッドを使用していたのでこちらをオーバーライドしています。

```kotlin:MyCallAdapterFactory.kt
class MyCallAdapterFactory : CallAdapter.Factory() {
    override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
        if (getRawType(returnType) != MyCall::class.java) {
            return null
        }
        check(returnType is ParameterizedType) { "MyCall must have generic type (e.g., MyCall<ResponseBody>)" }
        val responseType = getParameterUpperBound(0, returnType)
        return MyCallAdapter<Any>(responseType)
    }

    private class MyCallAdapter<T : Any>(
        private val responseType: Type
    ) : CallAdapter<T, MyCall<T>> {
        override fun responseType(): Type {
            return responseType
        }

        override fun adapt(call: Call<T>): MyCall<T> {
            return MyCallImpl(call)
        }
    }
}
```

`CallAdapter`周りに関してはほぼサンプルコードを流用しました。
上記の処理を反映させるため、Retrofit のインスタンスを生成する際には以下のように実装しました。

```kotlin
val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

val retrofit = Retrofit.Builder()
    .client(okHttpClient)
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .addCallAdapterFactory(MyCallAdapterFactory())
    .baseUrl(baseUrl)
    .build()
```

そして、通信処理を以下のように置き換えました。

```kotlin:ExampleService.kt
interface ExampleService {
    @POST("example/{id}/info")
    fun postExampleInfo(@Path("id") id: String, @Body body: Map<String, String>): 
}
```

```kotlin:ExampleData.kt
data class ExampleData(
    @Json(name = "data")
    val data: String
)
```

```kotlin:ExampleRepository.kt
class ExampleRepository(private val exampleService: ExampleService) {
    fun postExampleInfo(id: String, name: String): Single<ExampleData> {
        exampleService.postExampleInfo(id = id, body = mapOf("name" to name)).execute()
    }
}
```

これにより、Retrofit に置き換えることができました。

## つまづいたポイント

### レスポンスの内容に応じて変換したいデータクラスが変わる場合

つまづいたポイントとして、レスポンスの内容に応じて変換したいデータクラスが変わる場合です。
どちらのクラスに変換するべきか Moshi 側では判断できないためです。

例えば以下のような場合です。

```kotlin:BaseData.kt
sealed class BaseData

data class FirstData(val data: String): BaseData()

data class SecondData(val data: String): BaseData()
```

その場合、以下のように一時的なデータクラスを用意します。

```kotlin:DataResponse.kt
data class DataResponse(
    @Json(name = "type")
    val type: String,
    @Json(name = "data")
    val data: String
)
```

例えば`type`に応じてデータクラスを変える場合、以下のようなメソッドを実装します。

```kotlin
fun from(dataResponse: DataResponse): BaseData {
    return when (dataResponse.type) {
        "first" -> FirstData(data = dataResponse.data)
        "second" -> SecondData(data = dataResponse.data)
        else -> throw IllegalStateException()
    }
}
```

変換処理が増えてしまうのがデメリットではありますが、既存コードの影響範囲を最小限に抑えることができました。

# まとめ

Retrofit に移行したことで、コード量が減って可読性が上がりました。

現時点では RxJava ですが、今後 Kotlin Coroutine, Flow にも対応する予定なので、対応もしやすくなりました。