Tatsuro のテックブログ

アプリ開発の技術情報を発信します。

【Android × Kotlin】RecyclerView に View Binding を導入する

以前、RecyclerView でリスト表示する方法を解説した際、RecyclerView の各項目のビューにアクセスする方法として findViewById を使用しました。しかし、View Binding を使用した方がビューに安全にアクセスすることができます。

よって、今回は RecyclerView の各項目のビューに View Binding でアクセスする方法を解説します。

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

  • Android Studio Chipmunk | 2021.2.1
  • JDK 11.0.12
  • Android Gradle Plugin 7.2.0
  • Kotlin 1.6.21
  • Gradle 7.4.2
  • androidx.recyclerview 1.2.1

build.gradle の変更

RecyclerView と View Binding を使用できるようにするために、build.gradle を変更します。

まず、View Binding を有効にするために、android ブロック内の buildFeaturesviewBindingtrue にします。

android {
    ︙
    buildFeatures {
        viewBinding = true
    }
}

そして、RecyclerView を使えるようにするために、dependencies ブロック内に次の依存関係を追加します。

dependencies {
    implementation "androidx.recyclerview:recyclerview:1.2.1"
}

以上で build.gradle の変更は完了です。これで、RecyclerView と View Binding を使用できるようになります。

View Binding の導入

実際に、RecyclerView に View Binding を導入します。RecyclerView の各項目のビューへのアクセスは RecyclerView のアダプタで行うので、前回の RecyclerView の解説で作成したソースコードをベースにして、TwoLinesListAdapter に View Binding を導入してみます。

TwoLinesListAdapter を以下のように実装します。

