13
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(3)リスト表示編

Last updated at Posted at 2019-06-10

前回の続きです。

今回の目標

今回の目標は以下の通りです。

  • 値を何度も入力できるようにする
  • 入力された値をリスト表示

1. 値を何度も入力できるようにする

この方法はいくつも手段があるのですが、右上にメニューを追加して対応するととにします。

(1) メニューをMainActivityに追加する

1. menuリソースファイルを作成する

  • resで右クリックし、[New]-[Directory]と選ぶ

kotlin_03_002.png

  • menuと入力し、[OK]をクリック

kotlin_03_003.png

  • 出来たmenuフォルダで再び右クリック、[New]-[Menu resource file]を選択

kotlin_03_004.png

  • main_menuと入力し、[OK]をクリック

kotlin_03_005.png

main_menu.xmlファイルが開くと思います。[Text]タブを選ぶと、こんな感じ。

main_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

</menu>

2. menuタイトル文字列を作成する(任意作業)

文字列べた書きで警告が出るのが嫌な人は、stringリソースに作っておきましょう。

strings.xml
<string name="menu_label_add_record">記録を追加</string>

3. アイコンを作成する

メニューはアイコンで表示したいので、アイコンを作ります。
Androidの画像リソースは、drawableという所に入れていきます。
解像度ごとにアイコン画像をpngファイルで用意する方法もありますが、最近はVector画像で作ることが推奨されているので、試しに作ってみましょう。

  • res-drawableで右クリックし、[New]-[Vector Asset]を選ぶ

kotlin_03_006.png

  • ドロイド君アイコンの所をクリック

kotlin_03_007.png

  • "+"のアイコンを選び、[OK]をクリック

    • 検索窓に"add"と入れると見つけやすくなります。

    kotlin_03_008.png

このままだと黒いアイコンになりますが、黒いと見づらいので(ツールバーの色が濃い場合。薄い人は逆に黒い方が良いでしょう)

  • 下記画面で、[000000]という黒背景の部分をクリック

kotlin_03_010.png

  • "#[]"とある入力欄の部分に、[ffffff]と入力し、[Choose]をクリック

kotlin_03_011.png

  • アイコン名を"ic_add_white_24dp"に変更

kotlin_03_012.png

  • [Next], [Finish]とクリック

drawableフォルダにic_add_white_24dp.xmlというファイルが追加されます。
ファイルの中身はベクター画像のパス情報などです。ここで解説はしませんが、気になる人は自分で調べてみて下さい。

Androidでは解像度別に何種類かの大きさのアイコン画像ファイルを用意しておくと、実行時にOSがその端末の解像度を判断して最適な画像を選んでくれる、無ければ拡縮して表示してくれる、という機能があるのですが、それだと、以下のようなデメリットがありました。

  • 拡縮時、当然ながら画像が劣化する
  • 劣化を避けるためには、解像度の異なる端末が発売される度に画像を増やさなければならない
  • 画像ファイルだけでapkの容量が肥大する

ベクター画像にすると、拡縮の劣化も避けられ、何種類ものサイズの画像を用意する必要も無くなる・・・ということで、最近ではベクター画像を用意することが推奨されています。
ただ、使用できるベクター画像が、パスが閉じていなければならないとか、いろいろと制約があるので、これまでどおり画像を使うプロジェクトも少なくはないです。

メニュータイトルと、アイコンが用意できたので、早速メニュー項目を作りましょう。

4. メニューアイテムを作る

main_menu.xmlを、下記のようにします。

mamin_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
      xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@+id/add_record"
          android:title="@string/menu_label_add_record"
          android:icon="@drawable/ic_add_white_24dp"
          app:showAsAction="always"/>
</menu>

というので1つのメニュー項目を作ります。
android:titleは、メニュータイトルの文字列リソース、android:iconが、メニューのアイコンリソースを指定しています。

app:showAsActionというのは、「この項目を表示するタイミング」を指示するもので、"always"なので"常に表示"を指定しています。追加ボタンは常に表示されていて欲しいのでね。

