Smile Engineering Blog

ジェイエスピーからTipsや技術特集、プロジェクト物語を発信します

【Kotlin】Kotlin Serialization で JSON をパースする

今回は、Kotlin Serialization を使って JSON をパースする方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Bumblebee | 2021.1.1 Patch 2
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.2
  • Kotlin 1.6.20
  • Gradle 7.4.2
  • org.jetbrains.kotlin.plugin.serialization 1.6.20
  • org.jetbrains.kotlinx:kotlinx-serialization-json 1.3.2

ライブラリのインポート

最初に、Kotlin Serialization を使用するために必要なライブラリをインポートします。

まず、以下のようにプロジェクトの build.gradle の plugins に Kotlin Serialization のプラグインを追加します。

// Project's build.gradle
plugins {
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.20' apply false
}

そして、モジュールの build.gradle の plugins にも Kotlin Serialization のプラグインを追加します。

// Module's build.gradle
plugins {
    id 'org.jetbrains.kotlin.plugin.serialization'
}

後は、モジュールの build.gradle の dependencies に以下を追加します。

// Module's build.gradle
dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
}

以上で、ライブラリのインポートは完了です。

データクラスの宣言

ここから、実装に入っていきます。

まず、パースする JSON のフォーマットをデータクラスで宣言する必要があります。今回は、以下のような JSON の読み書きを行ってみます。

{
    "hidden_card": {
        "rank": "6",
        "suit": "SPADES"
    },
    "visible_cards": [
        {
            "rank": "4",
            "suit": "CLUBS"
        },
        {
            "rank": "A",
            "suit": "HEARTS"
        }
    ]
}

上記の JSON をデータクラスで宣言すると、以下のようになります。

@Serializable
data class BlackjackHand(
    @SerialName("hidden_card") val hiddenCard: Card,
    @SerialName("visible_cards") val visibleCard: List<Card>,
)

@Serializable
data class Card(
    val rank: Char,
    val suit: Suit,
)

enum class Suit {
    CLUBS, DIAMONDS, HEARTS, SPADES;
}

Kotlin Serialization で使用するデータクラスには、@Serializable を追加します。また、hidden_cardhiddenCard のように、JSON とデータクラスで名前が異なる場合、データクラスのプロパティに @SerialName を付与して、対応する JSON の名前を指定します。なお、JSON とデータクラスで名前が一致する場合は、@SerialName を省略できます。

以上で、データクラスの宣言は完了です。

JSON からオブジェクトにパースする

Json.decodeFromStringJSON 文字列を渡すと、パースした後のオブジェクトを返してくれます。

val json = """
    {
        "hidden_card": {
            "rank": "6",
            "suit": "SPADES"
        },
        "visible_cards": [
            {
                "rank": "4",
                "suit": "CLUBS"
            },
            {
                "rank": "A",
                "suit": "HEARTS"
            }
        ]
    }
""".trimIndent()
Log.d(TAG, Json.decodeFromString<BlackjackHand>(json).toString())

上記を実行すると Logcat に以下のログが表示され、JSON からオブジェクトにパースが成功していることがわかります。

D/MainActivity: BlackjackHand(hiddenCard=Card(rank=6, suit=SPADES), visibleCard=[Card(rank=4, suit=CLUBS), Card(rank=A, suit=HEARTS)])

オブジェクトから JSON にパースする

逆に、Json.encodeToString にオブジェクトを渡すと、パースした後の JSON を文字列で返してくれます。

val blackjackHand = BlackjackHand(
    Card('6', Suit.SPADES),
    listOf(Card('4', Suit.CLUBS), Card('A', Suit.HEARTS))
)
Log.d(TAG, Json.encodeToString(blackjackHand))

上記を実行すると Logcat に以下のログが表示され、オブジェクトから JSON にパースが成功していることがわかります。

D/MainActivity: {"hidden_card":{"rank":"6","suit":"SPADES"},"visible_cards":[{"rank":"4","suit":"CLUBS"},{"rank":"A","suit":"HEARTS"}]}

参考

Kotlin Serialization Guide - Basic Serialization

【Android】Moshi で JSON をパースする

今回は、Moshi を使って JSON をパースする方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Bumblebee | 2021.1.1 Patch 2
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.2
  • Kotlin 1.6.20
  • Gradle 7.4.2
  • KSP 1.6.20-1.0.4
  • com.squareup.moshi 1.13.0

ライブラリのインポート

最初に、Moshi を使用するために必要なライブラリをインポートします。