class TwoLinesListAdapter(
    private val twoLinesList: List<TwoLines>
) : RecyclerView.Adapter<TwoLinesListAdapter.ViewHolder>() {

    inner class ViewHolder(private val binding: TwoLinesItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(position: Int) {
            binding.apply {
                val twoLines = twoLinesList[position]
                primaryText.text = twoLines.primary
                secondaryText.text = twoLines.secondary
            }
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = TwoLinesItemBinding
            .inflate(inflater, parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(
        holder: ViewHolder, position: Int
    ) {
        holder.bind(position)
    }

    override fun getItemCount() = twoLinesList.size
}

ここで注目すべきは、ViewHolderonCreateViewHolderonBindViewHolder の 3 つです。

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

    inner class ViewHolder(private val binding: TwoLinesItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(position: Int) {
            binding.apply {
                val twoLines = twoLinesList[position]
                primaryText.text = twoLines.primary
                secondaryText.text = twoLines.secondary
            }
        }
    }

ViewHolder は、RecyclerView の各項目のビューを保持するクラスです。

今回の View Binding の導入では、ViewHolder に View Binding のインスタンスを保持させます。そして、このインスタンスはコンストラクタから渡せるようにします。

また、ビューを更新する bind メソッドを実装します。RecyclerView がスクロールされるなどで onBindViewHolder が呼ばれるたびに、bind メソッドを使用してビューを更新します。

次に、onCreateViewHolder を以下のように実装します。

    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = TwoLinesItemBinding
            .inflate(inflater, parent, false)
        return ViewHolder(binding)
    }

onCreateViewHolder では、ViewHolderインスタンスを生成して戻り値として返します。

ViewHolderインスタンスを生成するためには、View Binding のインスタンスを渡す必要があります。この View Binding のインスタンスを取得するために、TwoLinesItemBinding.inflate を使用します。

最後に、onBindViewHolder を以下のように実装します。

    override fun onBindViewHolder(
        holder: ViewHolder, position: Int
    ) {
        holder.bind(position)
    }

onBindViewHolder では、ViewHolder.bind を使用して、ビューホルダ内部のビューを更新します。

以上、View Binding の導入は完了です。これにより、View Binding にて RecyclerView の各項目のビューが更新されるようになります。

関連記事

RecyclerView でリスト表示する方法について、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】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.retrofitsample">

    <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 × Kotlin】RecyclerView でリスト表示する

今回は、RecyclerView でリスト表示する方法を解説します。

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

  • 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
  • androidx.recyclerview 1.2.1

RecyclerView でリスト表示する

RecyclerView とは、大きなデータセットを並べて表示できる ViewGroup です。並べ方を設定することができ、リストやグリッドなどで表示することができます。

今回は、以下のようなリストを表示してみます。

ライブラリのインポート

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

dependencies {
    implementation "androidx.recyclerview:recyclerview:1.2.1"
}

データクラスの作成

まず、RecyclerView に表示するデータクラスを作成します。今回は、リストの各項目ごとに 2 つの文字列を表示するので、データクラスにも String 型文字列を 2 つ用意します。

data class TwoLines(
    val primary: String = "Primary text",
    val secondary: String = "Secondary text",
)

レイアウトファイルの作成

次に、レイアウトファイルを作成します。RecyclerView を使用する場合、RecyclerView を構成する各項目のレイアウトファイルを作成する必要があります。

<?xml version="1.0" encoding="utf-8"?>
<!-- two_lines_item.xml -->
<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="wrap_content"
    android:layout_margin="4dp">

    <ImageView
        android:id="@+id/iconImage"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_baseline_android_24"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/primaryText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/iconImage"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Primary text" />

    <TextView
        android:id="@+id/secondaryText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/iconImage"
        app:layout_constraintTop_toBottomOf="@id/primaryText"
        tools:text="Secondary text" />

</androidx.constraintlayout.widget.ConstraintLayout>

なお、Activity については、RecyclerView のインスタンスを直接 AppCompatActivity#setContentView に渡すため、Activity のレイアウトファイルを作成しません。

アダプタの作成

そして、RecyclerView のアダプタを作成します。RecyclerView のアダプタは、RecyclerView とそれに表示するデータセットを紐付ける役割を持ちます。

class TwoLinesListAdapter(
    private val twoLinesList: List<TwoLines>
) : RecyclerView.Adapter<TwoLinesListAdapter.ViewHolder>() {

    inner class ViewHolder(view: View) :
        RecyclerView.ViewHolder(view) {

        val primaryText: TextView =
            view.findViewById(R.id.primaryText)
        val secondaryText: TextView =
            view.findViewById(R.id.secondaryText)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.two_lines_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(
        holder: ViewHolder, position: Int
    ) {
        holder.apply {
            val twoLines = twoLinesList[position]
            primaryText.text = twoLines.primary
            secondaryText.text = twoLines.secondary
        }
    }

    override fun getItemCount() = twoLinesList.size
}

RecyclerView のアダプタには、以下を実装する必要があります。

  • RecyclerView.Adapter の継承
class TwoLinesListAdapter(
    private val twoLinesList: List<TwoLines>
) : RecyclerView.Adapter<TwoLinesListAdapter.ViewHolder>() {
    ︙
  • ViewHolder の定義

ViewHolder には、RecyclerView に表示する各項目の View を持たせます。RecyclerView は自身がスクロールされるたびに、既存の ViewHolder インスタンスに新しいデータを設定することで、ViewHolder インスタンスを使い回し、効率よく動作します。

    inner class ViewHolder(view: View) :
        RecyclerView.ViewHolder(view) {

        val primaryText: TextView =
            view.findViewById(R.id.primaryText)
        val secondaryText: TextView =
            view.findViewById(R.id.secondaryText)
    }
  • RecyclerView.Adapter#onCreateViewHolder のオーバーライド

ここでは、さきほど作成した ViewHolder インスタンスの生成処理を実装する必要があります。

    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int
    ): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.two_lines_item, parent, false)
        return ViewHolder(view)
    }
  • RecyclerView.Adapter#onBindViewHolder のオーバーライド

ここでは、引数に渡された ViewHolder インスタンスに新しいデータを設定する処理を実装する必要があります。

    override fun onBindViewHolder(
        holder: ViewHolder, position: Int
    ) {
        holder.apply {
            val twoLines = twoLinesList[position]
            primaryText.text = twoLines.primary
            secondaryText.text = twoLines.secondary
        }
    }
  • RecyclerView.Adapter#getItemCount のオーバーライド

このメソッドは、RecyclerView に表示するデータセットのサイズを返す必要があります。

    override fun getItemCount() = twoLinesList.size

RecyclerView の設定

最後に、RecyclerView に対して必要な設定を行います。

class MainActivity : AppCompatActivity() {

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

        RecyclerView(this).also {
            it.layoutParams = ViewGroup
                .LayoutParams(MATCH_PARENT, MATCH_PARENT)
            setContentView(it)

            // リスト表示
            val layoutManager =
                LinearLayoutManager(this@MainActivity)
            it.layoutManager = layoutManager

            // 罫線の追加
            val dividerItemDecoration = DividerItemDecoration(
                this@MainActivity, layoutManager.orientation
            )
            it.addItemDecoration(dividerItemDecoration)

            // データの追加
            val data: List<TwoLines> = List(20) { TwoLines() }
            it.adapter = TwoLinesListAdapter(data)
        }
    }
}

