🕌

【Kotlin】AndroidのAPI通信(Retrofit)

2024/02/01に公開
2

はじめに

最近、Android開発を勉強していてAPI通信を実装したので、備忘録として記事を書きます。

Androidでよく使われるAPI通信のライブラリを調べてみたところ、
Android Developers(ネットワークに接続する)Retrofitという通信ライブラリが紹介されていたので、今回Retrofitとコルーチンを使って通信処理を実装してみました。

コルーチンに関してはまだ勉強途中なので、今回の記事ではあまり触れません。
実際に使用しているものも、標準で入っているコルーチンのみです。

なので、この記事では主にRetrofitについて書いていきます。

今回使用するAPI

自分が使い慣れているというのもあり、今回OpenWeatherという天気のAPIを使用します。

実際に使用するAPIは以下です。

api.openweathermap.org/data/2.5/weather?q={city}&appid={APIKey}&units=metric&lang=ja

ライブラリの導入

まず、今回使用するRetrofitをbuild.gradleに追記します。

Retrofitには、レスポンス(json形式)をKotlinのオブジェクトに変換してくれるコンバーターという便利な機能があるので、今回はコンバータも使用します。

Retrofitがサポートしているコンバータはいくつか種類があるのですが、gsonがGitHubのスター数が多くて良さそうだったので今回はgsonを使用していきます。

以下の2つをbuild.gradle(Module :app)に追記します。

build.gradle
dependencies {
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}

パーミッションの設定

ネットワークに接続するために必要な権限をAndroidManifest.xmlに追加します。

以下の2つをAndroidManifest.xmlに追記します。

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

データクラスの定義

APIのレスポンスに合わせてデータクラスを定義しました。

data class WeatherData(
    val weather: List<Weather>,
    val main: Main,
    val name: String,
)

data class Weather(
    val description: String,
    val icon: String,
)

data class Main(
    val tempMin: Double,
    val tempMax: Double,
)

インターフェースの定義

Retrofitではinterfaceを作成し、@GETなどのアノテーションを使ってインターフェイスを定義します。
以下のようにインターフェースを定義しました。

interface ApiService {
    @GET("weather")
    suspend fun fetchWeather(
        @Query("appId") appId: String,
        @Query("units") units: String,
        @Query("lang") lang: String,
        @Query("q") city: String
    ): Response<WeatherData>
}

このコードでは@GETでHTTPメソッドを定義し、それに続く("weather")でPATHを定義しています。
@Queryではクエリーパラメータを定義しています。

ここでのポイントは2つあります。
一つ目がコルーチンのsuspendを使っている点、
二つ目がコンバータを使用することでWeatherDataを指定できている点です。

メソッドはデフォルトでRetrofitのCall型(今回でいうとCall<WeatherData>)を返すのですが、suspendを使用することでCall型を使用することはできなくなり、Callを実行した後のResponse型を指定することになります。

この辺りで詰まったので、調べたときに出てきものを書いておきます。

By default, methods return a Call which represents the HTTP request.
(訳)デフォルトでは、メソッドはHTTPリクエストを表すCallを返す。
https://square.github.io/retrofit/2.x/retrofit/retrofit2/Retrofit.html

When you use suspend you should make the Retrofit function return the data directly
(訳)suspendを使用する場合は、Retrofit関数が直接データを返すようにする必要があります。
https://stackoverflow.com/questions/58429501/unable-to-invoke-no-args-constructor-for-retrofit2-call
https://github.com/square/retrofit/issues/3226#issuecomment-940702721

ApiClientの実装

以下のようなApiClientを実装しました。

class ApiClient {
    private companion object {
        private const val BASE_URL = "https://api.openweathermap.org/data/2.5/"
        private const val API_KEY = "..."
    }

    // スネークケースをローワーキャメルケースに変換するための設定
    private val gson = GsonBuilder()
        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
        .create()

    // addConverterFactoryでJsonをObjectに変換するよう設定(Gsonを使用)
    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build()
    private val apiService = retrofit.create(ApiService::class.java)    

    suspend fun fetchWeather(city: String): Response<WeatherData> {
        return apiService.fetchWeather(
            API_KEY, "metric", "ja", city,
        )
    }
}

このコードではインターフェースからRetrofitを作成し、fetchWeatherメソッドを定義しています。