まず、今回は KSP を使用するため、KSP をインポートします。プロジェクトの build.gradle の pluginscom.google.devtools.ksp を追加します。

// Project's build.gradle
plugins {
    id 'com.google.devtools.ksp' version '1.6.20-1.0.4' apply false
}

そして、モジュールの build.gradle の plugins にも com.google.devtools.ksp を追加します。

// Module's build.gradle
plugins {
    id "com.google.devtools.ksp"
}

これで、KSP を使用できるようになります。

後は、Moshi を使用するためにモジュールの build.gradle の dependencies に以下を追加します。

// Module's build.gradle
dependencies {
    implementation "com.squareup.moshi:moshi:1.13.0"
    ksp "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
}

以上で、ライブラリのインポートは完了です。

データクラスの宣言

ここから、実装に入っていきます。

まず、パースする JSON のフォーマットをデータクラスで宣言する必要があります。今回、以下のような JSON の読み書きを行ってみます。

{
    "hidden_card": {
        "rank": "6",
        "suit": "SPADES"
    },
    "visible_cards": [
        {
            "rank": "4",
            "suit": "CLUBS"
        },
        {
            "rank": "A",
            "suit": "HEARTS"
        }
    ]
}

上記の JSON をデータクラスで宣言すると、以下のようになります。

@JsonClass(generateAdapter = true)
data class BlackjackHand(
    @Json(name = "hidden_card") val hiddenCard: Card,
    @Json(name = "visible_cards") val visibleCard: List<Card>,
)

@JsonClass(generateAdapter = true)
data class Card(
    val rank: Char,
    val suit: Suit,
)

enum class Suit {
    CLUBS, DIAMONDS, HEARTS, SPADES;
}

Moshi で使用するデータクラスには、@JsonClass(generateAdapter = true) を追加します。また、hidden_cardhiddenCard のように、JSON とデータクラスで名前が異なる場合、データクラスのプロパティに @Json を付与して、対応する JSON の名前を指定します。なお、JSON とデータクラスで名前が一致する場合は、@Json を省略できます。

以上で、データクラスの宣言は完了です。

インスタンスの生成

次に、JSON をパースするインスタンスを生成します。

val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(BlackjackHand::class.java)

上記 1 行目の Moshi.Builder().build() にて Moshi のインスタンスを生成します。そして、2 行目で Moshi のインスタンスJSON のデータクラスを渡して、JsonAdapter を生成します。この JsonAdapter を使って、JSON をパースします。

JSON からオブジェクトにパースする

さきほど生成した JsonAdapter を使って JSON からオブジェクトにパースします。JsonAdapter#fromJson に JSON 文字列を渡すと、パースした後のオブジェクトを返してくれます。

val json = """
    {
        "hidden_card": {
            "rank": "6",
            "suit": "SPADES"
        },
        "visible_cards": [
            {
                "rank": "4",
                "suit": "CLUBS"
            },
            {
                "rank": "A",
                "suit": "HEARTS"
            }
        ]
    }
""".trimIndent()

val blackjackHand = jsonAdapter.fromJson(json)
Log.d(TAG, blackjackHand.toString())

上記を実行すると Logcat に以下のログが表示され、JSON からオブジェクトにパースが成功していることがわかります。

D/MainActivity: BlackjackHand(hiddenCard=Card(rank=6, suit=SPADES), visibleCard=[Card(rank=4, suit=CLUBS), Card(rank=A, suit=HEARTS)])

オブジェクトから JSON にパースする

逆に、オブジェクトから JSON にパースすることも可能です。JsonAdapter#toJson にオブジェクトを渡すと、パースした後の JSON を文字列で返してくれます。

val blackjackHand = BlackjackHand(
    Card('6', Suit.SPADES),
    listOf(Card('4', Suit.CLUBS), Card('A', Suit.HEARTS))
)

val json = jsonAdapter.toJson(blackjackHand)
Log.d(TAG, json)

上記を実行すると Logcat に以下のログが表示され、オブジェクトから JSON にパースが成功していることがわかります。

D/MainActivity: {"hidden_card":{"rank":"6","suit":"SPADES"},"visible_cards":[{"rank":"4","suit":"CLUBS"},{"rank":"A","suit":"HEARTS"}]}

参考

Moshi

【Android】Activity Result API で他の Activity の結果を取得する