今回は、RecyclerView に対して以下の設定を行います。

  • LayoutManager の設定

データセットの並べ方を設定します。今回はリスト表示なので、LinearLayoutManager を設定します。

            // リスト表示
            val layoutManager =
                LinearLayoutManager(this@MainActivity)
            it.layoutManager = layoutManager
  • ItemDecoration の追加

RecyclerView は、デフォルトの設定では罫線を表示しません。罫線を表示させるために、DividerItemDecoration を追加します。

            // 罫線の追加
            val dividerItemDecoration = DividerItemDecoration(
                this@MainActivity, layoutManager.orientation
            )
            it.addItemDecoration(dividerItemDecoration)
  • アダプタの設定

さきほど作成したアダプタにデータセットを設定し、そのアダプタを設定します。

            // データの追加
            val data: List<TwoLines> = List(20) { TwoLines() }
            it.adapter = TwoLinesListAdapter(data)

参考

RecyclerView で動的リストを作成する

関連記事

RecyclerView に View Binding を導入する方法について、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】WebView の JavaScript からネイティブコードを呼び出す

今回は、WebView の JavaScript からネイティブコードを呼び出す方法を解説します。

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

JavaScript からネイティブコードを呼び出すまでの流れ

WebView の JavaScript からネイティブコードを呼び出すには、以下を行う必要があります。

  1. JavaScript とネイティブコードをつなぐインターフェースを作成する
  2. JavaScript とネイティブコードのインターフェースを WebView に追加する
  3. WebView の JavaScript を有効にする

今回は、WebView に表示する Web ページにボタンを用意し、そのボタンを押すと、Android ネイティブの Toast を表示するようにします。また、ネイティブコードは JavaScript に対して戻り値を返すようにし、JavaScript はその戻り値を Web ページに表示するようにします。

JavaScript とネイティブコードをつなぐインターフェースを作成する

まず、JavaScript とネイティブコードをつなぐインターフェースを作成します。

class WebAppInterface(private val context: Context) {

    @JavascriptInterface
    fun showMessage(message: String): String {
        Toast.makeText(context, message, Toast.LENGTH_LONG).show()
        return message
    }
}

インターフェースには、JavaScript から呼び出すネイティブコードをメソッドとして作成します。このメソッドには、@JavascriptInterface を付与し、public にする必要があります。

今回作成するメソッドでは、JavaScript から文字列を受け取るようにし、その文字列を Toast に表示します。また、受け取った文字列をそのまま戻り値として呼び出し元に返します。

WebView の設定

次に、WebView にさきほど作成したインターフェースを追加し、JavaScript を有効にします。

