スポンサーリンク

【Android Kotlin】OkHttp3でコピペ可能なAPI通信処理実装

Kotlin

当ブログは福岡を中心とした都市の再開発や都市政策の紹介、考察を行うことをメインとしていますが、今回の記事では、本業であるアプリ・Webエンジニアで作成したコードの保存用として記事を作成しております。予めご了承ください。

スポンサーリンク

はじめに

プログラミングで初心者が苦労するAPI通信の実装について、リクエスト・レスポンスデータクラスの作成やOkHttp3を用いたAPI通信の実装を紹介します。GET, POST, PUT, DELETEメソッドに対応しており、APIを追加する際も簡単に追加できる形で実装しています。
レスポンスの内容などによってアプリに合わせて変更する必要がある箇所もありますが、基本的な部分はクラスのコピペで実装可能です。

minSdk 23
targetSdk 32

で動作を確認しています。

事前準備(ライブラリ追加)

Gradle Scripts/build.gradle(.app)に以下を追加

dependencies {
    implementation 'com.squareup.okhttp3:okhttp:4.10.0'
}

フォルダ構成

  • Api
    • Request
      • RequestInterface.kt (各データクラスに継承させるInterface)
      • LoginRequest.kt (ログインAPIのリクエストデータクラス)
    • Response
      • ResponseConversion.kt (取得したデータを各レスポンスクラスへ変換するクラス)
      • LoginResponse.kt (ログインAPIのレスポンスデータクラス)
    • ApiSegmentEnum.kt (各APIのURLとメソッドを保持しておくEnum)
    • OkHttpConnection.kt (HTTP通信を実行するクラス)
  • UtilDialog
    • ProgressDialog.kt (API通信中に表示させるぐるぐるダイアログクラス)
  • MainActivity.kt (APIを実行させるアクティビティ)

コード

RequestInterface.kt(各データクラスに継承させるInterface)

/*
 * リクエストクラスのインターフェース
 * リクエストパラメータクラスはこのインターフェースを継承する
 */
interface RequestInterface {
    // GETパラメータ用のJSON
    fun getRequestJson() : String
    // GET以外のパラメータ用Map
    fun getParams() : Map<String, Any?>? = null
    // APIセグメントEnum
    fun getEnum() : ApiSegmentEnum
}

LoginRequest.kt (APIのリクエストデータクラス)

// ログインAPIで送信するデータクラス
// RequestInterfaceを継承しており、必須の関数を3つ実装しなければなりません。

class LoginRequest constructor(
    _login_id : String,
    _password : String,
) : RequestInterface {
    private val loginId : String
    private val password : String
    init {
        loginId = _login_id
        password = _password
    }
    // ApiSegment
    // ここに対応するEnumを定数で入れておきます。
    private val mEnum = ApiSegmentEnum.LOGIN

    // getter
    private fun getLoginId() : String {
        return this.loginId
    }
    private fun getPassword() : String {
        return this.password
    }

    override fun getEnum(): ApiSegmentEnum {
        return this.mEnum
    }

    // request用Json
    override fun getRequestJson(): String {
        // 各データをMapに変換
        val data = mutableMapOf<String, Any>(
            Pair("login_id", loginId),
            Pair("password", password),
        )
        return JSONObject(data as Map<String, Any>).toString()
    }

    // request用Map
    override fun getParams(): Map<String, Any?> {
        val params : MutableMap<String, Any?> = mutableMapOf()
        params["login_id"] = loginId
        params["password"] = password
        return params
    }


}

LoginRequest.ktでは、ログインAPIを想定して作成しています。
コンストラクタにtokenとloginIdを入れていますが、APIによってリクエストパラメータを適宜変更して入れてください。

新たにAPIを追加する際は、UserRequest.ktのようにリクエストデータクラスを追加していきます。

ResponseConversion.kt (取得したデータを各レスポンスクラスへ変換するクラス)

import com.example.kotlintemplete.ApiTemplete.ApiSegmentEnum

// Api別にレスポンスデータをレスポンスクラスに変換するクラス
class ResponseConversion constructor(
    _responseMap : Map<*, *>?,
    _api_enum : ApiSegmentEnum
) {
    private val responseMap : Map<*, *>?
    private val apiEnum : ApiSegmentEnum
    init {
        responseMap = _responseMap
        apiEnum = _api_enum
    }

    // レスポンスクラス変換
    fun convertResponse() : Any {
        // レスポンスが空なら""で返却
        if (responseMap == null) {
            return ""
        }
        // 返却するデータを格納する
        val responseData : Any
        // ApiSegmentEnumによって格納するクラスを分岐させる
        when (apiEnum) {
            ApiSegmentEnum.LOGIN -> {
                // responseMap[xxx]のxxxはレスポンスdata内のKey名です。
                responseData =
                    LoginResponse(
                        responseMap["login_id"] as String,
                        responseMap["password"] as String,
                    )
            }
            // ApiSegmentEnum.USER -> {} のようにAPIを追加する毎に下に分岐を追加していきます。
            else -> return ""
        }
        return responseData
    }
}