今回は、Activity Result API で他の Activity の結果を取得する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Chipmunk | 2021.2.1 Patch 1
  • JDK 11.0.12
  • Android Gradle Plugin 7.2.1
  • Kotlin 1.6.21
  • Gradle 7.4.2
  • androidx.activity 1.4.0
  • androidx.fragment 1.4.1

概要

以前、他の Activity の結果を取得する方法として、startActivityForResult と onActivityResult を使う方法がありました。しかし、現在この方法は非推奨になっており、代わりに Activity Result API を使う方法が推奨されています。

今回は、文字列を結果として返す Activity から Activity Result API でその結果を取得するサンプルの作成を通して、Activity Result API の使い方を解説します。

なお、Activity Result API は、ComponentActivity と Fragment の両方で使用することができるため、Activity と Fragment のそれぞれで解説します。

Activity で Activity Result API を使う

ライブラリのインポート

Activity で Activity Result API を使う場合、アプリの build.gradle ファイルの dependenciesactivity-ktx を追加してください。

dependencies {
    implementation "androidx.activity:activity-ktx:1.4.0"
}

他の Activity の結果を取得する

ここから、文字列を結果として返す Activity と、Activity Result API でその結果を取得する Activity を作成します。

まず、文字列を結果として返す Activity から作成します。

class SubActivity : AppCompatActivity(R.layout.sub_activity) {

    companion object {

        const val EXTRA_RESULT = "result"

        fun createIntent(context: Context) =
            Intent(context, SubActivity::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.inputButton)
            .setOnClickListener {
                setResult(RESULT_OK, Intent().apply {
                    val editText: TextInputEditText =
                        findViewById(R.id.textInputEditText)
                    val text = editText.text.toString()
                    putExtra(EXTRA_RESULT, text)
                })
                finish()
            }
    }
}

この Activity には EditText と Button があり、Button が押されると EditText に入力された文字列を Activity の結果として返すようにします。

Activity の結果として返す際には、setResult に RESULT_OK と 返す文字列を格納した Intent を渡します。setResult を使って Activity の結果を返す方法は、startActivityForResult と onActivityResult を使っていたときと同じです。

次に、Activity Result API で他の Activity の結果を取得する Activity を作成します。

class MainActivity : AppCompatActivity(R.layout.main_activity) {

    private val subActivityLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == RESULT_OK) {
            val text = result.data?.getStringExtra(SubActivity.EXTRA_RESULT) ?: ""
            findViewById<TextView>(R.id.resultTextView).text = text
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.inputButton)
            .setOnClickListener {
                val intent = SubActivity.createIntent(this)
                subActivityLauncher.launch(intent)
            }
    }
}

Activity Result API を使う場合、まず registerForActivityResult を使って、他のアクティビティを開始するために使用する ActivityResultLauncher を取得します。

この registerForActivityResult には、他のアクティビティから結果を取得することを表す ActivityResultContracts.StartActivityForResult() を渡します。そして、結果を取得した後に呼び出されるコールバックも渡します。

    private val subActivityLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == RESULT_OK) {
            val text = result.data?.getStringExtra(SubActivity.EXTRA_RESULT) ?: ""
            findViewById<TextView>(R.id.resultTextView).text = text
        }
    }

そして、文字列を結果として返す Activity を開始する処理を作成します。Intent を生成し、さきほど取得した ActivityResultLauncher の launch に Intent を渡して、Activity を開始します。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.inputButton)
            .setOnClickListener {
                val intent = SubActivity.createIntent(this)
                subActivityLauncher.launch(intent)
            }
    }

以上で、文字列を結果として返す Activity からその文字列を取得できるようになります。

Fragment で Activity Result API を使う

ライブラリのインポート

Fragment で Activity Result API を使う場合、アプリの build.gradle ファイルの dependenciesfragment-ktx を追加してください。

dependencies {
    implementation "androidx.fragment:fragment-ktx:1.4.1"
}

他の Activity の結果を取得する

ここから、Activity Result API で他の Activity の結果を取得する Fragment を作成します。

なお、文字列を結果として返す Activity については、Activity で Activity Result API を使う場合と同様なので、ここでは省略します。

以下が、実際のコードです。

class MainFragment : Fragment(R.layout.main_fragment) {

    private val subActivityLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == RESULT_OK) {
            view?.let { view ->
                val text = result.data?.getStringExtra(SubActivity.EXTRA_RESULT) ?: ""
                val textView = view.findViewById<TextView>(R.id.resultTextView)
                textView.text = text
            }
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.findViewById<Button>(R.id.inputButton)
            .setOnClickListener {
                val intent = SubActivity.createIntent(requireContext())
                subActivityLauncher.launch(intent)
            }
    }
}