class MainActivity : AppCompatActivity() {

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

        WebView(this).apply {
            setContentView(this)
            loadUrl("file:///android_asset/index.htm")
            val webAppInterface = WebAppInterface(this@MainActivity)
            addJavascriptInterface(webAppInterface, "Android")

            settings.apply {
                allowFileAccess = true
                javaScriptEnabled = true
            }
        }
    }
}

まず、以下のように WebView にさきほど作成したインターフェースを追加します。

WebView#addJavascriptInterface の第 1 引数にインターフェースのインスタントを渡します。そして、第 2 引数には、JavaScript から呼び出す際の名前を指定します。例えば、この名前に "Android" を指定した場合、JavaScript から呼び出す際は Android.showMessage() となります。

        WebView(this).apply {
            ︙
            val webAppInterface = WebAppInterface(this@MainActivity)
            addJavascriptInterface(webAppInterface, "Android")
            ︙

そして、WebView の JavaScript を有効にします。デフォルトでは JavaScript は無効になっているので、有効にする必要があります。

        WebView(this).apply {
            ︙
            settings.apply {
                ︙
                javaScriptEnabled = true
            }
        }

以上で、ネイティブの実装は完了です。これで、JavaScript からネイティブコードを呼び出すことができるようになります。

JavaScript からネイティブコードを呼び出す

JavaScript からネイティブコードを呼び出してみます。Web ページの HTML を以下のようにします。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <script type="text/javascript">
            function showMessage(message) {
                const result = Android.showMessage(message);
                document.getElementById("text").innerHTML = result;
            }
        </script>
        <input type="button" value="Button"
               onClick="showMessage('Hello Android!');" />
        <p id="text"></p>
    </body>
</html>

ボタンが押されたら、JavaScript とネイティブコードのインターフェースの showMessage メソッドを呼びます。この呼び出しにより、Android ネイティブの Toast が表示されます。また、showMessage メソッドの戻り値を p タグに表示します。

参考

WebView での JavaScript の使用

関連記事

WebView でアプリ組み込みの Web ページを表示する方法について、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】WebView でアプリ組み込みの Web ページを表示する

今回は、WebView でアプリ組み込みの Web ページを表示する方法を解説します。

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

WebView による Web ページの表示

WebView は、HTML などの Web ページを表示できる View です。

例えば、マニフェストファイルにインターネットアクセス権限を付与して、以下のように URL を指定することにより、その URL の Web ページを表示できます。

val webView: WebView = findViewById(R.id.webView)
webView.loadUrl("https://www.example.com")

一方で、表示する Web ページのファイルをアプリに組み込み、それを表示することも可能です。

ここでは、その方法について解説します。

Web ページのファイルをアプリに組み込む

まず、Web ページのファイルを保存する assets ディレクトリを、アプリモジュールに作成します。

アプリモジュールのディレクトリを右クリックして、New -> Directory の順番でクリックします。

New Directory のウィンドウが表示されるので、src/main/assets をクリックします。

そうすると、アプリモジュールに assets ディレクトリが作成されます。

この assets ディレクトリに以下のような HTML ファイルを保存します。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <p>Hello World!</p>
    </body>
</html>

以上で、Web ページのファイルのアプリ組み込みは完了です。

ローカルの Web ページを表示する

アプリに組み込んだ Web ページを WebView に表示してみます。

WebView を表示する Activity を以下のように実装します。

class MainActivity : AppCompatActivity() {

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

        WebView(this).apply {
            setContentView(this)
            loadUrl("file:///android_asset/index.htm")

            settings.allowFileAccess = true
        }
    }
}

今回は XML のレイアウトファイルを使わずに、WebView のインスタンスを Activity の setContentView に直接渡します。

WebView(this).apply {
    setContentView(this)
    ︙
}