メニューアイコンボタンが複数有るときは、どんなに表示しようとしても画面の幅が足りなくて表示出来ないこともあるので、"always"を指定するのはせいぜい1-2個程度にしておくのが無難かと思います。
(表示出来るなら表示する、という指定には"ifRoom"という値があります。詳しくはこちらで)

5. メニューをActivityに追加する

menuリソースを作っただけでは表示されません。Activityで表示されるようにしていきます。

オプションメニューを表示するには、onCreateOptionsMenuをオーバーライドします。

MainActivity.kt
    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        val inflater = menuInflater
        inflater.inflate(R.menu.main_menu, menu)
        return true
    }

実行してみましょう。画面右上、アクションバーの上にアイコンが表示されているはずです。

kotlin_03_013.png

押してみましょう。何も起こらない?当然です。メニューが選択されたときのコードを実装していません。

(2) メニューが選択されたときのアクションを実装する

メニューが選択されたときのアクションは、onOptionsItemSelectedをオーバーライドして実装します。

MainActivity.kt
    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        item?.let {
            return when(it.itemId){
                R.id.add_record ->{
                    InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
                    true
                }
                else -> false
            }
        }
        return false
    }

item?.let{}は、itemがnullでないとき、let{}内の処理を実行し、任意の型のオブジェクトを返す、というコードです。

when(){}は、Javaでいうswitch-case文を書けます。Javaと違うのは、else節が必ず必要なところです。

上記のコードでは、「itemIdR.id.add_recordだったら、InputDialogFragmentを表示する」ということを実装しています。
それ以外は処理をしないので、falseを返して、後続に処理を任せています。(逆に言えば、自分で処理をし、後続に処理を渡す必要が無いときには、trueを返すことになっています)

実行して、"+"ボタンをタップしてみて下さい。何度でもダイアログが表示されるようになりました。

2. 入力された値をリスト表示する

現状では、値を入力する度に表示されている値が置き換わります。
これを、入力した値をすべて保持して、リスト表示し、行に追加していく実装をしましょう。

(1) 値をリストに保持する

1. LiveDataを型などを変更する

Listに保持したいので、MainViewModelにあるinputStepCountの型なんかを変更しましょう。
まず、型をMutableLiveData<MutableList<Int>>に変更します。

MainViewModel.kt
val inputStepCount = MutableLiveData<MutableList<Int>>()

続いて、変数名もリストと分かりやすい物に変更したいですが、そのまま変更すると、既にこの変数を参照している箇所でエラーになってしまうので、「リファクタリング」という機能を使って変更します。

  • inputStepCount変数の所にカーソルを合わせ、右クリックメニューで[Refactor]-[Rename]と選ぶ

kotlin_03_014.png

  • 赤枠が表示されたら、"stepCountList"等任意の名前に変え、Enterキーを押す

kotlin_03_015.png

これで、inputStepCountを参照していた箇所がすべて、stepCountListに置換されました。

2. LiveDataの初期化、操作関数を作る

val stepCountListの初期化コードを書きましょう。
メンバー変数の初期化は、initブロックを書くことで行えます。initは、コンストラクタから呼ばれます。

MainViewModel.kt
    init{
        stepCountList.value = mutableListOf()
    }

LiveDataとして扱いたい値であるvalueに、mutableListOf()で空のリストを作って渡しています。

Kotlinでは、コレクション(List, Map等)はそれが「値変更可能なのか」「読み取り専用なのか」を、とても厳密に宣言しなければならないことになっています。Javaでは意識しないで出来ましたが、Kotlinでは「読み取り専用」リストに対して、値を追加したり削除したりという操作はできないので、型宣言の時から注意しなくてはなりません。

コレクションの種類 変更可能(mutable) 読み取り専用(read-only)
リスト MutableList List
マップ MutableMap Map
セット MutableSet Set

mutableなリストを作成したければ、mudableListOf()を使い、読み取り専用のリストならばlistOf()を使います。(Map, Setもほぼ同様)

ということで、今回は、リストを追加していきたいので、mutableListOf()として空の変更可能リストを作成しています。

続いて、値を追加していく関数を追加しましょう。

MainViewModel.kt
    @UiThread
    fun addStepCount(count: Int) {
        val list = stepCountList.value ?: return
        list.add(count)
        stepCountList.value = list
    }