ここでのポイントとしてはGsonとコンバーターファクトリです。

GsonではjsonのスネークケースからKotlinオブジェクトのローワーキャメルケースに変換が必要なため、setFieldNamingPolicyで変換の指定を行っています。
そして、addConverterFactoryに作成したgsonを設定することでjsonからKotlinオブジェクトへの変換がされます。

API呼び出し

今回はキーボードのDoneボタンが押されたときに、APIを呼び出すようにしています。

    override fun onEditorAction(p0: TextView?, p1: Int, p2: KeyEvent?): Boolean {
            if (p1 == EditorInfo.IME_ACTION_DONE) {
                // Activityから呼び出すため、lifecycleScopeを使用
                lifecycleScope.launch {
                    showProgressBar()
                    // Callの実行(execute())ではIOExceptionが返される可能性があるため、コルーチンの呼び出し時にもエラー処理が必要
                    kotlin.runCatching { apiClient.fetchWeather(p0?.text.toString()) }
                        .onSuccess { response ->
                            // 通信が成功した場合
                            hideProgressBar()
                            if (response.isSuccessful) {
                                // ステータスコード200系
                                response.body()?.let {
                                    showWeatherData(it)
                                }
                            } else {
                                // ステータスコード200系以外
                                val msg = "StatusCode: ${response.code()}, msg: ${response.message()}"
                                Log.d("DEBUG", msg)
                                showErrorDialog(msg)
                            }
                        }
                        .onFailure { error ->
                            // 通信が失敗した場合
                            hideProgressBar()
                            Log.d("DEBUG", error.toString())
                            showErrorDialog(error.toString())
                        }
                }
                return false
            }
            return false
        }

このコードではボタンが押されたときに、fetchWeatherを呼び出し、showWeatherDataで結果を画面に表示しています。

ここでのポイントとしては、lifecycleScopeとrunCatchingです。

suspend関数はsuspend関数内もしくはコルーチンから呼び出すことしができず、今回はActivityやFragmentで使用されるlifecycleScope(CoroutineScope)内で呼び出しをしています。

runCatchingは呼び出し時にエラーが発生するために使用しているのですが、発生するエラーの種類がよくわかっていません。
RetrofitのCall<T>を実行するexecute()関数ではthrows IOExeptionとRuntimeExeption定義されているので、IOExeptionは返されるのではと思っているのですが、それ以外にも返されるエラーがあるので、この辺りはまた深掘りできたらなと思います。

Response<T> execute() throws IOException
Throws:
IOException - if a problem occurred talking to the server.
RuntimeException - (and subclasses) if an unexpected error occurs creating the request or decoding the response.
https://square.github.io/retrofit/2.x/retrofit/retrofit2/Call.html

コード