その後、WebView#loadUrl に 表示したい Web ページのファイルパスを指定します。このとき、ファイルパスは、file:///android_asset/{ファイル名} となるように指定します。

    loadUrl("file:///android_asset/index.htm")

最後に、WebView に対してファイルアクセス許可を有効にする必要があります。この設定値は、Android 11 以降、デフォルト値が false に変更されているため、true に設定します。

    settings.allowFileAccess = true

これで、WebView にローカルの Web ページが表示されるようになります。

関連記事

WebView の JavaScript からネイティブコードを呼び出す方法について、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】SavedStateHandle を使ってプロセス破棄を越えて値を保持し続ける

今回は、SavedStateHandle を使ってプロセス破棄を越えて値を保持し続ける方法を解説します。

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

  • Android Studio Bumblebee | 2021.1.1 Patch 3
  • JDK 11.0.11
  • Android Gradle Plugin 7.1.3
  • Kotlin 1.6.20
  • Gradle 7.4.2
  • androidx.lifecycle 2.4.1

SavedStateHandle とは?

以前、ViewModel を使えば、画面回転などにより Activity や Fragment が破棄されても、値を保持し続けることができることを解説しました。

Activity や Fragment が破棄されるだけであれば ViewModel だけで問題ありません。しかし、プロセスが破棄されてしまうと、ViewModel に保持した値も破棄されてしまいます。ViewModel 自体が破棄されてしまうからです。

そこで、ViewModel において、プロセス破棄を越えて値を保持したい場合には SavedStateHandle を使います。

SavedStateHandle には、Key-Value の組み合わせで値を読み書きすることができ、そこに保存された値はプロセス破棄を越えて保持され続けます。

この SavedStateHandle は、ViewModel のコンストラクタの引数から取得することができ、ViewModel の中で SavedStateHandle に対して保持したい値を読み書きします。

class SavedStateViewModel(handle: SavedStateHandle) : ViewModel() {
    ︙
}

また、ViewModel のコンストラクタの引数に SavedStateHandle を持たせる場合、ViewModel のインスタンスの取得方法はいままでのままで大丈夫です。ViewModel のファクトリが ViewModel に対して SavedStateHandle のインスタンスを渡してくれます。

class MainFragment : Fragment(R.layout.main_fragment) {
    private val viewModel: SavedStateViewModel by viewModels()
    ︙
}

ライブラリのインポート

SavedStateHandle を使うためには、以下のようにアプリの build.gradle ファイルの dependencieslifecycle-viewmodel-savedstate を追加すればよいです。

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

SavedStateHandle に値を読み書きする

SavedStateHandle に値を読み書きする場合、以下のように SavedStateHandle#getLiveData を使うのが便利です。

class MainViewModel(handle: SavedStateHandle) : ViewModel() {

    /** Button のクリック回数 */
    private val _count = handle.getLiveData("count", 0)
    val count: LiveData<Int> get() = _count

    /** クリック回数をカウントアップする。 */
    fun countUp() {
        val value = count.value!!
        _count.value = value + 1
    }
}

SavedStateHandle#getLiveData は、SavedStateHandle に書き込まれた値を LiveData で受け取ることができるため、値の変更を補足しやすいです。また、MutableLiveData の setValue や postValue に値を設定すると、自動的に SavedStateHandle に値が書き込まれます。

その他

SavedStateHandle には、getLiveData 以外に以下のメソッドが存在します。

メソッド 機能
get 指定されたキーの値を返す。
set 指定されたキーと値の組み合わせで SavedStateHandle に保存する。
contains 指定されたキーの値の有無を返す。
remove 指定されたキーの値を削除する。
keys SavedStateHandle 内に含まれるすべてのキーを返す。

参考

ViewModel の保存済み状態のモジュール

関連記事

ViewModel の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

LiveData の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

【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 のフォーマットをデータクラスで宣言する必要があります。Moshi で 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

関連記事

Moshi で JSON をパースする方法について、以下で解説しています。 tatsurotech.hatenablog.com