@UiThreadは、「この関数はUIスレッドから呼ばれなければならない」というアノテーションです。というのもLiveDataの値valueに値を代入するのは、UIスレッド以外からも出来るのですが、その場合は、LiveData#postValueという関数を使わなければならないことになっています。例えばAPIで通信処理の結果から値を更新するような場合は、ワーカースレッドから変更を掛けることになるので、postValueを使う必要があります。今回は、UIから呼ばれることが分かっているので、value=xxxという代入式で書くことが出来ます。逆に、valueへの代入を使っているので、UI以外から呼ばれたときにクラッシュします。ということで、このメソッドは、UIから呼ばなきゃダメだよと言うことを、コードを読む人と、コンパイラーに明示しています。

大きなプロジェクト、複数人で開発する、メンテは他の誰かがする・・・などの場合、アノテーションを見て「ああUIスレッド以外から呼んだらあかんのね」と分かるようにしておいてあげるのが親切ってもんですし、アノテーションがあれば、コンパイラーが「UIスレッド以外から呼んでるよ」とエラーを教えてくれるので(多分)、実装時に気付くことが出来ます(そのはず)。

さて、関数の中身は、なんだかややこしいことをしていますね。
val list = stepCountList.value ?: returnの部分で使っている?:は、Kotlinのエルビス演算子と呼ばれる物で、左側の式がnullになるときの値か処理を右に書くことが出来ます。
listがnullは実装上はあり得ませんが、「nullableなのにnullチェックして無い」とうるさい警告が出てビルドが通らないので、何かしらのチェックが必要です。ここはreturnするのだけが正解では無いと思います。nullならリストを作るコードを書いてもいいし、そもそも実装上nullが有り得ないなら、val list = stepCountList.value!!でもいいところです。この辺は好みの問題かと思います。
今回は、エルビス演算子の解説もしたかったのでこのようにしました。

問題は次の行からです。

パッと考えると、stepCountList.value?.add(count)ではダメなんだろうか?と思われると思います。思って普通です。私も思いました。実際そのコードで実行して、期待通りの動作にならず、1時間悩みました(笑)

LiveDataの更新が通知されるのは、value値が上書きされたときです。コレクションの場合、いくらコレクションの中身を変更しても、そのコレクションそのもののインスタンスが変わるわけではありません。なので、実は、stepCountList.value?.add(count)というコードでは、リストにいくら値を追加しても、オブザーバーに更新が通知されません。(C/C++系の経験のある方は、「ポインターが変わらないから」と考えれば分かりやすいかと思います。)あくまでも、valueの値が書き換わったときのみ、反応するという仕様なのです。
ここで、書き換わった=値が変わったではないことも重要です。同じ値であっても再代入があれば、やはり通知がなされます。

ということで、listに要素を追加した後で、わざわざ、valuelistを再セットする、というコードになっています。このとき、valueに代入されるListのインスタンスは、実際には変わっていません。全く同じ物です。でも、valueに値が「セット」されたので、オブザーバーには通知がなされます。
このように、LiveDataの通知の仕組みは、「同じ値であっても、代入が呼ばれると、必ず通知される」という形になっています。これが都合が良いときもあれば、悪いときもありますが、まずはこの性質を把握しておくことが大事だと思います。今回は、この仕様で良かったパターンですね。もし、「値が変わらなければ通知されない」だと、毎回、Listごと、作り直す必要が生じますので。

3. LiveDataへの値を参照している箇所の実装を変更する

LiveDataをリスト型に変えたので、それに併せて、参照していた箇所のコードを変更していきましょう。

  • InputDialogFragmentで入力した数値を入れていた箇所を、リストに追加するように変更する
InputDialogFragment.kt
viewModel.addStepCount(step.toInt())
  • MainAcvitiyobserverの型を変更し、処理も変更する
MainActivity.kt
        viewModel.stepCountList.observe(this, Observer { list ->
            list?.let{
            }
        })

listは、MutableList<Int>!型のオブジェクトです。itでも勿論アクセスできるのですが、今回、リストに変わったことを分かりやすくするため、引数にlistとラベルを付けました。当然ながら、このlistには、LiveDataで変更されたリストが渡ってきます。
list?.let{}で、そのlistがnullでないときに処理をしています。UIの変更をする処理をここに書いていきます。