ここでは、APIで取得してきたレスポンスを各レスポンスデータクラスに変換する処理を記載しています。
後述するApiSegmentEnumによってAPI毎に分岐させ、対応するレスポンスクラスに変換しています。

LoginResponse.kt (APIのレスポンスデータクラス)

/*
 * ログインAPIで取得するレスポンスのデータクラス
 * Activity, Fragment間でデータを受け渡せるようにParcelableを継承します。
 */
data class LoginResponse constructor(
    var token : String? = "",
    var userName : String? = ""
) : Parcelable {

    constructor(parcel: Parcel) : this() {
        token = parcel.readString()
        userName = parcel.readString()
    }

    override fun writeToParcel(parcel: Parcel, p1: Int) {
        parcel.let {
            it.writeString(token)
            it.writeString(userName)
        }
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<LoginResponse> {
        override fun createFromParcel(parcel: Parcel): LoginResponse {
            return LoginResponse(parcel)
        }

        override fun newArray(size: Int): Array<LoginResponse?> {
            return arrayOfNulls(size)
        }
    }
}

LoginResponse.ktでは、ログインAPIのレスポンスを受け取ったことを想定しています。
Parcelableを継承しているので、bundle.getParcelableを用いてActivity間、Fragment間でデータのやり取りが可能です。


ResponseConversion.ktでAPIから受け取ったResponseデータをLoginRequestデータクラスに変換しています。

新たにAPIを追加する際は、UserResponse.ktのようにレスポンスデータクラスを追加していきます。

ApiSegmentEnum.kt (各APIのURLとメソッドを保持しておくEnum)

/*
 * APIのセグメント一覧
 *  - segment ・・・ apiのアクセス先を格納。hostURL後のurl
 *  - method  ・・・ GET, POSTなどのAPIメソッド
 */
enum class ApiSegmentEnum (val segment: String, val method: String) {
    LOGIN("api/user/login", "POST"),
    USER("api/user", "GET"),
}

APIのアクセス先を置いておくEnumクラスです。
APIの接続先とメソッドを登録しておくことで、このEnumから情報を取得することが可能です。

OkHttpConnection.kt (HTTP通信を実行するクラス)

/* OkHttp3を利用したHttp通信処理クラス
 * - RequestInterface ・・・ リクエストクラス群のInterface
 * - AppCompatActivity・・・ API通信を実行するActivity
 */
class OkHttpConnection (
    _request: RequestInterface,
    _parentActivity: AppCompatActivity
        ) {
    // リクエスト情報クラス
    private val request: RequestInterface
    // API種別 (リクエストクラス内に存在)
    private val apiSegment: ApiSegmentEnum
    // リクエスト(JSON)(リクエストクラス内に存在)
    private val requestJson: String
    // リクエストパラメータ (リクエストクラス内に存在)
    private val requestParams : Map<String, Any?>?
    // 親Activity
    private val activity : AppCompatActivity

    init {
        request = _request
        apiSegment = request.getEnum()
        requestJson = request.getRequestJson()
        requestParams = request.getParams()
        activity = _parentActivity
    }

    // HTTPリクエストやレスポンスボディのコンテンツタイプ
    private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
    // APIのHostURL
    private val apiHostUrl = "apiHostUrl.com"
    // OkHttpのクライアント
    private val client = OkHttpClient.Builder().build()

    /*
     * Http通信を実行
     *  - isShowProgress ・・・ Http通信中にプログレスダイアログ(ぐるぐる)を表示するか
     */
    suspend fun startRequest(isShowProgress: Boolean = true) : Any? {

        // プログレスダイアログクラス
        val progressDialog = ProgressDialog()
        // プログレスダイアログを表示
        if (isShowProgress) {
            progressDialog.show(activity.supportFragmentManager, "progress")
        }
        // HttpリクエストのBuilder
        val builder: Request.Builder = Request.Builder()
        // リクエストボディ
        val requestBody = requestJson.toRequestBody(JSON_MEDIA)
        // レスポンス格納用
        val responseData: Any? = null;

        // sharedPreferenceから保存済みのアクセストークンを取得する。
        val sharedPref = activity.getSharedPreferences("mySharedPref(保存名)", Context.MODE_PRIVATE)
        val token = sharedPref.getString("トークンの保存名", "") as String

        // Request Headerを作成
        /*
         * ヘッダーにトークンを挿入 (Bearer形式 任意で変更してください。)
         * ログインAPIの場合はスルーする (トークン未取得のため)
         */
        if (token != "" && apiSegment != ApiSegmentEnum.LOGIN) {
            builder.addHeader("Authorization", "Bearer $token")
        } else if (token == "" && apiSegment != ApiSegmentEnum.LOGIN) {
            // プログレスダイアログを消す
            if (isShowProgress) {
                progressDialog.dismiss()
            }
            // ここにtoken未取得時のエラー処理を記載
            return null
        }

        // Requestを作成
        val apiRequest: Request
        when (apiSegment.method) {
            "GET" -> {
                apiRequest = builder
                    .url(makeHttpUrl())
                    .get()
                    .build()
            }
            "POST" -> {
                apiRequest = builder
                    .url(makeHttpUrl())
                    .post(requestBody)
                    .build()
            }
            "PUT" -> {
                apiRequest = builder
                    .url(makeHttpUrl())
                    .put(requestBody)
                    .build()
            }
            else -> { // DELETE
                apiRequest = builder
                    .url(makeHttpUrl())
                    .delete(requestBody)
                    .build()
            }
        }
        try {
            return suspendCoroutine { continuation ->
                client.newCall(apiRequest).enqueue(object : Callback {
                    // API正常
                    override fun onResponse(call: Call, response: Response) {
                        try {
                            /*
                         * レスポンスボディを取得
                         * レスポンスは
                         * header {errorCode:String, errorMessage: String}, data {...}
                         * の形式を想定しています。必要に応じて変更してください。
                         */
                            val responseBody = response.body?.string().orEmpty()
                            val jsonObj = JSONObject(responseBody)
                            val map = jsonObj.toMap()
                            // data, headerを抽出
                            var mapData = map["data"]
                            val mapError = map["header"] as Map<*, *>

                            // dataが空の場合は後でCastExceptionが発生するのでnullにしておきます。
                            if (!(mapData is Map<*, *>)) {
                                mapData = null
                            }

                            /*
                         * 正常値が返却された場合
                         * ResponseConversionクラスでレスポンス内容を各レスポンスクラスに変換します。
                         */
                            if (mapError["errorCode"] == "" || mapError["errorCode"] == null) {
                                val returnData = ResponseConversion(
                                    mapData as Map<*, *>?,
                                    apiSegment
                                ).convertResponse()
                                if (returnData != "") {
                                    continuation.resume(returnData)
                                } else {
                                    // returnDataが存在しない場合はステータスコードを返却します。
                                    continuation.resume(response.code)
                                }
                            } else {
                                // APIがエラーで返却された時はエラー内容を取得して返却します。
                                val errorCode = mapError["errorCode"] as String
                                val errorMessage = mapError["errorMessage"] as String
                                // ここでエラーダイアログを表示する等エラー処理を書きます。
                                // 今回は呼び出し元にエラー文言を返却しています。
                                continuation.resume("ErrorCode $errorCode\n$errorMessage")
                            }
                        } catch (e: Exception) {
                            // 例外処理
                            Log.e("ApiException", e.toString())
                        } finally {
                            // 処理完了時にプログレスダイアログを消します
                            if (isShowProgress) {
                                progressDialog.dismiss()
                            }
                        }
                    }
                    // API失敗
                    override fun onFailure(call: Call, e: IOException) {
                        Log.e("ApiError", e.toString())
                        // 通信エラーなどAPI通信に失敗した際の処理を書きます。
                        if (isShowProgress) {
                            progressDialog.dismiss()
                        }
                    }
                })
            }
        } catch (e: Exception) {
            // APIのリクエストにエラーが存在する時
            Log.e("ApiRequestError", e.toString())
            // APIのリクエストにエラーが存在する際のエラー処理を書きます。
            if (isShowProgress) {
                progressDialog.dismiss()
            }
        }
        return responseData
    }

    // JsonObjectをMapに変換する
    private fun JSONObject.toMap(): Map<String, *> = keys().asSequence().associateWith {
        when (val value = this[it])
        {
            is JSONArray ->
            {
                val map = (0 until value.length()).associate { Pair(it.toString(), value[it]) }
                JSONObject(map).toMap().values.toList()
            }
            is JSONObject -> value.toMap()
            JSONObject.NULL -> null
            else            -> value
        }
    }

    // API送信先のURLを作成
    private fun makeHttpUrl(): HttpUrl {
        val httpUrlBuilder: HttpUrl.Builder = HttpUrl.Builder()
            .scheme("https")
            .addPathSegment(apiSegment.segment)
            .host(apiHostUrl)
        // リクエストパラメータが存在すればセットする
        requestParams?.forEach { (key, value) ->
            httpUrlBuilder.addQueryParameter(key, value.toString())
        }
        return httpUrlBuilder.build()
    }

}

OkHttpConnection.ktクラスではOkHttp3を利用してAPI通信を実行します。
コンストラクタにRequestInterfaceを継承したリクエストクラスと呼び出し元のActivityを入れており、リクエストクラス内のEnumを読み取ってAPI通信先を分岐させています。

API通信を実行するstartRequest()関数を走らせるとAPI通信が実行し結果が返されます。
引数のisShowProgressは、API通信中にぐるぐる(progressDialog)を表示させるかの分岐に利用します。

startRequest()はsuspend関数なので、非同期処理が完了するまでアプリの処理を保留にしてくれます。

エラー処理などは任意で追加してください。

ProgressDialog.kt (API通信中に表示させるぐるぐるダイアログクラス)

// 通信中のぐるぐるダイアログ
class ProgressDialog : DialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = Dialog(requireContext())
        dialog.setContentView(R.layout.dialog_progress)

        return dialog
    }
}