Activity のときと同様に、registerForActivityResult を使って、他のアクティビティを開始するために使用する ActivityResultLauncher を取得します。

    private val subActivityLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == RESULT_OK) {
            view?.let { view ->
                val text = result.data?.getStringExtra(SubActivity.EXTRA_RESULT) ?: ""
                val textView = view.findViewById<TextView>(R.id.resultTextView)
                textView.text = text
            }
        }
    }

そして、文字列を結果として返す Activity を開始する処理を作成します。こちらも、Activity のときと同様です。

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.findViewById<Button>(R.id.inputButton)
            .setOnClickListener {
                val intent = SubActivity.createIntent(requireContext())
                subActivityLauncher.launch(intent)
            }
    }

以上で、Fragment でも文字列を結果として返す Activity からその文字列を取得できるようになります。

【Kotlin】Kotlin Coroutines で非同期処理を行う

今回は、Kotlin Coroutines について解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Chipmunk | 2021.2.1 Patch 1
  • JDK 11.0.12
  • Android Gradle Plugin 7.2.1
  • Kotlin 1.7.0
  • Gradle 7.4.2
  • org.jetbrains.kotlinx:kotlinx-coroutines-core 1.6.3

そして、アプリの build.gradle ファイルに kotlinx-coroutines-core をインポートしています。

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3"
}

基本

Coroutines とは Kotlin にて非同期処理を実現する機能です。

例えば、Coroutines で非同期処理を実現する場合、以下のように実装します。

fun main() {
    // 新しい CoroutineScope を実行する。
    runBlocking {
        // 新しい CoroutineScope を実行しつつ、
        // 現在のスレッドをブロックしない。
        launch {
            // ノンブロッキングで処理を遅延させる。
            delay(100L)
            println("World!")
        }
        println("Hello,")
    }
}

上記のコードを実行すると、以下の結果を得られます。

Hello,
World!

実装を上から見ると、"World!" を先に標準出力して、後に "Hello," を標準出力するように見えます。

しかし、launch の後に続くブロック内の処理と println("Hello,") は並列に実行されており、さらに launch の後に続くブロックでは、最初に delay(100L) で少し処理を中断しているため、実際には "Hello," から "World!" の順番に標準出力されます。

詳細

ここでは、Coroutines によく登場する以下の 4 つについて解説します。

  • CoroutineScope
  • CoroutineContext
  • Coroutine ビルダー
  • suspend 関数

CoroutineScope

CoroutineScope とは Coroutine の有効範囲です。Coroutines で処理を実行したい場合、この CoroutineScope を開始する必要があります。

また、以下のように CoroutineScope は内部に CoroutineContext を保持しており、CoroutineContext の設定通りに Coroutine を実行します。

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

CoroutineContext

CoroutineContext とは Coroutine の設定のようなものです。CoroutineContext を保持する CoroutineScope は、CoroutineContext の設定通りに Coroutine を実行します。

以下に、代表的な CoroutineContext の要素を記載します。

要素 説明
Job Coroutine のハンドラ。Coroutine のキャンセルができる。
CoroutineDispatcher Coroutine が動作するスレッドを指定する。
CoroutineName Coroutine の名前。デバッグでこの名前が表示される。
CoroutineExceptionHandler Exception のハンドラ。Coroutine でキャッチできなかった Exception を処理できる。

Coroutine ビルダー

Coroutine ビルダーとは CoroutineScope を開始する関数です。さきほどのサンプルコードでは、runBlockinglaunch が Coroutine ビルダーです。Coroutine ビルダーに Coroutines で実行したい処理を渡すと、その処理は CoroutineScope 内部で実行されます。

以下に、代表的な Coroutine ビルダーを記載します。

Coroutine ビルダー 特徴
runBlocking 現在のスレッドをブロックする。
CoroutineScope の外からでも呼び出せる。
ブロック内部の処理の結果を返せる。
launch 現在のスレッドをブロックしない。
CoroutineScope の内部からのみ呼び出せる。
ブロック内部の処理の結果を返せない。
async 現在のスレッドをブロックしない。
CoroutineScope の内部からのみ呼び出せる。
ブロック内部の処理の結果を返せる。

上記の 3 つの内、launch と async については、CoroutineScope の拡張関数として定義されているため、CoroutineScope の内部からしか呼び出すことができません。runBlocking については、CoroutineScope の拡張関数ではないため、CoroutineScope の外からでも呼び出すことができます。