これでリストを受け取れるようになりました。
このリストを使って、リスト表示していきましょう。

(2) リスト表示

リストを表示するには、ListView, RecyclerViewといくつか手法がありますが、Googleさんの推奨がRecyclerViewなのでそちらを使っていきましょう。

1. リスト項目用のレイアウトを作成する

リストの1行をどう表示するか、決めたいですよね。そのためのレイアウトファイルを作成していきます。

res-layoutに、item_step_log.xmlを作って、こんなレイアウトにしてみてください。

kotlin_03_017.png

サンプルはこちら。
item_step_log.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
             android:orientation="horizontal"
             android:layout_width="match_parent"
             android:layout_height="wrap_content">

    <LinearLayout
            android:orientation="horizontal"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" 
            android:layout_gravity="center_horizontal">
        <TextView
                android:text="この日は"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        <TextView
                android:text="12356"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/stepTextView"
                android:textSize="24sp"
                android:textColor="#0B0A0A"/>
        <TextView
                android:text="歩 歩きました"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
    </LinearLayout>
</FrameLayout>

2. MainActivityのレイアウトをRecyclerViewに変更する

TextViewしかなかったMainActivityのレイアウトを、RecyclerViewを使ったものに変更します。

app/build.gradleのdependenciesに下記を追加して下さい。

app/build.gradle
implementation 'androidx.recyclerview:recyclerview:1.1.0-alpha06'

続いて、activity_main.xmlにあったTextViewを、RecyclerViewに置き換えます。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/log_list"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginTop="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"/>

</androidx.constraintlayout.widget.ConstraintLayout>

3. RecyclerView用のAdapterを作成する

RecyclerViewを使うのはパターン化されているので、一度覚えてしまえば楽です。

  • Adapterクラスを作成する
    • 作成するのはMainActivity.ktの中でも良いですし、ファイルを新規に作っても良いです。
class LogRecyclerAdapter(private var list: List<Int>) : RecyclerView.Adapter<LogRecyclerAdapter.LogViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val rowView = LayoutInflater.from(parent.context).inflate(R.layout.item_step_log, parent, false)
        return LogViewHolder(rowView)
    }

    fun setList(newList:List<Int>){
        list = newList
        notifyDataSetChanged()
    }

    override fun getItemCount() = list.size


    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        holder.textCount.text = if (position < list.size) list[position].toString() else ""
    }

    class LogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textCount = itemView.stepTextView!!
    }
}

LogRecyclerAdapterクラスは、コンストラクタでリストを受け取り、onCreateViewHolderで1行のレイアウトを作成して、そのViewオブジェクトを基にLogViewHolderというものを作って返しています。このonCreateViewHolderは、新しいViewが必要になったときにだけ呼ばれます。

RecyclerViewは、その名の通り、Viewをリサイクルしています。スクロールして上に行って非表示になった行のViewオブジェクトを、下から現れる行に使い回しているんです。その時、Viewと、表示すべきデータの紐付けなんかをうまくやりくりしてくれるのが、このAdapterクラスとViewHolderクラスの役割です。

LogViewHolderクラスでは、itemViewR.layout. item_step_logがインフレートされた実体なので、その中には、stepTextViewがあるはずで、それを保持しています。このクラスの役割はこれだけです。

LogRecyclerAdaptergetItemCountはその名の通り、アイテムの数を返す関数です。リストがnon-nullなので、nullチェックせずにそのままlist.size()を返しています。

onBindViewHolderは、実際にViewに表示するデータをバインドするときに呼ばれます。Viewオブジェクトは、新しく作られたものかも知れないし、使い回されてきたViewかも知れません。が、使う側はそんなこと気にせず、ただ表示したい値をセットするだけです。

holder.textCount.text = if (position < list.size) list[position].toString() else ""

positionがリストサイズより多いとIndexOutOfBoundsExceptionが起きるので、サイズをチェックし、範囲内ならそのリストからその位置のアイテムを表示し、外れているようであれば空文字""を指定しています。
(※もっとも、サイズ以上のindexがくるわけがない・・・という暗黙の了解で、ここでチェックをするのは冗長だ、という意見もあると思います。でも、削除したり出来るリストの場合は、やっておいた方が無難かなあと思います。ま、お好みで。)

