【Kotlin】AndroidのAPI通信(Retrofit)
はじめに
最近、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)に追記します。
dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}
パーミッションの設定
ネットワークに接続するために必要な権限をAndroidManifest.xmlに追加します。
以下の2つを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
なるほど、data classはネストしてる感じですね。ふむふむ。FlutterでもSwiftでもありますね。
Retrofitは元々Androidで使われているものだろうで、Flutterだと自動生成して使いますけど、Kotlinで使うと設定するコードが多くて大変ですね。
分かりやすい記事を作ってくれた学習コストが下がりそうです。
imakyoさんありがとうございます💙💚
そう言っていただけるとありがたいです。☺️
コメントありがとうございます。