suspend 関数

suspend 関数とは、スレッドをブロックせずに処理を一時停止し、そして再開できる関数のことです。さきほどのサンプルコードでは、delay が suspend 関数です。delay は処理を一時停止する関数ですが、現在のスレッドをブロックせずにスレッドプールに一時返却するため、delay で一時停止している間、別の処理がそのスレッドを使用できます。

また、suspend 関数は suspend 関数からしか呼ぶことができません。よって、例えば delay を呼び出すためには、その呼び出し元の関数に suspend を付与する必要があります。

// NG
fun doWork() {
    delay(100L)
    println("Do work.")
}
// OK
suspend fun doWork() {
    delay(100L)
    println("Do work.")
}

参考

コルーチンの基礎

【Android】Retrofit で HTTP 通信を行う

今回は、Retrofit を使って HTTP 通信を行う方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Bumblebee | 2021.1.1 Patch 3
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.3
  • Kotlin 1.6.21
  • Gradle 7.4.2
  • com.squareup.retrofit2 2.9.0

Retrofit とは?

Retrofit は HTTP クライアント通信ができるライブラリです。使用できる HTTP メソッドは、GET、POST、PUT、PATCH、DELETE、OPTIONS、HEAD です。

Retrofit では、使用する API をインターフェースとアノテーションにて定義します。

なお、Retrofit の実態は OkHttp のラッパーであり、OkHttpClient を使用して機能を拡張することができます。例えば、HTTP 通信の送受信内容を logcat に表示するができます。

Retrofit で GitHub リポジトリの情報を取得する

実際に Retrofit にて HTTP 通信を行ってみます。

今回は、以下の GitHubAPI を使って、指定したユーザーのリポジトリ情報を取得します。

  • HTTP メソッド : GET
  • ホスト : api.github.com
  • パス : /users/{username}/repos

ライブラリのインポート

初めに、Retrofit を使えるようにするために、アプリの build.gradle ファイルに次の依存関係を追加します。

dependencies {
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
}

パーミッションの設定

次に、今回はインターネット通信を行うので、AndroidManifest.xmlandroid.permission.INTERNET を追加します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.tatsuro.app.retrofitsample02">

    <uses-permission android:name="android.permission.INTERNET" /></manifest>

API インターフェースの定義

ここから実装に入っていきます。

まず、次のように使用する API を定義します。

interface GitHubService {

    @GET("users/{username}/repos")
    fun loadRepos(
        @Path("username") username: String, @Query("sort") sort: String
    ): Call<ResponseBody>
}

API を定義するために、インターフェースを宣言します。

interface GitHubService {
    ︙
}

そして、使用する API 1 つに対して、メソッドを 1 つ追加します。

@GET("users/{username}/repos")
fun loadRepos(
    @Path("username") username: String, @Query("sort") sort: String
): Call<ResponseBody>

追加したメソッドには、次のように使用する API のメソッドに合わせてアノテーションを追加します。

@GET("users/{username}/repos")

今回は GET メソッドなので、@GET を追加します。そして、@GET の引数に 今回使用する API のパスを設定します。このとき、API の中の username パラメータは実際に API を呼び出すときに後から設定できるようにするために、波括弧で囲います。

そして、メソッドに引数を追加します。

まず、次のようにAPI の中の username パラメータを引数に追加します。

@Path("username") username: String

この引数には、それがパスの一部であることを表す @Path を追加して、その引数にパタメータの名称 "username" を設定します。

次に、API のクエリを引数に追加します。

@Query("sort") sort: String

今回は、sort クエリを引数に追加します。この引数には、それがクエリであることを表す @Query を追加して、その引数にクエリの名称 "sort" を設定します。

最後に、メソッドに戻り値 Call<ResponseBody> を設定します。実際には、この Call インスタンスを使用して API を呼び出し、その結果を受け取ります。

以上で、API インターフェースの定義は完了です。

HTTP 通信を行う

さきほど定義したメソッドを使って、HTTP 通信の API を呼び出します。

Retrofit で HTTP 通信を行う場合、同期方式で HTTP 通信を行う Call#execute と 非同期方式で HTTP 通信を行う Call#enqueue があります。

メソッド 方式
execute 同期
enqueue 非同期

ここでは、これら 2 つの方式で HTTP 通信を行います。

同期方式で HTTP 通信を行う

同期方式 Call#execute で HTTP 通信を行う場合、以下のように実装します。