Kotlinでは、リストの要素にも、[]でアクセスできます。get(index)とやらなくて良いんですね。
また、if文が値を返せるので、このように書けます。
Javaであれば、三項演算子(a==b ? "A" : "B"みたいなのですね)が出番な所です(私は三項演算子が短く書けて好きなので、Kotlinで無くなって残念ですが・・・)。

setList関数は、リストを更新する関数です。値が追加されたときに呼び出して、Adapterにデータが変わったことを知らせ(notifyDataSetChanged)、表示が更新されるように促しています。

実は、このコードはリスト全体を書き換えるので効率は良くないです。実際の業務で使う場合には、データが追加されたときや(notifyDataInserted)、データが削除されたとき(notifyDataDeleted)など、それぞれ適したメソッドがあるので、それを使うようにする方が良いでしょう。

このサンプルアプリでは、表示する行のレイアウトもシンプルですし、データ量もたいしたことないので全更新をかけてしまっていますが、View全体に更新をかけるのは、UIスレッドを止めかねないので、避けるべきだということは覚えておいて下さい。

4. RecyclerViewを初期化する

MainActivityに追加したRecyclerViewは、このままではまだ何も表示しません。
RecyclerViewにAdapterをセットしてやるなど、初期化が必要です。

初期化は、MainActivityonCreateの中で行います。

まず下記のプライベートメンバーを宣言します。

MainActivity.kt
private lateinit var adapter : LogRecyclerAdapter

続いて、下記のコードをViewModelを作成した後に追加します。

MainActivity.kt

override fun onCreate(){
....
        // RecyclerViewの初期化
        log_list.layoutManager = LinearLayoutManager(this)
        adapter = LogRecyclerAdapter(viewModel.stepCountList.value!!)
        log_list.adapter = adapter
....
}

log_list.layoutManager = LinearLayoutManager(this)では、レイアウトマネージャーに、LinearLayoutManagerを使うことを設定してます。これはレイアウトをLinear、つまり直線的に並べるレイアウト、いわゆる「リスト表示」ですね。
レイアウトマネージャーには他に、GridLayoutManagerというのがあり、これはGrid表示、つまり格子状にレイアウトを並べるレイアウトです。

viewModel.stepCountList.value!!は、(MainViewModelクラスで、LiveDataのvalueinitブロックで空リストを作成して入れていたのを覚えてるでしょうか、)この時点でnullが有り得ないので!!を使っています。

log_list.adapter = adapterでRecyclerViewにAdapterをセットしています。これでリストが表示されます。

実行してみましょう。
kotlin_03_018.png

たくさん入力するとちゃんとスクロールもしますよ。

5. 区切り線を入れる

今のままだと、行の区切りが分かりません。RecyclerViewでは、自分で区切りを入れてやらないと行けないのです。
RecyclerViewを初期化している部分に、以下の記述を追加すると、区切り線が出るようになります。

MainActivity.kt
// 区切り線を追加
val decor = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
log_list.addItemDecoration(decor)

kotlin_03_020.png

3. テスト

例によってテストを書いていきましょう。
今回追加するテストは、

  • 追加用のメニューアイコンが表示されていること
  • メニューをタップしたときに入力ダイアログが起動すること
  • MainViewModel#addStepCountでLiveDataのリストデータが追加されること
  • LiveDataのリストの内容が、RecyclerViewで正しく表示されていること

というのを確認する、という内容で作っていきます。

テスト用にライブラリの追加が必要なので、app/build.gradleのdependenciesに下記を追加して下さい。

app/build.gradle
    testImplementation "androidx.arch.core:core-testing:2.0.1"

    testImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'

espresso-contribは、本当はtestImplementationandroidTestImplementationのどちらかだけでいいのですが(対象のテストがRobolectricでも書けるため)、どちらに転んでも良いように両方書いておきます。

また、MainActivityTestI.ktを開くと、textViewが無くなっているのでエラーが出ていると思いますが、その行は

