前回の続きです。
今回の目標
今回の目標は以下の通りです。
- 値を何度も入力できるようにする
- 入力された値をリスト表示
1. 値を何度も入力できるようにする
この方法はいくつも手段があるのですが、右上にメニューを追加して対応するととにします。
(1) メニューをMainActivityに追加する
1. menuリソースファイルを作成する
-
res
で右クリックし、[New]-[Directory]と選ぶ
- menuと入力し、[OK]をクリック
- 出来た
menu
フォルダで再び右クリック、[New]-[Menu resource file]を選択
- main_menuと入力し、[OK]をクリック
main_menu.xmlファイルが開くと思います。[Text]タブを選ぶと、こんな感じ。
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
</menu>
2. menuタイトル文字列を作成する(任意作業)
文字列べた書きで警告が出るのが嫌な人は、stringリソースに作っておきましょう。
<string name="menu_label_add_record">記録を追加</string>
3. アイコンを作成する
メニューはアイコンで表示したいので、アイコンを作ります。
Androidの画像リソースは、drawable
という所に入れていきます。
解像度ごとにアイコン画像をpng
ファイルで用意する方法もありますが、最近はVector
画像で作ることが推奨されているので、試しに作ってみましょう。
-
res
-drawable
で右クリックし、[New]-[Vector Asset]を選ぶ
- ドロイド君アイコンの所をクリック
このままだと黒いアイコンになりますが、黒いと見づらいので(ツールバーの色が濃い場合。薄い人は逆に黒い方が良いでしょう)
- 下記画面で、[000000]という黒背景の部分をクリック
- "#[]"とある入力欄の部分に、[ffffff]と入力し、[Choose]をクリック
- アイコン名を"ic_add_white_24dp"に変更
- [Next], [Finish]とクリック
drawable
フォルダにic_add_white_24dp.xml
というファイルが追加されます。
ファイルの中身はベクター画像のパス情報などです。ここで解説はしませんが、気になる人は自分で調べてみて下さい。
Androidでは解像度別に何種類かの大きさのアイコン画像ファイルを用意しておくと、実行時にOSがその端末の解像度を判断して最適な画像を選んでくれる、無ければ拡縮して表示してくれる、という機能があるのですが、それだと、以下のようなデメリットがありました。
- 拡縮時、当然ながら画像が劣化する
- 劣化を避けるためには、解像度の異なる端末が発売される度に画像を増やさなければならない
- 画像ファイルだけでapkの容量が肥大する
ベクター画像にすると、拡縮の劣化も避けられ、何種類ものサイズの画像を用意する必要も無くなる・・・ということで、最近ではベクター画像を用意することが推奨されています。
ただ、使用できるベクター画像が、パスが閉じていなければならないとか、いろいろと制約があるので、これまでどおり画像を使うプロジェクトも少なくはないです。
メニュータイトルと、アイコンが用意できたので、早速メニュー項目を作りましょう。
4. メニューアイテムを作る
main_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
をオーバーライドします。
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
val inflater = menuInflater
inflater.inflate(R.menu.main_menu, menu)
return true
}
実行してみましょう。画面右上、アクションバーの上にアイコンが表示されているはずです。
押してみましょう。何も起こらない?当然です。メニューが選択されたときのコードを実装していません。
(2) メニューが選択されたときのアクションを実装する
メニューが選択されたときのアクションは、onOptionsItemSelected
をオーバーライドして実装します。
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
節が必ず必要なところです。
上記のコードでは、「itemId
がR.id.add_record
だったら、InputDialogFragment
を表示する」ということを実装しています。
それ以外は処理をしないので、falseを返して、後続に処理を任せています。(逆に言えば、自分で処理をし、後続に処理を渡す必要が無いときには、true
を返すことになっています)
実行して、"+"ボタンをタップしてみて下さい。何度でもダイアログが表示されるようになりました。
2. 入力された値をリスト表示する
現状では、値を入力する度に表示されている値が置き換わります。
これを、入力した値をすべて保持して、リスト表示し、行に追加していく実装をしましょう。
(1) 値をリストに保持する
1. LiveDataを型などを変更する
Listに保持したいので、MainViewModel
にあるinputStepCount
の型なんかを変更しましょう。
まず、型をMutableLiveData<MutableList<Int>>
に変更します。
val inputStepCount = MutableLiveData<MutableList<Int>>()
続いて、変数名もリストと分かりやすい物に変更したいですが、そのまま変更すると、既にこの変数を参照している箇所でエラーになってしまうので、「リファクタリング」という機能を使って変更します。
-
inputStepCount
変数の所にカーソルを合わせ、右クリックメニューで[Refactor]-[Rename]と選ぶ
- 赤枠が表示されたら、"stepCountList"等任意の名前に変え、Enterキーを押す
これで、inputStepCount
を参照していた箇所がすべて、stepCountList
に置換されました。
2. LiveDataの初期化、操作関数を作る
val stepCountList
の初期化コードを書きましょう。
メンバー変数の初期化は、init
ブロックを書くことで行えます。init
は、コンストラクタから呼ばれます。
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()
として空の変更可能リストを作成しています。
続いて、値を追加していく関数を追加しましょう。
@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
に要素を追加した後で、わざわざ、value
にlist
を再セットする、というコードになっています。このとき、value
に代入されるListのインスタンスは、実際には変わっていません。全く同じ物です。でも、value
に値が「セット」されたので、オブザーバーには通知がなされます。
このように、LiveDataの通知の仕組みは、「同じ値であっても、代入が呼ばれると、必ず通知される」という形になっています。これが都合が良いときもあれば、悪いときもありますが、まずはこの性質を把握しておくことが大事だと思います。今回は、この仕様で良かったパターンですね。もし、「値が変わらなければ通知されない」だと、毎回、Listごと、作り直す必要が生じますので。
3. LiveDataへの値を参照している箇所の実装を変更する
LiveDataをリスト型に変えたので、それに併せて、参照していた箇所のコードを変更していきましょう。
-
InputDialogFragment
で入力した数値を入れていた箇所を、リストに追加するように変更する
viewModel.addStepCount(step.toInt())
-
MainAcvitiy
のobserver
の型を変更し、処理も変更する
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
を作って、こんなレイアウトにしてみてください。
サンプルはこちら。
<?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に下記を追加して下さい。
implementation 'androidx.recyclerview:recyclerview:1.1.0-alpha06'
続いて、activity_main.xml
にあったTextView
を、RecyclerView
に置き換えます。
<?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
クラスでは、itemView
はR.layout. item_step_log
がインフレートされた実体なので、その中には、stepTextView
があるはずで、それを保持しています。このクラスの役割はこれだけです。
LogRecyclerAdapter
のgetItemCount
はその名の通り、アイテムの数を返す関数です。リストが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をセットしてやるなど、初期化が必要です。
初期化は、MainActivity
のonCreate
の中で行います。
まず下記のプライベートメンバーを宣言します。
private lateinit var adapter : LogRecyclerAdapter
続いて、下記のコードをViewModelを作成した後に追加します。
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のvalue
にinit
ブロックで空リストを作成して入れていたのを覚えてるでしょうか、)この時点でnullが有り得ないので!!
を使っています。
log_list.adapter = adapter
でRecyclerViewにAdapterをセットしています。これでリストが表示されます。
たくさん入力するとちゃんとスクロールもしますよ。
5. 区切り線を入れる
今のままだと、行の区切りが分かりません。RecyclerViewでは、自分で区切りを入れてやらないと行けないのです。
RecyclerViewを初期化している部分に、以下の記述を追加すると、区切り線が出るようになります。
// 区切り線を追加
val decor = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
log_list.addItemDecoration(decor)
3. テスト
例によってテストを書いていきましょう。
今回追加するテストは、
- 追加用のメニューアイコンが表示されていること
- メニューをタップしたときに入力ダイアログが起動すること
-
MainViewModel#addStepCount
でLiveDataのリストデータが追加されること - LiveDataのリストの内容が、RecyclerViewで正しく表示されていること
というのを確認する、という内容で作っていきます。
テスト用にライブラリの追加が必要なので、app/build.gradle
のdependenciesに下記を追加して下さい。
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
は、本当はtestImplementation
かandroidTestImplementation
のどちらかだけでいいのですが(対象のテストがRobolectricでも書けるため)、どちらに転んでも良いように両方書いておきます。
また、MainActivityTestI.kt
を開くと、textView
が無くなっているのでエラーが出ていると思いますが、その行は
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
クラスに、以下のテストを追加します。
@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
フォルダの方です。
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派)に次のテスト関数を実装します。
@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クラス向けです。)
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