MainActivity.kt
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.weatherappsample.databinding.ActivityMainBinding
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
    private lateinit var activityMainBinding: ActivityMainBinding

    private val apiClient = ApiClient()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        activityMainBinding.etCity.setOnEditorActionListener(OnCityEditorActionListener())
    }

    private inner class OnCityEditorActionListener : TextView.OnEditorActionListener {
        override fun onEditorAction(p0: TextView?, p1: Int, p2: KeyEvent?): Boolean {
            if (p1 == EditorInfo.IME_ACTION_DONE) {
                // Activityから呼び出すため、lifecycleScopeを使用
                lifecycleScope.launch {
                    showProgressBar()
                    // Callの実行(execute())ではIOExceptionが返される可能性があるため、コルーチンの呼び出し時にもエラー処理が必要
                    kotlin.runCatching { apiClient.fetchWeather(p0?.text.toString()) }
                        .onSuccess { response ->
                            // 通信が成功した場合
                            hideProgressBar()
                            if (response.isSuccessful) {
                                // ステータスコード200系
                                response.body()?.let {
                                    showWeatherData(it)
                                }
                            } else {
                                // ステータスコード200系以外
                                val msg = "StatusCode: ${response.code()}, msg: ${response.message()}"
                                Log.d("DEBUG", msg)
                                showErrorDialog(msg)
                            }
                        }
                        .onFailure { error ->
                            // 通信が失敗した場合
                            hideProgressBar()
                            Log.d("DEBUG", error.toString())
                            showErrorDialog(error.toString())
                        }
                }
                return false
            }
            return false
        }

        private fun showWeatherData(data: WeatherData) {
            activityMainBinding.tvCity.text = data.name
            activityMainBinding.imageView.setImageResource(getIconResource(data.weather.first().icon))
            activityMainBinding.tvWeather.text = data.weather.first().description
            activityMainBinding.tvTempMax.text = "${data.main.tempMax} ℃"
            activityMainBinding.tvTempMin.text = "${data.main.tempMin} ℃"
        }

        private fun getIconResource(icon: String): Int {
            return when (icon) {
                "01d" -> R.drawable.d01
                "01n" -> R.drawable.n01
                "02d" -> R.drawable.d02
                "02n" -> R.drawable.n02
                "03d" -> R.drawable.d03
                "03n" -> R.drawable.n03
                "04d" -> R.drawable.d04
                "04n" -> R.drawable.n04
                "09d" -> R.drawable.d09
                "09n" -> R.drawable.n09
                "10d" -> R.drawable.d10
                "10n" -> R.drawable.n10
                "11d" -> R.drawable.d11
                "11n" -> R.drawable.n11
                "13d" -> R.drawable.d13
                "13n" -> R.drawable.n13
                "50d" -> R.drawable.d50
                "50n" -> R.drawable.n50
                else -> R.drawable.d01
            }
        }

        private fun showProgressBar() {
            val progressBar = activityMainBinding.progressBar
            progressBar.visibility = View.VISIBLE
        }

        private fun hideProgressBar() {
            val progressBar = activityMainBinding.progressBar
            progressBar.visibility = View.INVISIBLE
        }

        private fun showErrorDialog(msg: String) {
            AlertDialog.Builder(this@MainActivity)
                .setTitle(R.string.error_dialog)
                .setMessage(msg)
                .setPositiveButton(R.string.bt_ok, null)
                .show()
        }
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    tools:layout_editor_absoluteX="0dp"
    tools:layout_editor_absoluteY="42dp">

    <EditText
        android:id="@+id/etCity"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:imeOptions="actionDone"
        android:ems="10"
        android:hint="@string/et_hint"
        android:importantForAutofill="no"
        android:inputType="textPersonName"
        android:minHeight="48dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tvDescription" />

    <TextView
        android:id="@+id/tvDescription"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/tv_description"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_marginTop="8dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.497"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tvCity" />

    <TextView
        android:id="@+id/tvCity"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etCity" />

    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintEnd_toEndOf="@+id/imageView"
        app:layout_constraintStart_toStartOf="@+id/imageView"
        app:layout_constraintTop_toBottomOf="@+id/imageView">

        <TextView
            android:id="@+id/tvWeather"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <View
            android:id="@+id/divider"
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp"
            android:background="?android:attr/listDivider" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <TextView
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:text="@string/tv_temp_max_label" />

            <TextView
                android:id="@+id/tvTempMax"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" />
        </LinearLayout>

        <View
            android:id="@+id/divider2"
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp"
            android:background="?android:attr/listDivider" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="@string/tv_temp_min_label" />

            <TextView
                android:id="@+id/tvTempMin"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" />
        </LinearLayout>
    </LinearLayout>

    <ProgressBar
        android:id="@+id/progressBar"
        style="?android:attr/progressBarStyle"
        android:layout_width="75dp"
        android:layout_height="75dp"
        android:visibility="invisible"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

おわりに

今回はRetrofitを使ってAPI通信をやってみました。

標準で用意されているものは難しすぎて絶望していたんですが、コルーチンやRetrofitを使うことで比較的簡単に実装することができてよかったです。

この記事が未来の自分や他の人のお役に立てば幸いです。
ここまで読んでいただいた方、ありがとうございます。

Discussion

JboyHashimotoJboyHashimoto

標準で用意されているものは難しすぎて絶望していたんですが
標準のってあるんですね知らなかった😅

なるほど、data classはネストしてる感じですね。ふむふむ。FlutterでもSwiftでもありますね。

Retrofitは元々Androidで使われているものだろうで、Flutterだと自動生成して使いますけど、Kotlinで使うと設定するコードが多くて大変ですね。
分かりやすい記事を作ってくれた学習コストが下がりそうです。

imakyoさんありがとうございます💙💚

imakyoimakyo

分かりやすい記事を作ってくれた学習コストが下がりそうです。

そう言っていただけるとありがたいです。☺️
コメントありがとうございます。