MainActivityTestI.kt
   onView(withText("12345")).check(matches(isDisplayed()))

と直すと良いです。

最後に、テストMainActivityTest#helloWorld()は不要になりましたので、削除してください。

(1) 追加用のメニューアイコンが表示されているかのテスト

※このテストは、Robolectricでも動作します。

MainActivityTestクラス(Robolectricでテストしたい方)またはMainActivityTestIクラス(androidTestでテストしたい方)に、次のテスト関数を追加します。

    @Test
    fun addRecordMenuIcon() {
        Espresso.pressBack()

        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).check(matches(isDisplayed()))
    }
  • Espresso.pressBack()でダイアログをキャンセル
  • onView(...)の部分は、すべてのViewから、IdがR.id.add_recordであり、contentDescription="記録を追加"であるものを探しています。
  • 上記で見つかったViewに対して、.check(matches(isDisplayed()))で、表示されていることを確認しています。

(2) メニューをタップしたときに入力ダイアログが起動しているかのテスト

これはandroidTest限定です。(RobolectricはDialogのViewにEspressoでアクセスできないらしいので)
MainActivityTestIクラスに、以下のテストを追加します。

MainActivityTestI.kt
    @Test
    fun addRecordMenu() {
        Espresso.pressBack()

        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        onView(withText(R.string.label_input_title)).check(matches(isDisplayed()))
    }

これは大丈夫ですよね。(1)のテストと同じようにメニューアイコンボタンを見つけ、perform(click())でクリックした後、ダイアログで表示されているべき文字列があるかチェックしています。

※Robolectric向けのこのテスト関数も、Githubのブランチには上げてありますので、よろしければご参考下さい。

(3) MainViewModel#addStepCountでLiveDataのリストデータが追加されているかのテスト

このテストは、UIテストでは無く、MainViewModelクラスのテストとなります。なので、新たにMainViewModelTest.ktというファイルを作成して下さい。作成するのはtestフォルダの方です。

MainViewModelTest.kt
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule

class MainViewModelTest {

    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    lateinit var viewModel: MainViewModel

    @Before
    fun setUp() {
        viewModel = MainViewModel()
    }

    @Test
    fun init() {
        assertThat(viewModel.stepCountList.value)
            .isNotNull()
            .isEmpty()
    }

    @Test
    fun addStepCount() {
        viewModel.addStepCount(123)
        viewModel.addStepCount(456)

        assertThat(viewModel.stepCountList.value)
            .isNotEmpty()

        val list = viewModel.stepCountList.value as List<Int>
        assertThat(list.size).isEqualTo(2)
        assertThat(list[0]).isEqualTo(123)
        assertThat(list[1]).isEqualTo(456)
    }
}
  • @get:Rule val rule: TestRule = InstantTaskExecutorRule() : LiveDataのテストには、特殊なルールが必要なため、ここで指定しています。

  • @Before fun setUp() {} : @Beforeアノテーションは、各テストの実行前に必ず実行されることを示します。ここでは、MainViewModelをインスタンス化しています。

  • @Test fun init() : ついでなので初期化時にnullでないこと、リストが空であることを確認するテストを作りました

  • @Test fun addStepCount() : addStepCountカウントを複数回呼び出して、リストの内容をチェックするテストです。

(4) LiveDataのリストの内容が、RecyclerViewで正しく表示されているかのテスト

リストデータの用意の方法は、アプローチはいくつかあると思いますが、ダイアログは介さずに直接MainViewModel#addStepCountを呼び出してリストを作り、その表示の整合性をチェックする、というテストにしたいと思います。ダイアログを出して入力して、でも良いのですが、そうするとRobolectricで実行できなくなるので・・・
(出来るだけ、androidTestでもRobolectricでも同じコードで動くように実装していきます)
尚、Githubのブランチには、androidTest向けのテストコードは、ダイアログを介して値を追加するコードをpushしていますので、良ければご参考下さい。

MainActivityTestクラス(Robolectric派)、またはMainActivityTestIクラス(androidTest派)に次のテスト関数を実装します。