ぐるぐる表示用のダイアログフラグメントクラスです。
レイアウトは任意で作成してください。以下はレイアウト作成例です。

<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBaseline_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:gravity="center"
        android:background="@color/white">


        <ProgressBar
            android:id="@+id/progress_bar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="16dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="通信中..."
            android:layout_marginEnd="40dp"
            android:layout_marginStart="40dp"
            android:textSize="14sp" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt (APIを実行させるアクティビティ)

// API通信呼び出し例
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ここでAPI呼び出し関数を呼んでいる。
        callApi()
    }

    // Api通信実行
    private fun callApi() {
        val request = LoginRequest("入力されたLoginId", "入力されたPassword")
        // 非同期処理なので、コルーチンスコープ内でstartRequestを呼び出します。
        MainScope().launch(Dispatchers.Main) {
            val response = OkHttpConnection(request, this@MainActivity)
                .startRequest(true)
            if (response is LoginResponse) {
                // ログイン情報取得後の処理(必要に応じて変更してください)
                val token = response.token
                // 取得したトークンを端末に保存する処理(実際は暗号化してから保存する方が良いと思います。)
                val sharedPref = getSharedPreferences("mSharedPref", Context.MODE_PRIVATE)
                sharedPref.edit().putString("access_token", token).apply()
            }
        }
    }
}