class MainActivity : AppCompatActivity(R.layout.main_activity) {

    companion object {
        private const val TAG = "MainActivity"
        private const val BASE_URL = "https://api.github.com"
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .build()

    private val service = retrofit.create(GitHubService::class.java)

    private val loadRepos = service.loadRepos("octocat", "created")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.button)
            .setOnClickListener {
                executeReposLoad()
            }
    }

    private fun executeReposLoad() {
        thread {
            runCatching { loadRepos.clone().execute() }
                .onSuccess { response ->
                    if (response.isSuccessful) {
                        response.body()?.string()?.let { json ->
                            Log.d(TAG, json)
                        }
                    } else {
                        val msg = "HTTP error. HTTP status code: ${response.code()}"
                        Log.e(TAG, msg)
                    }
                }
                .onFailure { t -> Log.e(TAG, t.toString()) }
        }
    }
}

まず、Retrofit のインスタンスを作成します。このインスタンスには、API のホストを渡す必要があります。

    companion object {
        ︙
        private const val BASE_URL = "https://api.github.com"
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .build()

次に、API インターフェースの実体を作成します。Retrofit#create に、さきほど定義した API インターフェース を渡し、API インターフェースの実体を取得します。

    private val service = retrofit.create(GitHubService::class.java)

そして、API の Call インスタンスを取得します。このとき、API にユーザ名とソートのパラメータを設定します。

    private val loadRepos = service.loadRepos("octocat", "created")

Call インスタンスを取得できたので、Call#execute を使って API を呼び出します。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.button)
            .setOnClickListener {
                executeReposLoad()
            }
    }

    private fun executeReposLoad() {
        thread {
            runCatching { loadRepos.clone().execute() }
                .onSuccess { response ->
                    if (response.isSuccessful) {
                        response.body()?.string()?.let { json ->
                            Log.d(TAG, json)
                        }
                    } else {
                        val msg = "HTTP error. HTTP status code: ${response.code()}"
                        Log.e(TAG, msg)
                    }
                }
                .onFailure { t -> Log.e(TAG, t.toString()) }
        }
    }