MainActivityTest.kt

    @Test
    fun addRecordList() {
        Espresso.pressBack()

        val mainActivity = activityRule.activity

        mainActivity.viewModel.addStepCount(12345)
        mainActivity.viewModel.addStepCount(666)

        // リストの表示確認
        var index = 0
        onView(withId(R.id.log_list))
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(
                matches(
                    atPositionOnView(
                        index, withText("12345"), R.id.stepTextView
                    )
                )
            )

        index = 1
        onView(withId(R.id.log_list))
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(
                matches(
                    atPositionOnView(
                        index, withText("666"), R.id.stepTextView
                    )
                )
            )
    }
  • .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index)) は、indexの位置までRecyclerViewをスクロールさせています。今は2行しか無いのでスクロールしませんが、何十行もあるようなデータでテストするときは、スクロールさせて、その行がちゃんと画面に見えている状態でないとテストできませんので、敢えてここでもスクロールするように入れています。

  • atPositionOnViewというのはEspressoでRecyclerViewの指定の位置のViewを取得するための自作関数です。昔作ったのから引っ張ってきています。
    この関数は、新しくファイルを作って関数だけ定義しても良いですし、MainActivityTest.ktファイル内の、MainActivityTestクラス定義の中でも外でも良いです。
    Javaでは、関数だけという定義は出来ず、必ず何かのクラスを作り、そこに属するメソッドにする必要がありましたが(Util系クラスの氾濫の原因とも言えます)、Kotlinでは、ただの関数を宣言が可能です。(この辺はC/C++に回帰したとも言えなくもないのかな)

fun atPositionOnView(
    position: Int, itemMatcher: Matcher<View>, targetViewId: Int
): Matcher<View> {

    return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
        override fun describeTo(description: Description) {
            description.appendText("has view id $itemMatcher at position $position")
        }

        override fun matchesSafely(recyclerView: RecyclerView): Boolean {
            val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
            val targetView = viewHolder!!.itemView.findViewById<View>(targetViewId)
            return itemMatcher.matches(targetView)
        }
    }
}

※上記コードはJavaコードをコピペして自動でKotlin変換された物で、あまり精査していません。Kotlinならこう書くべき、こう書ける、等あればご指摘ください。

この関数は、positionで与えられた行のレイアウト内から、targetVieIdで指定されたViewを探し、そのViewに対してitemMatcher#matchesを実行した値を返しています。
従って、

atPositionOnView(
    index, withText("666"), R.id.stepTextView
)

という呼び出しは、index行のレイアウトのR.id.stepTextViewというViewに、"666"という文字列が表示されているかのテストをしていることになります。

EspressoのMatcherはこうして自作しなければならないことも多いですが、ググればそれなりにヒントが見つかるはずなので、必要が生じたときには諦めずに頑張ってください。

なお、AndroidStudioには、**[Run]**メニューに、 **[Record Espresso Test]**という機能があって、単純なビュー非表示On/Offのテスト、文字列のテスト程度なら割と使えるのですが(今回もいくつかそれで記録したテストを参考にしました)、RecyclerViewのテストには未だに対応してくれていません。これが出来るようになれば本当にUIテストを書くのが楽になるのですがね・・・

さて、テストはすべてPassしていますでしょうか?
していない場合は何か間違えてますので、import文、typo等々よく調べてください。
だいたい、importするパッケージを間違えていることが多いです。

最終的なimport文全体を載せておきます(androidTest向けのMainActivityIクラス向けです。)

MainActivityTestI.kt
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.BoundedMatcher
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

まとめ

オプションメニューアイコンの表示方法、RecyclerViewの表示方法について学びました。

ここまでの状態のプロジェクトをGithubにpushしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_03

次回予告

今はアプリを起動するとデータが消えてしまうので、データが永続化されるようにします。そう、データベースの出番です。RoomというAACの機能を使います。せっかくデータベースに入れるので、もう少しいろいろな情報を扱って表示するようにも変更します。
それと、お気づきと思いますが、ダイアログで値を入力せずに登録ボタンを押すと、クラッシュします。そちらも直します。

参考ページなど

Y.A.M の 雑記帳 - LiveData を UnitTest でテストする
http://y-anz-m.blogspot.com/2018/06/livedata-unittest.html

13
14
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?