OkHttpConnection().startRequest()の呼び出し例です。
startRequest()は前述の通りsuspend関数なので、コルーチン内で呼び出す必要があります。
APIの返却値に応じてUIを操作したいので、Dispatchers.Mainを指定してメインスレッドで実行しています。詳しくは↓
https://developer.android.com/kotlin/coroutines/coroutines-adv?hl=ja#main-safety

APIを追加する

APIを新規に追加したい時は、RequestフォルダにXXXRequest.ktのようなデータクラスと、ResponseフォルダにXXXResponse.ktのようなデータクラスを作成してください。
また、ApiSegmentEnumにも追加でEnumを登録しましょう。
ResponseConversion.ktクラスにデータクラスへの変換処理を記載するのも忘れずに行ってください。

あとは、OkHttpConnectionクラスを呼び出す際に対応したRequestデータクラスを込めればAPI通信できると思います。

APIを追加するときに更新が必要なクラス↓↓↓
・Request/XXXRequest.ktの追加
・Response/XXXResponse.ktの追加
・ApiSegmentEnumにEnumを追加
・ResponseConversion.ktに変換処理を追加

終わりに

長らくJavaを利用してAndroidアプリを開発してきましたが、Kotlinに変えたことでかなりコードがスッキリしました。
今回紹介したコードはAPI追加時に必要な処理が少ないので便利だと思います。
一つの関数で全メソッドを実行できるので、一度書けば後はかなり楽です。
開発経験が長いわけではないので、不明点やご感想、ご指摘があれば是非コメントもしくはTwitterのDMまでご連絡いただけると助かります。

参考記事

コメント

タイトルとURLをコピーしました