まず、UI スレッド上で Retrofit を使って同期通信を行うことはできないので、UI スレッドとは別のスレッドを作成します。また、Call インスタンスは、連続で同じ API を呼び出すことができないので、Call#execute を呼ぶ前に Call#clone を使い、API 呼び出しのたびに、新しい Call インスタンスを生成するようにします。

    private fun executeReposLoad() {
        thread {
            runCatching { loadRepos.clone().execute() }
                ︙

API 呼び出しは通信不良などで失敗する場合があり、その場合は Call#execute が例外を発します。よって、今回は、runCatching を使って Call#execute が発する例外をキャッチできるようにします。

そして、API 呼び出し成功と失敗それぞれに対して、その結果を Logcat に出力します。

なお、Call#execute が例外を発しなかった場合でも、HTTP のステータスコードが 404 または 500 の場合があるため、Response#isSuccessful で HTTP 通信が成功であることを確認します。

            runCatching { loadRepos.clone().execute() }
                .onSuccess { response ->
                    if (response.isSuccessful) {
                        response.body()?.string()?.let { json ->
                            Log.d(TAG, json)
                        }
                    } else {
                        val msg = "HTTP error. HTTP status code: ${response.code()}"
                        Log.e(TAG, msg)
                    }
                }
                .onFailure { t -> Log.e(TAG, t.toString()) }

以上の API 呼び出しを行うと、その結果が Logcat に出力されます。

非同期方式で HTTP 通信を行う

非同期方式 Call#enqueue で HTTP 通信を行う場合、以下のように実装します。

class MainActivity : AppCompatActivity(R.layout.main_activity) {

    companion object {
        private const val TAG = "MainActivity"
        private const val BASE_URL = "https://api.github.com"
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .build()

    private val service = retrofit.create(GitHubService::class.java)

    private val loadRepos = service.loadRepos("tatsuroappdev")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.button)
            .setOnClickListener {
                executeReposLoad()
            }
    }

    private fun executeReposLoad() {
        loadRepos.clone().enqueue(object : Callback<ResponseBody> {
            override fun onResponse(
                call: Call<ResponseBody>,
                response: Response<ResponseBody>
            ) {
                if (response.isSuccessful) {
                    response.body()?.string()?.let { json ->
                        Log.d(TAG, json)
                    }
                } else {
                    val msg = "HTTP error. HTTP status code: ${response.code()}"
                    Log.e(TAG, msg)
                }
            }

            override fun onFailure(
                call: Call<ResponseBody>,
                t: Throwable
            ) {
                Log.e(TAG, t.toString())
            }
        })
    }
}

Call インスタンスを取得するまでの流れは、同期方式のときと同じであるため、解説を省略します。また、API 呼び出しのたびに、Call#clone を使って、新しい Call インスタンスを生成するのも、同期方式のときと同様に行います。

Call#enqueue を使って API 呼び出しを行う場合、API 呼び出し後に呼び出される Callback を定義して、呼び出し後の処理を実装します。

Callback#onResponse は、通信が成功した場合に呼び出されます。処理の中身は、同期方式のときの Result.onSuccess と同じにします。

Callback#onFailure は、通信が失敗した場合に呼び出されます。処理の中身は、同期方式のときの Result.onFailure と同じにします。

        loadRepos.clone().enqueue(object : Callback<ResponseBody> {
            override fun onResponse(
                call: Call<ResponseBody>,
                response: Response<ResponseBody>
            ) {
                if (response.isSuccessful) {
                    response.body()?.string()?.let { json ->
                        Log.d(TAG, json)
                    }
                } else {
                    val msg = "HTTP error. HTTP status code: ${response.code()}"
                    Log.e(TAG, msg)
                }
            }

            override fun onFailure(
                call: Call<ResponseBody>,
                t: Throwable
            ) {
                Log.e(TAG, t.toString())
            }
        })

以上の API 呼び出しを行うと、その結果が Logcat に出力されます。

参考

【Android】map や switchMap で LiveData を変換する

今回は、map や switchMap で LiveData を変換する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Bumblebee | 2021.1.1 Patch 2
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.2
  • Kotlin 1.6.10
  • Gradle 7.4.1
  • androidx.lifecycle 2.4.1

また、実際に LiveData の map や switchMap を使用する際は、以下のようにアプリの build.gradle ファイルに lifecycle-livedata-ktx をインポートしてください。

dependencies {
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1"
}

map とは?

map は LiveData の拡張関数の 1 つです。

public inline fun <X, Y> LiveData<X>.map(
    crossinline transform: (X) -> Y
): LiveData<Y>

この関数の使い方は、変換したい LiveData に対して変換処理 transform を渡すだけです。そうすると、変換元の LiveData が変更されたとき、map に渡した transform が実行されて、変換後の LiveData を取得できます。

例えば、整数値を持つ LiveData の値を 2 倍にしたい場合、以下のような使い方をします。

val before = MutableLiveData(0)
// before を 2 倍にして返す。
val after = before.map { it * 2 }

内部的には MediatorLiveData で実装されており、監視対象が 1 つだけの MediatorLiveData のような振る舞いをします。

switchMap とは?

switchMap も map と同様に LiveData の拡張関数の 1 つです。

public inline fun <X, Y> LiveData<X>.switchMap(
    crossinline transform: (X) -> LiveData<Y>
): LiveData<Y>

使い方も、map と同様に変換したい LiveData に対して変換処理 transform を渡すだけです。

map との違いは、変換処理 transform の中で変換結果を返すとき、値の型ではなくて LiveData 型で返す必要があることです。

よって、さきほどの整数値を 2 倍にする例を switchMap で実装する場合、以下のようになります。

val before = MutableLiveData(0)
// before を 2 倍にして返す。
val after = before.switchMap { MutableLiveData(it * 2) }

switchMap の使い道は、戻り値が LiveData 型のメソッドで変換を行いたいときでしょう。

例えば、Room データベースの DAO で、ID を引数に渡すとその ID に紐付くユーザーデータを読み出すメソッドを変換に使いたい場合です。

@Dao
interface UserDao {

    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): LiveData<User>
}
class MainViewModel(
    private val id: Int,
    private val dao: UserDao
) : ViewModel() {

    private val _id = MutableLiveData(id)
    // ID からユーザーデータを読み出す。
    val user = _id.switchMap { dao.loadUserById(it) }

    fun setId(id: Int) {
        _id.value = id
    }
    ︙
}

上記のように switchMap であれば、そのメソッドの戻り値をそのまま返すだけで、LiveData 型のユーザーデータを取得できます。

また、上記の user は LiveData 型なので、user を LiveData#observe にて監視しておけば、ID が変わるたびに user の変更を補足できるようになります。

参考

LiveData を変換する

【Android】MediatorLiveData で他の LiveData を監視する

今回は、MediatorLiveData で他の LiveData を監視する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Bumblebee | 2021.1.1 Patch 2
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.2
  • Kotlin 1.6.10
  • Gradle 7.4.1
  • androidx.lifecycle 2.4.1

MediatorLiveData とは?

MediatorLiveData は LiveData のサブクラスであり、他の LiveData の値を監視して自身の値を変更することができます。

以下が、監視対象を追加するメソッドです。

@MainThread
public <S> void addSource(@NonNull LiveData<S> source,
                          @NonNull Observer<? super S> onChanged)

引数 source に監視対象の LiveData を設定します。そして、引数 onChanged に引数 source が変更されたときに呼び出されるコールバックを設定します。

なお、1 つの MediatorLiveData で、2 つ以上の LiveData を監視することが可能です。

// OK
mediator.apply {
    addSource(source1) { mediator = it }
    addSource(source2) { mediator = it }
}

しかし、MediatorLiveData は 1 つの LiveData に対して 2 つ以上のコールバックを設定することができません。

以下のように、すでに監視を開始している LiveData に対して、異なるコールバックを設定した場合、MediatorLiveData#addSource は IllegalArgumentException を投げます。

// NG
mediator.apply {
    addSource(source1) { mediator = it }
    addSource(source1) { mediator = "source1: $it" }
}

監視を停止したい場合には、以下の MediatorLiveData#removeSource を使います。

@MainThread
public void <S> removeSource(@NonNull LiveData<S> toRemote)

MediatorLiveData を使う

実際に MediatorLiveData を試してみます。

今回は、Activity と ViewModel、そして、String 型の値を持つ LiveData を 2 つとそれらを結合する MediatorLiveData を 1 つ用意します。MediatorLiveData は、2 つの LiveData を監視し、それらが変更されるたびに 2 つの LiveData を結合して自身に格納します。

上記の 3 つの LiveData を ViewModel に保持し、Activity から結合元となる 2 つの LiveData の変更と MediatorLiveData の監視を行います。

ライブラリのインポート

MediatorLiveData を使えるようにするために、アプリの build.gradle ファイルに次の依存関係を追加します。

dependencies {
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"
}

lifecycle-livedata-ktx をインポートすることにより、MediatorLiveData が使えるようになります。また、以降のサンプルでは LiveData を ViewModel に保持するため、lifecycle-viewmodel-ktx もインポートします。

ViewModel で値の保持と結合を行う

まず、ViewModel を以下のように実装します。

class MainViewModel : ViewModel() {

    // editText1 の文字列
    val str1 = MutableLiveData("")

    // editText2 の文字列
    val str2 = MutableLiveData("")

    // editText1 と editText2 の結合
    val linkedStr: MediatorLiveData<String> = MediatorLiveData()

    init {
        linkedStr.apply {
            // str1 の変更を監視する。
            addSource(str1) {
                linkedStr.value = "$it ${str2.value!!}"
            }
            // str2 の変更を監視する。
            addSource(str2) {
                linkedStr.value = "${str1.value!!} $it"
            }
        }
    }
}

監視の開始を init で行います。2 つの LiveData ごとに addSource を呼び出します。監視対象が変更されたら、もう一方の LiveData と結合して、自身に格納します。

Activity で LiveData の変更と MediatorLiveData の監視を行う

そして、Activity を以下のように実装します。

class MainActivity : AppCompatActivity(R.layout.main_activity) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val viewModel =
            ViewModelProvider(this)[MainViewModel::class.java]

        // editText1 の文字列変更を str1 に反映する。
        findViewById<EditText>(R.id.editText1)
            .doOnTextChanged { text, _, _, _ ->
                viewModel.str1.value = (text ?: "").toString()
            }

        // editText2 の文字列変更を str2 に反映する。
        findViewById<EditText>(R.id.editText2)
            .doOnTextChanged { text, _, _, _ ->
                viewModel.str2.value = (text ?: "").toString()
            }

        viewModel.linkedStr.observe(this) {
            findViewById<TextView>(R.id.text).text = it
        }
    }
}

Activity のレイアウトには、2 つの LiveData ごとに EditText を用意し、それらのテキストの変更を監視し、テキストを ViewModel の LiveData に格納します。

そして、2 つの LiveData を結合した linkedStr を監視し、その値を TextView に表示します。

こうすることにより、2 つの EditText に入力したテキストを結合したものが TextView に表示されるようになります。

参考

MediatorLiveData