前回の続きです。
今回の目標
- データセットを扱う(Dataクラスの作成)
- Data Bindingを使う
- Fragmentを使う
- Activity遷移を覚える
本当はRoomまでやりたかったのですが、Coroutineも絡んできてしまってボリュームが増えすぎるので、それは次回に回します。
今回は画面周りの実装がメインなので、楽しい、はず^^;
1. データセットを扱う
これまでは、記録しているデータはInt型カウント数値1つだけでした。
もう少し複雑なデータセットにしてみようと思います。とはいえ、「歩数計記録アプリ」という目標があるので、必要なのはあと日付くらいですね。
- 歩行記録データクラス
- 日付
- 歩数
個人的には、「よく歩いた/全然歩かなかった/普通」みたいな感じでその日の感想を選択式で入れられるようにしようかな。あとはお天気とか?犬の散歩は雨だと行けないから・・・
- 歩行記録データクラス
- 日付(必須) : "yyyy/MM/dd"
- 歩数(必須) : Int
- 達成度(任意) : Enum(Default=NORMAL, GOOD, BAD)
- 天気(任意) : Enum(Default=FINE, RAIN, CLOUD, HOT, COLD, SNOW...)
他に何か思いつく物があれば任意で追加して下さい。
(1) データクラスを作成する
Kotlinには、その名もズバリ、data class
というキーワードがあります。通常のclass
定義と何が違うかというと、
-
equals()
/hashCode()
を自動生成してくれる -
toString()
を自動生成してくれる -
copy()
を自動生成してくれる
など、自動で色々内部的に作ってくれて使うことができます。ただし制約もあって、**「派生できない/継承できない」**というのもあります。データ設計の際にはこの辺りは要注意ですね。
個人的には、toString()
の自動生成が助かりますね。デバッガーでbreakポイントを貼ったときに、値が確認しやすくなります。Java時代は自前で書くの結構大変でしたから。
早速、上でざっくり設計した歩行記録データクラスをコードに起こしてみましょう。
1. パッケージを追加
まず、新しくdata
というパッケージを追加します。
パッケージルートを右クリックして、[New]-[Package]と選択肢、data
と入力して下さい。

2. データクラスを追加
新しくできたdata
パッケージ下に、新規Kotlinクラスを追加します。
クラス名はStepCountLog
としましょうか。ファイルを新規作成して、以下のように記述します。
enum class LEVEL {
NORMAL, GOOD, BAD,
}
enum class WEATHER {
FINE, RAIN, CLOUD, SNOW, COLD, HOT,
}
data class StepCountLog(
val date: String,
val step: Int,
val level: LEVEL = LEVEL.NORMAL,
val weather: WEATHER = WEATHER.FINE
)
-
enum class LEVEL
が、達成度を表すenumクラスです。 -
enum class WEATHER
が、天気を表すenumクラスです。- Javaにあった
enum
とそれほど大きな違いはありません。少なくとも、この連載で使っていく分には、複雑な使い方をしていませんので、特に難しいことは無いかと思います。
- Javaにあった
-
StepCountLog
クラスは、プライマリコンストラクタのみ定義しています。- 基本的なメソッドを自動で作ってくれるので、データクラスはほとんどの場合、操作のない宣言だけのものになります。
コンストラクタの引数、val level: LEVEL = LEVEL.NORMAL
とval weather: WEATHER = WEATHER.FINE
の部分は、デフォルト値の設定です。Kotlinでは、引数を省略できます。省略した場合は、コンストラクタで指定されているデフォルト値が渡されることになります。
省略可能な引数は、省略不可能なすべての引数より、後ろに宣言されていなくてはなりません。
こういう形は出来ない、ということですね。
data class StepCountLog(
val date: String,
val level: LEVEL = LEVEL.NORMAL,
val step: Int,
val weather: WEATHER = WEATHER.FINE
)
上記のコードだと、クラス宣言の部分では特にエラーは出ないのですが、インスタンス化するコードのところでエラーが出ます。
StepCountLog
クラスのインスタンス化(Javaでいうnew
する)は次のように書けます。
val data1 = StepCountLog("2019/06/11", 123, LEVEL.GOOD, WEATHER.RAIN)
// LEVELはNORMAL, WEATHERはFINEが渡る
val data2 = StepCountLog("2019/06/11", 123)
// 引数を1つだけ省略
val data3 = StepCountLog("2019/06/11", 123, LEVEL.GOOD)
val data4 = StepCountLog("2019/06/11", 123, level = LEVEL.GOOD)
data4
の宣言を見てお気づきの通り、Kotlinでは、引数を渡すときにはエイリアスを指定することができます。エイリアスとは、まあ早い話、コンストラクタの宣言で書いた、引き数名を指定して、引数が渡せる、ということです。なので引数には分かりやすい名前を付けておく方が良いでしょう。
なお、エイリアス指定方式は、すべての関数で使用できます。
エイリアス指定の便利なところは、引数の順番を自由に書けるところでしょうか。
通常、関数の引数は、宣言の順番通りに渡さなければなりませんが、エイリアスを使うと、その順番を任意に出来るのです。
// 引数の順番を任意にする
val data = StepCountLog(step=123, level = LEVEL.GOOD, date = "2019/06/11")
これを利用して、こんな書き方も出来ます。
val data = StepCountLog("2019/06/11", 123, weather = WEATHER.RAIN)
省略可能な引数のうち、後に宣言されたweatherのみ、指定してます。levelはデフォルト値が渡されます。本当に必要なパラメータだけ渡せるので、便利ですね。
まあ、個人的には、行が長くなるので、エイリアスはあまり使いませんが(汗)
(※1行80文字でコードを長く書いてきた人間なので^^;)
3. ViewModelで扱うデータ型を変更する
さて、データクラスを作ったので、ViewModelで扱う型も変えていきましょう。
-
MainViewModel
のLiveDataの型を変更する-
Int型のリストにしていたのを、StepCountLogのリストにする
MainViewModel.ktval stepCountList = MutableLiveData<MutableList<StepCountLog>>()
-
-
MainViewModel
のaddStepCount
の引数の型も、StepCountLogにする
@UiThread
fun addStepCount(stepLog: StepCountLog) {
val list = stepCountList.value ?: return
list.add(stepLog)
stepCountList.value = list
}
-
InputDialogFrament
でaddStepCount
を呼んでいる箇所を変更する-
今は入力値を選択出来ないので、levelとweatherはいったんデフォルト値が渡るようにします。日付も後で選べるようにしますが、今今は今日の日付、にしておきます。
InputDialogFragment.ktval step = view.editStep.text.toString() val date = getDateStringYMD(Calendar.getInstance().time) viewModel.addStepCount(StepCountLog(date, step.toInt()))
getDateStringYMD
は下記のような関数です。 -
private fun getDateStringYMD(time:Date):String{
val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
return fmt.format(time)
}
-
LogRecyclerAdapter
のリストの型も、StepCountLogに変更
class LogRecyclerAdapter(private var list: List<StepCountLog>) : 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<StepCountLog>) {
list = newList
notifyDataSetChanged()
}
// 後は同じ
ここまでで、いったんビルド&実行してみましょう。ビルドが通らないときは、一度Clean&Rebuildしてみてください。どちらも、[Run]メニューにあります。
動いたけど、なんか変だって?
べ、別に、Adapterの表示を設定するところを直すのを忘れたわけじゃ無いんだからね!
わ、わざとに決まってるでしょ!!
「ホラ、レイアウト変更しなきゃね!」てやりたかったからだよ!!
まあ、原因は、お察しの通り、LogRecyclerAdapter
の表示データをセットする以下のところですね。
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
holder.textCount.text = if (position < list.size) list[position].toString() else ""
}
TextViewに、list[position].toString()
を渡してます。ここが、StepCountLog#toString()
の呼び出しになり、自分ではこの関数は作っていませんが、前述の通り、data class
ですので、自動的に生成されています。
data class
のtoString()
は、このようにメンバーの値をテキストで読みやすい形で出してくれるという代物なのです。
なぜデバッグで便利かというと、ブレークポイントを貼ってデバッガーで値を見るときに、自動的にこの形になっていると見やすいんですね。
例えば、こんな所にブレークポイントを貼っておき、アプリを実行して「登録」ボタンを押します。

ブレークポイントで止まりますね。その時、[Valiables]のstepCount
で[toString]-[View]とクリックすると、こんな風に値を確認することができます。

[Variables]のところで、stepLog
の左にある矢印をクリックして、要素を展開すれば中を見ることも出来ますが、ネストの深いデータ構造だと、矢印のクリック回数が増えて非常に面倒です。文字列で一瞥できた方が、便利なときもあるのです。
ということで、toString()
の自動生成のありがたみを痛感したところで(?)、レイアウトをちゃんと対応していきましょう(汗)
(2) レイアウトを変更する
変えなければならないレイアウトは以下です。
- リストの各行(アイテム)のレイアウト(item_step_log.xml)
- 入力時のレイアウト(dialog_input.xml)
InputDialogFragmentについては、今回、新しく入力項目が増えたので、今回から、ダイアログ表示をやめて、画面遷移にしようと思います。
また、RecyclerViewは、databindingというのを使って行くと便利なのでそちらを使って行きます。
1. アイテムレイアウトを変更する
まずは、RecyclerViewのアイテムのレイアウトを変えましょう。日付、レベル、天気が表示出来るようにします。
-
画像の準備
レベル、天気は、こんな感じで、vector画像を用意しました。ほとんどは、AndroidStudioのクリップセットから作成可能ですが、一部は、フリー素材を使っています(参考ページ参照)
サンプル画像はこんな感じです。
-
item_step_log.xml
のレイアウトを、日付、レベル画像、天気画像を表示するように変更する
私はこんなレイアウトにしました。
こちらはサンプルxmlです。
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activity.logitem.LogInputFragment">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/dateTextView"
tools:text="2019/06/11"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="8dp"/>
<TextView
android:text=""
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/stepTextView"
android:textSize="24sp"
android:textColor="#0B0A0A"
tools:text="12345"
app:layout_constraintStart_toEndOf="@+id/levelImageView"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/weatherImageView"/>
<TextView
android:text="@string/label_log_suffix"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@+id/stepTextView"
app:layout_constraintBottom_toBottomOf="parent"
android:id="@+id/suffixTextView"
android:layout_marginStart="8dp"
android:elevation="0dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="@+id/stepTextView"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:src="@drawable/ic_cloud_gley_24dp"
android:id="@+id/weatherImageView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@+id/dateTextView"
app:layout_constraintStart_toEndOf="@+id/dateTextView"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:src="@drawable/ic_sentiment_neutral_green_24dp"
android:id="@+id/levelImageView"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/dateTextView"/>
</androidx.constraintlayout.widget.ConstraintLayout>
-
LogRecyclerAdapter
を修正する
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
if (position >= list.size) return
val stepCountLog = list[position]
holder.textCount.text = stepCountLog.step.toString()
holder.textDate.text = stepCountLog.date
when (stepCountLog.level) {
LEVEL.GOOD -> holder.level.setImageResource(R.drawable.ic_sentiment_very_satisfied_pink_24dp)
LEVEL.BAD -> holder.level.setImageResource(R.drawable.ic_sentiment_dissatisfied_black_24dp)
else -> holder.level.setImageResource(R.drawable.ic_sentiment_neutral_green_24dp)
}
when (stepCountLog.weather) {
WEATHER.CLOUD -> holder.weather.setImageResource(R.drawable.ic_cloud_gley_24dp)
WEATHER.RAIN -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_umbrella_1)
WEATHER.HOT -> holder.weather.setImageResource(R.drawable.ic_flare_red_24dp)
WEATHER.COLD -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_weather_64)
WEATHER.SNOW -> holder.weather.setImageResource(R.drawable.ic_grain_gley_24dp)
else -> holder.weather.setImageResource(R.drawable.ic_wb_sunny_yellow_24dp)
}
}
class LogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val textCount = itemView.stepTextView!!
val textDate = itemView.dateTextView!!
val level = itemView.levelImageView!!
val weather = itemView.weatherImageView!!
}
LogViewHolder
クラスで保持するViewを増やし、LogRecyclerAdapter#onBindViewHolder
でデータクラスの値に対応したそれぞれの値を入れたり、アイコン画像を引っ張ってきたりしています。
しかし・・・、when
節でdrawableリソースのidを引っ張ってきているところが、なんか気になります。もっと綺麗に、短く書けないでしょうか?
enumクラスでなんとか返せないかな?というアプローチで考えると、実は、こんな書き方が出来ます。
enum class LEVEL(val drawableRes: Int) {
NORMAL(R.drawable.ic_sentiment_neutral_green_24dp),
GOOD(R.drawable.ic_sentiment_very_satisfied_pink_24dp),
BAD(R.drawable.ic_sentiment_dissatisfied_black_24dp),
}
enum class WEATHER(val drawableRes: Int) {
FINE(R.drawable.ic_wb_sunny_yellow_24dp),
RAIN(R.drawable.ic_iconmonstr_umbrella_1),
CLOUD(R.drawable.ic_cloud_gley_24dp),
SNOW(R.drawable.ic_grain_gley_24dp),
COLD(R.drawable.ic_iconmonstr_weather_64),
HOT(R.drawable.ic_flare_red_24dp)
}
どちらも、Int型のdrawableResという「付加情報」を付けて、初期化しています。
これを使うと、LogRecyclerAdapter#onBindViewHolder
はこんなにシンプルになります。
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
if (position >= list.size) return
val stepCountLog = list[position]
holder.textCount.text = stepCountLog.step.toString()
holder.textDate.text = stepCountLog.date
holder.level.setImageResource(stepCountLog.level.drawableRes)
holder.weather.setImageResource(stepCountLog.weather.drawableRes)
}
すっきりしましたね。
もっとも、ただの列挙型、特にこの列挙型は、MVVMのModel
部分に相当するStepCountLog
というdataクラスで使われるものですので、そのクラスの情報に、R.drawable
クラスという表示データに関わる情報を持たせることに、設計として「気持ち悪い」と感じる人もいらっしゃると思います。場合によってはそのせいで、UnitTestが書きづらくなったりすることもありますし。その辺りは、設計思想だったり、現場の文化だったりでも方針は変わってきますので、柔軟に対応していきましょう。
今回は勉強ということもあり、enumの付加情報について学べる良い機会ということで、取り上げてみました。
2. Data Binding
(1) data bindingとは
Androidには、Data Bindingという便利なライブラリがあります。何をしてくれるかというと、レイアウトのウィジェット(View)と、表示するデータを、xml上でバインドしておくと、ViewHolderクラスでやっていたような「値をビューにセットするだけ」のコードを、ごっそりコードから削除することが出来る、という代物です。
だいたい、ViewHolderに表示する物って、あるクラスの情報まるっとだったり、その一部だったり、要するに、まとめて渡せたら便利なことが多くない?それ実現できない?ってのをやってくれるのが、Data Bindingライブラリです。
公式ドキュメントはこちら。
CodeLabsを日本語訳してみた拙記事もありますので、よければそちらも見てみて下さい。
とにかく使って行きましょう。
必要な初期化は、android{}
ノードに、下記を追記するだけです。
android{
...
dataBinding {
enabled true
}
...
}
dependenciesに追加する記述はありません。
(2) アイテムレイアウトのdata binding
早速、アイテムレイアウトを、data bindingのものに変えましょう。
1. レイアウトファイル全体をdata binding向けにする
data binding向けのレイアウトファイルにするには、レイアウト全体を、<layout>
タグで囲む必要があります。
ルート要素のタグ内にカーソルを合わせ、マウスをホバリングさせると表示される黄色い(オレンジ?)電球アイコンをクリックすると、[Convert to data binding layout]とやると、楽です。

こんな感じになるはずです。
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
...
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
<data>
タグに、レイアウト変数というのを定義します。挿入したいデータを外部から指定する場合の、渡される型、クラスなどを名前付きで指定します。
今回は、StepCountLog
クラスを渡して使いたいので、こう書きます。
<data>
<variable name="stepLog"
type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
</data>
2. バインドするデータのセット
各ビューに、stepLog
のどの値を使うかを設定していきます。
-
dateTextViewのtext
には、stepLog.date
-
stepTextViewのtext
には、stepLog.step
(※ただしInt型なので文字列に変更して) -
weatherImageViewのsrc
には、stepLog.weather.drawableRes
-
levelImageViewのsrc
には、stepLog.level.drawableRes
レイアウト式というのを使います。レイアウト式は、@{}
で書きます。
各設定は次のようになります。
<TextView
android:id="@+id/dateTextView"
android:text="@{stepLog.date}"
<TextView
android:id="@+id/stepTextView"
android:text="@{Integer.toString(stepLog.step)}"
<ImageView
android:id="@+id/weatherImageView"
android:src="@{stepLog.weather.drawableRes}"
<ImageView
android:id="@+id/levelImageView"
android:src="@{stepLog.level.drawableRes}"
レイアウトxmlファイルへの設定はこれで終わりです。ここまでで、いったんBuildしておいてください。
3. LogRecyclerAdapter
を書き換える
- レイアウトのインフレートをdata bindingを使ったものに書き換える
-
LogRecyclerAdapter#onCreateViewHolder
で、レイアウトをinflateしている部分を、Data Binding用に変更する -
LogViewHolder
のコンストラクタの引数の型を、Bindingオブジェクトに変更する
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
val binding: ItemStepLogBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context), R.layout.item_step_log, parent, false
)
return LogViewHolder(binding)
}
class LogViewHolder(val binding: ItemStepLogBinding)
: RecyclerView.ViewHolder(binding.root)
ItemStepLogBinding
のimport候補が出ない場合は、xmlのgenerateに失敗しています。Buildをやり直してみて下さい。[generatedJava]に、ItemStepLogBinding
というのが出来ているはずです。出来ていない場合は、xmlファイルの記述で何か間違えているか、どこかのKotlinコードが変になっていますので、よく確認して下さい。
- 最後に、レイアウトで表示するのに必要な、バインドするデータを渡します。
-
LogRecyclerAdapter#onBindViewHolder
を書き換える
-
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
if (position >= list.size) return
holder.binding.stepLog = list[position]
}
stepLog
がどこから来たかというと、xmlのここです。
<data>
<variable name="stepLog"
type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
</data>
<variable>
タグのname
属性に指定した名前の変数が、Data Bindingライブラリによって生成され、binding.stepLog
のようにアクセスすることが出来るようになります。
だいぶコードがスッキリしましたね。実行してみて下さい。
(3) もっとdata binding(Binding Adapter)
CodeLabsを日本語訳してみたとき、便利な方法を学習したので紹介します。
BindingAdapterというのを自作する方法です。
値によって画像を変えるのには、こちらが適していそうだと感じました。enumクラスの付加情報を勉強のために使いましたが、こちらのほうが、モデルから表示情報を削除出来、data bindingも活用した良い方法なので、知っておくと役に立つと思います。
1. BindingAdapterを作成する
- BindingAdapters.ktというファイルを作り、以下の関数を作る
@BindingAdapter("android:src")
fun setImageLevel(view: ImageView, level: LEVEL) {
val res =
when (level) {
LEVEL.GOOD -> R.drawable.ic_sentiment_very_satisfied_pink_24dp
LEVEL.BAD -> R.drawable.ic_sentiment_dissatisfied_black_24dp
else -> R.drawable.ic_sentiment_neutral_green_24dp
}
view.setImageResource(res)
}
@BindingAdapter("android:src")
fun setImageWeather(view: ImageView, level: WEATHER) {
val res =
when (level) {
WEATHER.RAIN -> R.drawable.ic_iconmonstr_umbrella_1
WEATHER.CLOUD -> R.drawable.ic_cloud_gley_24dp
WEATHER.SNOW -> R.drawable.ic_grain_gley_24dp
WEATHER.COLD -> R.drawable.ic_iconmonstr_weather_64
WEATHER.HOT -> R.drawable.ic_flare_red_24dp
else -> R.drawable.ic_wb_sunny_yellow_24dp
}
view.setImageResource(res)
}
やってることはだいたいわかるかと思いますが、次のような感じです。
-
"android:src"
という属性に対し、特定の引数の型が指定されたら、その型に応じて、setImageLevel
かsetImageWeather
が呼び出される(@BindingAdapter
アノテーションにより、その辺は適切にコードが自動生成されます) - 中身は、以前
LogRecyclerAdapter#onBindViewHolder
でやっていたのと一緒で、enumクラスの値に応じて、ImageViewにセットすべきdrawableのリソースidを振り分けて、それをsetImageResource
にセット。when
節が値を返せるところが、Kotlin的なポイントでしょうか。if
もそうでしたね。
2. ImageViewのバインディングを変更する
レイアウトxmlのlevelImageView, weatherImageViewのバインドしている値の指定を変更します。
<ImageView
android:id="@+id/weatherImageView"
android:src="@{stepLog.weather}"
...
/>
<ImageView
android:id="@+id/levelImageView"
android:src="@{stepLog.level}"
...
3. enumクラスを元に戻す
enum class LEVEL {
NORMAL,
GOOD,
BAD,
}
enum class WEATHER {
FINE,
RAIN,
CLOUD,
SNOW,
COLD,
HOT,
}
BindingAdapter、便利そうなので覚えておくと、かなり開発効率がよくなりそうです。
(4) メインレイアウト(LiveData)のdata binding
さて、CodeLabをちらっと見て頂けた方なら、LiveData
をdata bindingする方法があることに気付いたと思います。
それを、RecyclerViewでもやってみようと思います。
1. RecyclerViewにもdata bindingを適用する
-
activity_main.xml
をdata binding用レイアウトに変更する-
<data>
タグに指定するのは、name=viewmodel
、type=(フルパッケージ名).MainViewModel
-
-
app:items
という属性でリストList<StepCountLog>
を受け取る、BindingAdapter用の関数を作る -
MainActivity
でdata bindingの設定をする- レイアウト設定方法を
DataBindingUtil#setContentView
にする -
binding
オブジェクトのライフサイクルオーナーに自分を設定する -
binding
オブジェクトのレイアウト変数viewmodel
に、MainViewModel
をセットする
- レイアウト設定方法を
全部やるとこんな感じになります。
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable name="viewmodel"
type="jp.les.kasa.sample.mykotlinapp.MainViewModel"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
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"
app:items="@{viewmodel.stepCountList}"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
@BindingAdapter("app:items")
fun setLogItems(view: RecyclerView, logs: List<StepCountLog>?) {
val adapter = view.adapter as LogRecyclerAdapter? ?: return
logs?.let {
adapter.setList(logs)
}
}
nullチェックがしつこいですが、タイミングによってはnullがありうるので、きちんとチェックしておきます。
2. MainActivityでBindingオブジェクトにライフサイクルオーナーとViewModelをセットする
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding
= DataBindingUtil.setContentView(this, R.layout.activity_main)
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
binding.lifecycleOwner = this
binding.viewmodel = viewModel
// RecyclerViewの初期化
log_list.layoutManager = LinearLayoutManager(this)
adapter = LogRecyclerAdapter(viewModel.stepCountList.value!!)
log_list.adapter = adapter
// 区切り線を追加
val decor = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
log_list.addItemDecoration(decor)
InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
}
-
val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
は、DataBindingでレイアウトを指定して、bindingオブジェクトを取得する定型的なコードです -
binding.lifecycleOwner = this
で、ライフサイクルオーナーを指定しています -
binding.viewmodel = viewModel
で、レイアウトxmlの<data>
タグのname
属性に指定した名前のレイアウト変数viewmodel
に、作成したViewModelのインスタンスを渡しています
ViewModel
をobserveしているコードが無くなりました。Data Bindingの方でよしなにobserveしてくれているのですね。
実行すると、ちゃんと追加が反映されているかと思います。
3. ダイアログをやめて画面遷移にする(Activity遷移)
さて、お気づきでしょうが、せっかくログクラスにデータが増えているのに(天気、レベル)、それを入力する箇所がありません。
ということで、入力画面を変えていきます。
ダイアログでそのままやっても良いのですが、今後いろいろ肉付けしていく際に必要そうなので、別のActivityを作ってやっていこうと思います。
今回は、Fragmentの作成もやってみましょう。
おさらいですが、Fragmentとは、Activity(1画面)の上に何枚でも(多分上限はありそうですが)重ねられる、スクリーン(あるいはレイヤーと言った方が分かりやすいかな?)みたいなものです。
(1) 新しいActivityクラスを作成する
1. 新しいパッケージを作る
ついでなので、パッケージを増やしましょう。
[app]-[java]下にある、パッケージ名のトップで右クリックして、[New]-[Package]とし、
activity.logitem
と入力します。

パッケージングにはいろいろ流儀があると思いますので、現場やチームでのルールに従いましょう。
私は最近は画面ごとに分けています。今回も、Activityが増えるごとに、activityパッケージ下に増やしていくイメージで作りました。
2. LogItemActivityを作成する
Activiyを新規作成します。
- パッケージ[activity.logitem]で右クリックし、[New]-[Activity]-**[Basic Activity]**と選択

- ActivityNameに
LogItemActivity
と入力し、あとは図の通りにして[Finish]

LogItemActivity.kt
ファイルと、activity_log_item.xml
、そしてcontent_log_item.xml
が作成され、ファイルが開きます。また、[manifests]下にあるAndroidManifest.xml
を開くと、<activity>
タグが追加されているのが分かります。
それと、app/build.gradle
にもdependenciesが1行追加されています。
implementation 'com.google.android.material:material:1.0.0-beta01'
これは、AppBarLayoutというのに必要なので自動で追加されました。気になる人は、最新バージョンにしておきましょう。(2019/06/19現在、"1.1.0-alpha07"が最新版みたいです)
activity_log_item.xml
MainActivityにもActionBarは出ています(上部の緑色?の部分。この色はランダムに決まる可能性があるので、違うかも)が、このクラスは実は、もうあまり推奨されていません。多分、標準の(MainActivityについている)ActionBarは、マテリアルデザインに対応していないとかで、対応しているSupportActionBar
を使えということだったと思います。それを使うためのレイアウトは、基本的にはこのような構成になります。
CoordinatorLayout
の中に、AppBarLayout
があり、更にその中に、Toolbar
がある、という階層構造になっています。
Acvitiyのメインコンテンツは、CoordinatorLayout
の中、AppBarLayout
の下に定義していきますが、
<include layout="@layout/content_log_item"/>
このように、別のレイアウトで定義した物をincludeする形が圧倒的に多いです。もちろん、直接その部分にレイアウトを書いていっても良いのですが、レイアウトファイルがゴチャゴチャするのを避ける為にも、なるべくこの形にした方が良いのではないかと思っています。
include
を使うと、細々としたレイアウトセットを別ファイルに作っておき、組み合わせて使えるようにもなったりするので、レイアウトファイルの再利用性が高まります。ただもちろん、これはデメリットもあって、1つの画面だけレイアウトを変えたくても、他の画面も影響を受けてしまう、ということもあるので、あまり分割/再利用しすぎるのも問題になりますので、要注意です。
3. レイアウトファイルを修正
FloatingActionButton
は不要なので、ごっそり削除して下さい。
<!-- <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
app:srcCompat="@android:drawable/ic_dialog_email"/>
-->
content_log_item.xml
ルート要素に、app:layout_behavior="@string/appbar_scrolling_view_behavior"
という属性が定義されています。これが無いと、ActionBarの下にViewが潜り込んでしまうので、必ず指定をして下さい。
そして、FrameLayout
をConstraintLayout
内に1つ作り、idをlogitem_container
とします。
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/logitem_container"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
</FrameLayout>
Activityのレイアウトはここまでで完了です。値を入力する項目を作ってないって?それはFragmentのレイアウトでやるのです。
Activityに必要なのは、ActionBar
に関わるレイアウトと、Fragmentをはめ込む「枠」としてのLayoutが1つあれば、ほとんどの場合、充分です。
LogItemActivity.kt
いまはonCreate
があるだけですね。
ひとまず、fabボタンのコードは不要なので削除します。
LogItemActivity.kt
のコードはこうなります。
class LogItemActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_log_item)
setSupportActionBar(toolbar)
}
}
setSupportActionBar(toolbar)
が新しいですかね。
前述の通り、SupportActionBarに、toolbar
とidを付けたToolbarオブジェクトを指定しています。
これにより、supportActionBar
を介して、タイトルを表示したりアイコンを表示したりすることが出来るようになります。
4. Fragmentを追加するコードを実装
LogItemActivity#onCreate
に下記のコードを追記します。
if(savedInstanceState==null){
supportFragmentManager.beginTransaction()
.replace(R.id.logitem_container, LogInputFragment.newInstance())
.commitNow()
}
このコードでやっているのは、supportFragmentManager
というActivityのFragmentを管理するマネージャーに(その名の通りですね)、LogInputFragment
のインスタンスを作って、R.id.logitem_container
というidのViewとreplace
する、という内容です。
以前、DialogFragment
を表示するときは、DialogFragment#show
を使いましたが、Fragmentを表示するには、本来、supportFragmentManager
に対して設定を行っていきます。まずbeginTransaction()
してから、replace/add
等をして、最後にcommitNow()
かcommit()
をする、としなければなりません。DialogFragment#show
は、内部でその流れをやってくれているということになります。
なお、LogInputFragment
クラスはまだ作っていないので、現時点ではコンパイルエラーになります。
(2) Fragmentを作成する
次はFragmentを作っていきます。
1. Fragmentを新規作成する
- パッケージ[activity.logitem]を選んで、右クリックメニューから、[New]-[Fragment]-[Fragment (Blank)]を選択

- Fragment Nameに、
InputLogFragment
とし、あとは図の通りの設定にして、[Finish]

-
不要なコードは削除しておく
下記コードを削除
// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"
InputFragment.kt
はいまはこれだけの状態のはずです。
class LogInputFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_log_input, container, false)
}
}
androidx.fragment.app.Fragment
を継承しています。これは必ずこのクラスにします。
onCreateView
は、LayoutInflater
とcontainer
(nullable)と、savedInstanceState
(nullable)を受け取ります。
LayoutInflater
はこれまでにも出てきたのでお分かりと思いますが、レイアウトxmlファイルを実際にViewオブジェクトにインスタンス化している、と思えば良いと思います。
container
は、親のViewGroupです。例えば、今回は、activity_log_item.xml
のR.id.logitem_container
というidを指定してFragmentをセットしているので、ここに渡ってくるのは、FrameLayout
の実体ということになります。nullableになってるけど、nullが来ることは無い気がするんですがね・・・
さて、Bundle?
なsavedInstanceState
は、Activityのコードでも出てきました。onCreate
に追加したコードですね。
if(savedInstanceState==null){
supportFragmentManager.beginTransaction()
.replace(R.id.logitem_container, LogInputFragment.newInstance())
.commitNow()
}
Activityのライフサイクルを覚えているでしょうか?Activityが裏に回ったとき(onPaused
以降)、Activityクラスのオブジェクトは、実はメモリから解放されてしまうことがあります。AndroidOSが、メモリが足りなくなると、バックグラウンドにあるオブジェクトのメモリを解放して賄おうとするからです。
その状態で、アプリをもう一度起動しようとすると、AndroidOSはなるべく前回の状態を復元しようとしてくれます。そんなとき、savedInstanceState
に、保存してあったデータが渡ってきます。
ということで、savedInstanceState==null
という条件は、実は、復元すべきデータが無いとき、つまり**「最初にActivityが作成されたとき」**を意味しています。
ちなみに、復元が必要なデータは、自動で保存してくれるわけでは無く、Activity#onSaveInstanceState
で自分で保存するコードを書いた物だけが渡ってきます。
このデータには容量制限があるので、やたらめったらデカいデータは、別の保存/復元方法を考えた方が良いでしょうね。
FragmentにもonSaveInstanceState
があります。Activity同様、バックグラウンドなどから復帰したときに復元されて欲しいデータをセットしておくと、onCreateView
のsavedInstanceState
はnullではなく、セットしておいたデータのセットが入ってきます。
バックグラウンドに行ったが、メモリが解放されることが無かった場合は、ActivityやFragmentの再作成は行われません(=onCreate
等は呼ばれない)。その場合、画面が復帰したときはonResume
等から呼ばれます。
onSaveInstanceState
が呼ばれるもう1つ重要なタイミングとしては、「画面回転」が挙げられます。端末を縦にしたり横にしたりしたときですね。この場合は、画面の再作成は、必ず行われます。(行わせないようにする設定は一応ありますが、このアプリでは使いません)
面倒なので、スマホ向けのアプリだと、「縦画面固定」や、ゲーム等は「横画面固定」にしているものがほとんどだと思いますが、今回は、ViewModelにせっかく対応しているので、固定にはしないで実装を進めたいと思います。
ただし、縦画面向け、横画面向けに、レイアウトの変更が必要になる場合があるので、その際には、代替リソースを使って、レイアウトxmlファイルを分けて対応していきましょう。
(今回も最終的に横画面向きのレイアウトを作ってあります。Githubにpushしたプロジェクトで、res/layout-land
を参照して下さい。)
2. LogInputFragmentをインスタンス化するメソッドを作成
Fragmentのクラスが出来たので、それをインスタンス化するメソッドを作ります。
LogItemActivity
で、LogInputFragment.newInstance()
と呼び出していたのを覚えていますか?このメソッドを作りましょう。
companion object {
fun newInstance(): LogInputFragment {
val f = LogInputFragment()
return f
}
}
※inlineに出来るよ、という警告は無視して良いです。
companion object{}
の説明はしたっけ?した気もするな・・・(汗)
Javaでいうクラスの静的メンバ、メソッドは、companion object{}
の中に定義します。
今やっているのは、単にLogInputFragment
をnewしているだけですが、後々、ここに初期値などのデータを渡すこともあるかも知れない・・・ということでこの形で作っておく方が便利です。
さて、これでいったんビルドは通るようになりました。
実行・・・しても、変化はありません。当然です、LogItemActivity
に遷移するコードを書いていません!
ということで、次はActvitiyからActivityへの遷移を実装します。
(3) MainActivityからの遷移を実装する
1. InputDialogFragmentは不要なので、削除する
-
クラスファイル、レイアウトファイル、MainActivityでshowしているコード、すべて削除する
MainActivity.kt// このコードは削除 // InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
2. 追加メニューが押されたときの処理を、Activity遷移に変更する
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
item?.let {
return when (it.itemId) {
R.id.add_record -> {
val intent = Intent(this, LogItemActivity::class.java)
startActivityForResult(intent, REQUEST_CODE_LOGITEM)
true
}
else -> false
}
}
return false
}
Activityを起動するときには、Intent
を使い、startActivity
やstartActivityForResult
というメソッドを呼びます。Intent
オブジェクトの作り方はいくつか引数のパターンがありますが、よく必要なのは、「自分のアプリパッケージ内の特定のActivityクラスを開く」なので、上記のパターンを非常に多く使うことになります。引数の1つめはPackageContext
、2つめは「リクエストコード」と言われるものです。startActivityForResult
メソッドを通して起動したActivityが終了して自分に戻ってくると、そのActivityからの戻り値やデータを受け取ることが出来ます。startActivity
は特にデータのやりとりが不要な場合に使います。今回は、後で値を受け取るので、startActivityForResult
の方を使います。
REQUEST_CODE_LOGITEM
は次のようにしました。
companion object {
const val REQUEST_CODE_LOGITEM = 100
}
そういえば、INPUT_TAG
は不要ですのでこれも削除しておきます。
実行してみて下さい。"+"ボタンタップで、新しい画面に遷移しましたか?
端末の戻るボタンを押すと戻りましたか?
これで行き来できるようになりましたね。
3. ActionBar(Toolbar)に戻るボタンを表示して処理をする
遷移した画面からは、左上に戻るボタンを表示して、そこをタップしても戻れるようにしましょう。
-
戻るボタン表示にする
下記コードを
LogItemActivity#onCreate
内の、setSupportActionBar(toolbar)
の後に追記します。
supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
戻るボタンがタップされたときの処理を実装する
下記メソッドを
LogItemActivity.kt
に追加します。
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
面白いことに(?)、左上の「←」ボタンは、オプションの一部なんですね。そしてメニューアイテムIDは、android.R.id.home
が付いているようです。
onBackPressed()
メソッドは、Activityクラスが元々持っているメソッドで、基本的には自分をfinish()
するだけです。別の動作をさせたいときは、このメソッドをオーバーライドすると楽です。(少し古いサンプルだと、キーイベントでbackキー
を拾って奪うようなコードを見かけますが、そんな必要はもう無いです。)
これで実行すると、ActionBarの左側に←アイコンが表示され、タップすると戻るようになりました。
愚痴ここから===
ところで、よく、アプリを終了しようとして戻るボタン押すと、わざわざ「終了しますか?」って聞いていくるアプリがありますが、私、アレ嫌いなんですよね・・・もっと酷いのだと、戻るボタンの動作を無効にしていて、戻るボタンではアプリを終了できないのさえ、あります。そういうアプリは直ぐアンインストールしちゃいます。が、その仕様を企画から要求されたときには・・・ジレンマをご想像下さい^^; Googleさんも、「ユーザーの意図を妨げる、無効にするような処理」はやっちゃだめと言っているのにねえ・・・
==愚痴ここまで
4. 入力画面のレイアウトを作る
fragment_log_input.xml
を編集していきます。
とりあえず、既にあるTextViewは不要なので、削除します。
こんな見た目はどうでしょうか?(デザインセンスが無いのは目を瞑って下さい汗)

階層はこんな感じになります。

xmlは参考までにこちら。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activity.logitem.LogInputFragment">
<TextView
android:text="@string/label_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/label_date"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/text_date"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/label_date"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="@+id/button_date"
android:textSize="18sp"
tools:text="2999/99/99"/>
<Button
android:text="@string/label_select_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/button_date"
app:layout_constraintStart_toEndOf="@+id/text_date"
android:layout_marginStart="8dp"
app:layout_constraintTop_toBottomOf="@+id/label_date"/>
<TextView
android:text="@string/label_step_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/label_step_count"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/button_date"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"/>
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:inputType="numberSigned"
android:ems="10"
android:id="@+id/edit_count"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/label_step_count"
android:hint="@string/hint_edit_step"
android:singleLine="true"
android:textAlignment="textEnd"
android:importantForAutofill="no" tools:targetApi="o"/>
<TextView
android:text="@string/label_level"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/label_level"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/edit_count"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"/>
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="8dp"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/label_level"
android:id="@+id/radio_group"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="32dp">
<RadioButton
android:text="@string/level_normal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/radio_normal"/>
<ImageView
android:src="@drawable/ic_sentiment_neutral_green_24dp"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/imageView"/>
<RadioButton
android:text="@string/level_good"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/radio_good"
android:layout_marginLeft="8dp"/>
<ImageView
android:src="@drawable/ic_sentiment_very_satisfied_pink_24dp"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/imageView2"/>
<RadioButton
android:text="@string/level_bad"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/radio_bad"
android:layout_marginLeft="8dp"/>
<ImageView
android:src="@drawable/ic_sentiment_dissatisfied_black_24dp"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/imageView3"/>
</RadioGroup>
<TextView
android:text="@string/label_weather"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/label_weather"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/radio_group"/>
<Spinner
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="180dp"
android:id="@+id/spinner_weather"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/label_weather"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:entries="@array/array_weathers"/>
<Button
android:text="@string/resist"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:id="@+id/button_resist"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"
app:layout_constraintTop_toBottomOf="@+id/spinner_weather"
android:layout_marginTop="24dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
RadioGroup
とRadioButton
、Spinner
が新しいですかね。
RadioGroup
とRadioButton
はその名の通り、ラジオボタンを管理するグループと、ラジオボタンのアイテムです。今回は、android:orientation="horizontal"
を指定してるので、横に並びますが、デフォルトは縦に並びます。
また、RadioGroup
は恐らくLinearLayout
の派生なので、RadioButton
でなくても他の子Viewを並べられます。
ただ、通常、RadioButton
を配置していくと、android:layout_weight="1"
という属性がデフォルトで入ります。
これが本来は都合が良いのですが(並べた要素の数に応じて均等な幅にしてくれる)、今回はアイコン画像を差し込みたかったのもあり、その属性を消去しています。動作が気になる方は、入れてみてどうなるか見てみて下さい。
Spinner
は、いわゆるドロップダウンボックスです。固定の要素をポップアップでリスト表示して選べるあれです。
要素は、今回は完全に固定なので、android:entries="@array/array_weathers"
と属性で指定しています。要素が変動する場合は、プログラム上から設定することになります。
array/array_weathers
が新しいリソースの定義の仕方になりますかね。これは、arrays.xml
というファイルをres/values
下に作って以下のよう記述してあります。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="array_weathers">
<item>晴れ</item>
<item>雨</item>
<item>曇り</item>
<item>雪</item>
<item>寒い</item>
<item>暑い</item>
</string-array>
</resources>
このarraysは、後でenumクラス(WEATHER)と付き合わせることになるので、enumクラスでの定義順と同じ並びになっている必要があります。
(4) 入力データの受け渡し
入力画面が出来たので、登録ボタンが押されたときに、その値を収集して、MainActivityに返すようにしてみましょう。
データの受け渡しには、ViewModel(LiveData)を使って行きます。data bindingは今回ちょっと使いませんが、使えないことはないはずなので興味ある方はチャレンジしてみて下さい。
1. ViewModelを作成する
クラス名は、LogItemViewModelとでもしましょうか。パッケージは、activity.logitem
としました。
import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog
class LogItemViewModel : ViewModel() {
private val _stepCountLog = MutableLiveData<StepCountLog>()
val stepCountLog = _stepCountLog as LiveData<StepCountLog>
@UiThread
fun changeLog(data :StepCountLog){
_stepCountLog.value = data
}
}
さて、今回はちょっとLiveDataの取り扱いに小技を入れてみました。stepCountLog
をそのままMutableLiveData
としても良いのですが、それではどこで誰が変更しようとするか分かりづらくなります。changeLog
という関数を介してしか、変更できないようにしておくと、[Find Usage]とか、[Grep]検索とかで、非常に探し出しやすくなります。・・・というのはかなり強引に考えた理由なのですが、Googleさんがこうしなさいと言っているみたいです。
ということで、Mutableな_stepCountLog
をprivate変数とし、誰でも参照できるpublicなstepCountLog
は、読み取り専用としました。
changeLog
は新しいStepCountLog
型のオブジェクトを受け取り、それをそのままLiveDataの値として発行します。setValue
を直接使っていて、postValue
としていないため、この関数は、必ずUIメソッドから呼ばれる必要があります。そのため、@UiThread
アノテーションを付けています。
2. LogItemViewModelを使うようにする
LogItemActivity
と、LogInputFragment
で、LogItemViewModel
を使うようにします。
lateinit var viewModel: LogItemViewModel
override fun onCreate(savedInstanceState: Bundle?) {
...
viewModel = ViewModelProviders.of(this).get(LogItemViewModel::class.java)
}
lateinit var viewModel: LogItemViewModel
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
}
ほとんど同じですが、Fragmentの方は、少し初期化のタイミングが違います。onCreateView
でも良さそうな気がするけど、AndroidStudioが[Fragment + LiveData]でFragmentを新規作成したときに生成するコードが、ここで初期化していたのでそれを踏襲することにします。
やってることはもう分かりますね。ActivityとViewModelを共有したいので、ViewModelProviders.of
にはactivityを渡しています。(むしろ共有しないパターンがこのアプリでは出てこない気がしてきた)
3. データをMainActivityに戻す処理を実装する
- ラジオボタンやスピナーの選択状態から、enum値を取り出す関数を作る
LogInputFragment
に、下記のようなprivate関数を作ります。
private fun levelFromRadioId(checkedRadioButtonId: Int): LEVEL {
return when (checkedRadioButtonId) {
R.id.radio_good -> LEVEL.GOOD
R.id.radio_bad -> LEVEL.BAD
else -> LEVEL.NORMAL
}
}
private fun weatherFromSpinner(selectedItemPosition: Int): WEATHER {
return WEATHER.values()[selectedItemPosition]
}
weatherFromSpinner
が少し見慣れないコードでしょうか。といっても、Javaのenumと同様、values()
で配列で取得できるので、それを利用して、「選択したスピナーの位置」をindexとして配列から値を取ってきて返していると言うだけのコードです。
ここで、配列の順番と、スピナーの各アイテムの位置が一致してないとこの手法は使えないので、arraysリソース定義のところで、「一致させる必要がある」と書きました。
当然、要素が途中に増えたりすると面倒なこと(挿入位置の間違いや片方の挿入漏れの発生等)になるので、頻繁に改修が入るようなときには要注意ですね。
- 登録ボタンにクリックリスナーを登録する
onCreateView
で、登録ボタンに対してクリックリスナーを登録します。
onCreateView
の全体はこのようになります。
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)
contentView.radio_group.check(R.id.radio_normal)
contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)
contentView.button_resist.setOnClickListener {
val dateText = text_date.text.toString()
val stepCount = edit_count.text.toString().toInt()
val level = levelFromRadioId(radio_group.checkedRadioButtonId)
val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
val stepCountLog = StepCountLog(dateText, stepCount, level, weather)
viewModel.changeLog(stepCountLog)
}
return contentView
}
ビューの子要素にアクセスしたいので、ルートのViewをcontentView
変数に入れ、最終的にそれをreturnしています。
contentView.radio_group.check(R.id.radio_normal)
は、初期値のラジオボタンを選択状態にしています。未選択だと都合が悪いので。
contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)
は、日付を今日の日付で初期化しています。
日付を選ぶ実装は少し後に入れます。getDateStringYMD
関数は、以前InputDialogFrament
にあったものを、Util.kt
ファイルに移して、Calendarクラス
の拡張関数にしてみました。
fun Calendar.getDateStringYMD():String{
val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
return fmt.format(this.time)
}
fun クラス名.新しい関数宣言
で、あるクラスに対して、追加の関数を定義することが出来ます。これが拡張関数です。
元のクラスを変更したり、わざわざ派生クラスを作る必要も無く、新しい関数を作れるので、とても便利です。
これにより、あたかも最初からCalendarクラスにgetDateStringYMD
というメソッドが用意されていたかのように、利用できるわけです。
ちゃんとthis
でそのオブジェクトのメンバーやメソッドにアクセスできます。thisは省略可能ですが、敢えて分かりやすく書いておきました。
あたかもクラスのメソッドのように使えると書きましたが、private
やprotect
なメンバーにはアクセスできないようです。
contentView.button_resist.setOnClickListener {...}
が、登録ボタンにクリックリスナーを登録するコードです。ラムダになっています。やっていることは単純なので分かりますね。各Viewから値を取得し、レベルと天気については、先ほど作った関数からenum値に変換し、最終的にStepCountLog
の新しいインスタンスを作って、ViewModelに変更を掛けています。
(data bindingにしたらここもかなりスッキリしそうですね)
尚、contentView.text_date
としている箇所と、ラムダの中ではtext_date
と直接アクセスしていたりする違いについてですが、onCreateView
を抜けるまでは、直接アクセスするtext_date
のほうは、まだnull
なんですね。だってonCreateView
が返したViewオブジェクトを使って初めてFragmentが作られますから。この時点ではまだFragmentにViewはセットされていないんです。
なのでonCreateView
内では、ルートのViewからアクセスしています。
import文をよく見ると、以下の2つがあることが分かるとかと思いますが、
import kotlinx.android.synthetic.main.fragment_log_input.*
import kotlinx.android.synthetic.main.fragment_log_input.view.*
下にあるのがcontentView.text_date
とアクセスするためのパッケージで、上にあるのが、直接アクセスするためのパッケージです。どちらも自動生成されたものです。
4. Activityでデータ変更を検知して呼び出し元に戻す
LogItemActivity
で、LogItemViewModel.stepCountLog
を監視しておき、変更がかかったら呼び出し元のActivityに値を返し、自分は終了するようにします。
- LiveDataを監視するコード
viewModel.stepCountLog.observe(this, Observer {
val dataIntent = Intent()
dataIntent.putExtra("data", it)
setResult(RESULT_OK, dataIntent)
finish()
})
Activityが呼び出し元にデータを返す常套手段は、setResult
にリザルトコードを指定したり、付加情報を付けたIntentを返す方法です。
dataIntent.putExtra("data", it)
で、dataIntent
のExtraデータのキー名"data"として、it
、すなわちStepLogCount
のオブジェクトを入れています。
その後、setResult(RESULT_OK, dataIntent)
で、RESULT_OK
というリザルトコードと共に、dataIntent
を結果として設定後、自分自身をfinish()
しています。
これで、呼び出し元のActivityが、リザルトコードとdatIntentを受け取ることが出来ます。
・・・dataIntent.putExtra("data", it)
でエラーになっているって?
今からそこを直します。
その前に、"data"のべた書きが気になるので、companion objectに定数で定義しておきます。
class LogItemActivity : AppCompatActivity() {
companion object {
const val EXTRA_KEY_DATA = "data"
}
}
dataIntent.putExtra(EXTRA_KEY_DATA, it)
5. StepLogCountをSerializableにする
Intent#putExtra
は色んな型を受け取れるようオーバーロードされた関数がたくさんあります。
頑張ってStepLogCountの要素1つずつ、putExtra(String)とかでチマチマやっても良いですが、ちゃんとクラスで対応しておく方が後々楽です。
クラスごとIntentのExtraに設定できるようにするには、2つのアプローチがあります。
- Serializableにする
- Parcelableにする
Serializableは、Javaにもあるデータの直列化
ですね。
Parcelableは、フルパッケージはandroid.os.Parcelable
となっていて、Androidならではの形式になります。
今回は、Serializableにします。というのも、その方が圧倒的に簡単だし(Parcelableにするとコーディング量が増える)、Parcelableのメリットは「アプリ同士で外部連携が可能」という部分くらいかなと思っているので、今回はSerializableで充分だと思います。
クラスをSerializableにするには、そのメンバーもすべてSerializableである必要があるので、Bitmap何かを渡したいときには注意が必要です。今回は、String, Int, そしてEnumが対象ですが、EnumクラスはSerializableに対応しているので、そこも問題ありません。
早速Serializableにしましょう。
Serializable
をimplementするだけですね。
data class StepCountLog(
val date: String,
val step: Int,
val level: LEVEL = LEVEL.NORMAL,
val weather: WEATHER = WEATHER.FINE
) : Serializable
これで入力画面からデータを戻すところは出来ました。
6. 戻されたデータをMainActivityで処理する
入力画面からの結果を受け取って処理するコードを書いていきます。
- Activityの終了結果を受け取る
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_CODE_LOGITEM -> {
onNewStepCountLog(resultCode, data)
return
}
}
super.onActivityResult(requestCode, resultCode, data)
}
自分がstartActivityForResult
で起動したActivityが終了したことは、onActivityResult
で受け取ります。
上記のコードは、requestCode==REQUEST_CODE_LOGITEM
だったとき、onNewStepCountLog
メソッドを呼び出しています。
when{}
節がネストするのは嫌いなので、private関数に分けました。
onNewStepCountLog
は次のようになります。
private fun onNewStepCountLog(resultCode: Int, data: Intent?) {
when (resultCode) {
RESULT_OK -> {
val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
viewModel.addStepCount(log)
}
}
}
resultCode==RESULT_OK
だったときのみ、data
から"data"キーのExtraデータをSerializableで取り出し、それをStepCountLog
にキャストしています。?
を付けていないので、この時変数log
はnon-nullです。万が一nullになる場合(data==null
か、Extraデータに該当キーのデータが無い、nullがセットされているなど)は、キャストに失敗するため、例外でクラッシュします。しかしnullは実装上あり得てはいけないので、ここでは!!
で強行しています。
が、もし万全を期すなら、
val log = data?.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog?
log?.let{
viewModel.addStepCount(log)
}
これくらいやれば、万全でしょう。
後は、これまで見てきたように、その値をViewModelに渡してリストに追加すれば、完了です。
実行してみましょう。
天気、レベルのアイコンが、ちゃんと入力画面で選んだ内容に変わっているはずです。
(5) 日付を選択出来るようにする
最後に、「日付を選ぶ」ボタンを押したときに、日付が選べるようにします。
CalendarViewというそのままズバリの物があるので、使いましょう。
1. DateSelectDialogFragmentクラスを作って表示する
InputDialogFragmentを作ったように、DateSelectDialogFragment
クラスを、AlertDialog
で表示するように作ります。
レイアウトは特に不要で、CalendarViewオブジェクトをそのままsetView
します。
また、CalendarView
には、「今日」を初期値で選択状態にします。
CalendarView#setDate
で初期値が設定できます。
button_date
が押されたときに、DateSelectDialogFragment
を表示します。
2. 選択した日付をセットするLiveDataを定義
LogItemViewModel
に、選んだ日付を受け渡せるLiveDataを定義し、DateSelectDialogFragment
のポジティブボタンが押されたときに変更するようにします。
CalendarView
での日付変更イベントリスナーは、CalendarView#setOnDateChangeListener
で設定します。
3. そのLiveDataをobserveし、変更をTextViewに反映する
そして、LogInputFragment
でそのLiveDataをobserveし、変更時にtext_date
に反映するようにします。せっかく作ったCalendarクラスの拡張関数、getDateStringYMD()
を使いましょう。
ここまでの復習です。まずは上記をヒントに作ってみましょう。
サンプルはこちら。
import android.app.Dialog
import android.os.Bundle
import android.widget.CalendarView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProviders
import java.util.*
/**
* 日付選択ダイアログ
**/
class DateSelectDialogFragment : DialogFragment() {
// CalendarViewで選択している日付の保存
private val selectDate = Calendar.getInstance()
// CalendarView
lateinit var calendarView: CalendarView
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
// AlertDialogで作成する
val builder = AlertDialog.Builder(requireContext())
// CalendarViewのインスタンス生成
calendarView = CalendarView(requireContext())
// 初期値(今日)をセット
calendarView.date = selectDate.timeInMillis
// 選択している日付が変わったときのイベントリスナー
calendarView.setOnDateChangeListener { _, year, month, dayOfMonth ->
selectDate.set(year, month, dayOfMonth)
}
// AlertDialogのセットアップ
builder.setView(calendarView)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
// ポジティブボタンでVieModelに最後に選択した日付をセット
viewModel.dateSelected(selectDate)
}
return builder.create()
}
}
class LogItemViewModel : ViewModel() {
// StepCountLogデータ(Activityに戻す用)
private val _stepCountLog = MutableLiveData<StepCountLog>()
val stepCountLog = _stepCountLog as LiveData<StepCountLog>
// 選択日付
private val _selectDate = MutableLiveData<Calendar>()
val selectDate = _selectDate as LiveData<Calendar>
/**
* StepCountLogデータのセット(すべてのデータの登録完了)
*/
@UiThread
fun changeLog(data: StepCountLog) {
_stepCountLog.value = data
}
/**
* 選択した日付のセット
*/
@UiThread
fun dateSelected(selectedDate: Calendar) {
_selectDate.value = selectedDate
}
}
class LogInputFragment : Fragment() {
companion object {
const val DATE_SELECT_TAG = "date_select"
fun newInstance(): LogInputFragment {
val f = LogInputFragment()
return f
}
}
lateinit var viewModel: LogItemViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)
contentView.radio_group.check(R.id.radio_normal)
contentView.text_date.text = Calendar.getInstance().getDateStringYMD()
contentView.button_resist.setOnClickListener {
val dateText = text_date.text.toString()
val stepCount = edit_count.text.toString().toInt()
val level = levelFromRadioId(radio_group.checkedRadioButtonId)
val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
val stepCountLog = StepCountLog(dateText, stepCount, level, weather)
viewModel.changeLog(stepCountLog)
}
// 日付を選ぶボタンで日付選択ダイアログを表示
contentView.button_date.setOnClickListener {
val fgm = fragmentManager ?: return@setOnClickListener // nullチェック
DateSelectDialogFragment().show(fgm, DATE_SELECT_TAG)
}
return contentView
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
// 日付の選択を監視
viewModel.selectDate.observe(this, Observer {
text_date.text = it.getDateStringYMD()
})
}
...
ビルド、実行してみてください。
4. 入力値の検証を行う
日付を変更して、登録ボタンを押すと、・・・あれ?クラッシュすることがある?
そんなときは、Logcatの出番です。どこで落ちているか、分かりましたか?
2019-06-20 03:24:11.471 29889-29889/jp.les.kasa.sample.mykotlinapp E/AndroidRuntime: FATAL EXCEPTION: main
Process: jp.les.kasa.sample.mykotlinapp, PID: 29889
java.lang.NumberFormatException: For input string: ""
at java.lang.Integer.parseInt(Integer.java:533)
at java.lang.Integer.parseInt(Integer.java:556)
at jp.les.kasa.sample.mykotlinapp.activity.logitem.LogInputFragment$onCreateView$1.onClick(LogInputFragment.kt:46)
at android.view.View.performClick(View.java:5657)
at android.view.View$PerformClick.run(View.java:22314)
at android.os.Handler.handleCallback(Handler.java:751)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:241)
at android.app.ActivityThread.main(ActivityThread.java:6223)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:865)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:755)
--------- beginning of system
こんなスタックトレースが吐かれているはずです。
例外はjava.lang.NumberFormatException: For input string: ""
。空文字""が渡ったのがダメだったようです。
原因箇所は、LogInputFragment.kt:46とのことですから、LogInputFragment.kt
ファイルの46行目です。クリックできるようになっていますので、クリックするとその行に飛んでくれます。便利。
val stepCount = edit_count.text.toString().toInt()
で落ちています。どうやら、空文字""がtoInt()
に渡ってはいけないようです。
どうしましょうか?強制的に0にするか・・・
値が未入力だったら、登録できないようにするのは、どうでしょう?
はいということで、入力データのバリデーション(varidation/検証)を行うようにしましょう。検証エラーだったら、メッセージを表示して、登録できないことをユーザーに知らせます。
今回の場合、空文字でなければ良いので、チェックはこうなります。
if(edit_count.text.toString()==0){
// エラーメッセージを表示
}else{
// 登録処理
}
それと、未来日付もNGにしましょうか。
こんなチェック関数を用意しました。logInputValidation
はグローバルな関数にしています。理由は後でJUnitでテストしやすいようにです^^;
class LogInputFragment : : Fragment() {
...
private fun validation(): Int? {
val selectDate = viewModel.selectDate.value?.clearTime()
return logInputValidation(today, selectDate!!, edit_count.text.toString())
}
}
fun logInputValidation(
today: Calendar, selectDate: Calendar,
stepCountText: String?
): Int? {
if (today.before(selectDate)) {
// 今日より未来はNG
return R.string.error_validation_future_date
}
// ステップ数が1文字以上入力されていること
if (stepCountText.isNullOrEmpty()) {
return R.string.error_validation_empty_count
}
return null
}
clearTime
という関数は、Calendarクラスの拡張関数で次のように定義しました。
fun Calendar.clearTime() : Calendar{
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
return this
}
LogInputFragment
にtoday
を今日で初期化し、同時にclearTime()
しておきます
private val today = Calendar.getInstance().clearTime()
なぜ時間をクリアしているか分かりますか?
logInputValidation
メソッドで、Calendar#before
を使ってますが、この関数は当然ながら、ミリ秒まで比較します。
年月日までの比較で良いので(というかミリ秒まで比較すると都合が悪い)、不要なところはクリアしているというわけです。
最後に、登録部分の処理を書き換えます。
contentView.button_resist.setOnClickListener {
validation()?.let {
val fgm = fragmentManager ?: return@setOnClickListener
ErrorDialog.Builder().message(it).create().show(fgm, null)
return@setOnClickListener
}
val dateText = text_date.text.toString()
val stepCount = edit_count.text.toString().toInt()
val level = levelFromRadioId(radio_group.checkedRadioButtonId)
val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
val stepCountLog = StepCountLog(dateText, stepCount, level, weather)
viewModel.changeLog(stepCountLog)
}
ErrorDialog.Builder().message(it).create().show(fgm, null)
ErrorDialog
クラスはこんな感じで作りました。最近、Builderかますのがマイブームです。
class ErrorDialog : DialogFragment() {
class Builder() {
private var message: String? = null
private var messageResId: Int = R.string.error
fun message(message: String): Builder {
this.message = message
return this
}
fun message(resId: Int): Builder {
this.messageResId = resId
return this
}
fun create(): ErrorDialog {
val d = ErrorDialog()
d.arguments = Bundle().apply {
if (message != null) {
putString(KEY_MESSAGE, message)
} else {
putInt(KEY_RESOURCE_ID, messageResId)
}
}
return d
}
}
companion object {
const val KEY_MESSAGE = "message"
const val KEY_RESOURCE_ID = "res_id"
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
// AlertDialogで作成する
val builder = AlertDialog.Builder(requireContext())
// メッセージの決定
val message =
when {
arguments!!.containsKey(KEY_MESSAGE) -> arguments!!.getString(KEY_MESSAGE)
else -> requireContext().getString(
arguments!!.getInt(KEY_RESOURCE_ID)
)
}
// AlertDialogのセットアップ
builder.setMessage(message)
.setNeutralButton(R.string.close, null)
return builder.create()
}
}
カレンダーで未来の日付を選択したり、ステップ数を入力しないで登録ボタンを押して下さい。
エラーメッセージは、strings.xml
にお好きに定義して下さい。
4. テスト
今回テストはこんな内容でしょうか。
-
表示テストを修正する
- 追加メニューボタンでダイアログでは無くActivityが起動する
- RecyclerViewのレイアウト変更に合わせたテストの修正
-
新しい表示テスト
- 登録画面の表示テスト
- 登録画面で日付選択ボタンでダイアログが表示される
- ダイアログのテスト
- 登録画面でステップ数を入力したときのテスト
- 登録画面でラジオボタンを押したときのテスト
- 登録画面でスピナーを押したときのテスト
- 登録画面で登録ボタンを押したときのテスト
-
validationのテスト
結構ボリュームがありますね。頑張りましょう。
(1) MainActivityのテストの変更
androidTest向けに書きます。Robolectric向けは、Githubにはpushしてあります。
下記をapp/build.gradle
のdependenciesに追加しておく必要があるかも知れません。
androidTestImplementation 'org.assertj:assertj-core:3.2.0'
また、テストをビルド時に
> Error while dexing.
The dependency contains Java 8 bytecode. Please enable desugaring by adding the following to build.gradle
というようなエラーが出た場合は、これは"dependenciesがJava8を使ってるから有効にしなさい"というメッセージなので、その下にある指示の通り、app/build.gradle
のandroid{}
内に、下記を追記して下さい。
android{
...
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
1. 不要なテストを削除
以下のテストは不要なので削除します。
- inputDialogFragmentShown
- inputStep
また、addRecordMenuIcon
は、最初のEspresso.pressBack()
が不要になったので削除しておきます。
2. 画面遷移のテスト
addRecordMenu
を変更して、画面遷移のテストにします。
@Test
fun addRecordMenu() {
// ResultActivityの起動を監視
val monitor = Instrumentation.ActivityMonitor(
LogItemActivity::class.java.canonicalName, null, false)
getInstrumentation().addMonitor(monitor)
// 追加メニューをクリック
onView(
Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
).perform(click())
// ResultActivityが起動したか確認
val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L)
assertThat(monitor.hits).isEqualTo(1)
assertThat(resultActivity).isNotNull()
// 端末戻るボタンで終了を確認
Espresso.pressBack()
assertThat(resultActivity.isFinishing).isTrue()
}
Activityが起動したかどうかのテストは、ActivityMonitor
を使ってカウントが増えたことと、その結果のresultActivity
がnullでないことで確認するのが王道・・・かな?
取り敢えず起動したことだけを確認したい場合は、これで十分かと思います。
尚、上記テストコードをそのままRobolectric向けで実行すると、resultActivity
がnullになってしまいテストが失敗します。
hit==1
にはなっているのに・・・
原因・回避方法を探っていますがまだ分かっていないので、分かったら追記します。
今は取り敢えず、androidTestで進めていきます。
3. 新しいRecyclerViewレイアウト
addRecordList
も、レイアウトが変わったのでビルドエラーになっていると思います。直していきましょう。
@Test
fun addRecordList() {
// ViewModelのリストに直接追加
val mainActivity = activityRule.activity
mainActivity.runOnUiThread {
mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// リストの表示確認
var index = 0
onView(withId(R.id.log_list))
// @formatter:off
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
.check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
.check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
// @formatter:on
index = 1
onView(withId(R.id.log_list))
// @formatter:off
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
.check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
.check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp),R.id.levelImageView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
// @formatter:on
}
mainActivity.runOnUiThread {}
の部分は、ViewModelのaddStepCount
が@UiThread
を付けた上でLiveDataのpostValue
ではなくsetValue
を使っているので、UIスレッドから更新する必要があるためにこうしています。
(※ViewModelを更新できるルールがあるはずなのですが、どうにもimportが出来ず、今回は使うのを断念しました。また解決次第、追記します)
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
で、UIの更新が終わるのを待っています。
尚、Robolectric版の場合は、このUIスレッドからviewModelを更新しなければならない、という部分はなぜか見逃されるので、以下のように書いてもテストは通りますが、もしこれがRobolectricのバグで、将来直されたら通過しなくなる可能性は有ります。
val mainActivity = activityRule.activity
mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
// リストの表示確認
...
// @formatter:off
と// @formatter:on
は、フォーマッターで改行されて縦に長くなって見づらかったので解除してみました。この二つで囲むと、formatterを部分的に無効にすることが出来ますが、設定で有効にしておく必要があります。
[Preferences]-[Editor]-[Code Style]とし、下記図の部分にチェックを入れ、[OK]すると有効になります。

withDrawable
というのも自作関数です。
やっているのは、ImageViewに設定されているカレントのDrawableと、指定のリソースidのDrawableが、内部的に同じBitmapかをチェックしています。
class DrawableMatcher(private val expectedId: Int) : TypeSafeMatcher<View>(View::class.java) {
private var resourceName: String? = null
override fun matchesSafely(target: View): Boolean {
if (target is ImageView) {
if (expectedId < 0) {
return target.drawable == null
}
val resources = target.getContext().resources
val expectedDrawable = resources.getDrawable(expectedId, null)
resourceName = resources.getResourceEntryName(expectedId)
if (expectedDrawable == null) {
return false
}
var drawable = target.drawable
if (drawable is StateListDrawable) {
drawable = drawable.getCurrent()
}
val bitmap = drawable.toBitmap()
val otherBitmap = expectedDrawable.toBitmap()
return bitmap.sameAs(otherBitmap)
}
return false
}
override fun describeTo(description: Description) {
description.appendText("with drawable from resource id: ")
description.appendValue(expectedId)
if (resourceName != null) {
description.appendText("[")
description.appendText(resourceName)
description.appendText("]")
}
}
}
fun withDrawable(resourceId: Int): Matcher<View> {
return DrawableMatcher(resourceId)
}
どこに書いてもいいですが、atPositionOnView
と併せて、EspressoUtils.kt
とかにまとめておいてもいいかも知れませんね。
(2) 登録画面のテスト
新しく追加した画面のテストをしましょう。Fragmentごとにテストするという手もあるようなのですが、今はまだ1つしかないので、いったんActivityのテストとして作成します。
なお、LogItemActivity
のActivityRuleは次のようにします。
@get:Rule
val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)
ActivityTestRule
コンストラクタの2つめの引数は、initialTouchMode
に対するBoolean値です。デフォルトはfalse
です。
3つめの引数は、launchActivity
に対するBoolean値です。デフォルトはtrue
で、trueだと、自動的にActivityが起動されます。でも、自動で起動されたら困る場合もあります。例えば、先に設定ファイルやデータベースの値を、テストケースごとに設定したい場合などです。LogItemActivity
はいずれ他のFragmentを起動する場合を想定しているため、あらかじめ自動で起動せず、こちらで設定を終えた後で手動で起動するようにしておきます。
Activityを起動するコードは次のようになります。
activity = activityRule.launchActivity(null)
launchActivity
の引数はIntent
型です。Activityを起動するIntentを指定できますが、ExtraDataが特にない場合は、nullでOKです。
上記を踏まえ、テストを書いていってみましょう。
1. 起動直後の表示のテスト
各Viewの初期値のセットを確認しましょう。
2. 日付選択ボタンでダイアログが表示されるテスト
以前あったInputDialogFragment
に対してやっていたのと同じ感じで、該当のDialogが表示されていることを確認したいですが、CalendarViewが中々に特殊なので、ちょっと別のアプローチが必要です。
SupportFragmentManager
で、今追加されているFragmentが全部取れるので、そのクラスのオブジェクトからCalendarViewを直接参照して確認します。
3. ステップ数を入力したときのテスト
値がちゃんと置き換わることを確認すれば良いでしょう。
4. ラジオボタンを押したときのテスト
Aを押せばB,Cは非選択になること、Bを押せばA,Cは非選択になること、Cを押せば・・・というのを確認しましょう。
ラジオボタンがチェック状態かどうかは、isChecked()
でチェック出来ます。
5. スピナーを押したときのテスト
初期値とは別の任意の要素を選択したとき、表示が変わるかどうかをチェックしましょう。
可能であれば、必要な要素が全部ポップアップされるかも確認しましょう。スクロールすると厄介ですが、取り敢えずそんなに数はないのでまだ大丈夫かと。
6. 登録ボタンを押したときのテスト
値をそれぞれセットして、戻りのIntentにExtraDataとして正しく設定されているか確認しましょう。
7. エラーメッセージの表示テスト
いわゆる"異常系"のテストです。
「日付が未来」「ステップ数が未入力」のとき、エラーメッセージが表示されていることも確認しましょう。
validation
関数のUnitテスト自体も書きましたが、その「戻り」を正確に判定して、正しいエラーメッセージを出しているか、のUIのテストとしては、やはり必要です。
8. 実装結果
すべてを実装したandroidTest用のサンプルはこちら。
import android.app.Activity
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.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import jp.les.kasa.sample.mykotlinapp.*
import jp.les.kasa.sample.mykotlinapp.activity.logitem.LogItemActivity.Companion.EXTRA_KEY_DATA
import jp.les.kasa.sample.mykotlinapp.data.LEVEL
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog
import jp.les.kasa.sample.mykotlinapp.data.WEATHER
import jp.les.kasa.sample.mykotlinapp.espresso.withDrawable
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.data.Offset
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.*
@RunWith(AndroidJUnit4::class)
class LogItemActivityTestI {
@get:Rule
val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)
lateinit var activity: LogItemActivity
/**
* 起動直後の表示のテスト<br>
* LogInputFragmentの初期表示をチェックする
*/
@Test
fun logInputFragment() {
activity = activityRule.launchActivity(null)
// 日時ラベル
onView(withText(R.string.label_date)).check(matches(isDisplayed()))
// 日付
val today = Calendar.getInstance().getDateStringYMD()
onView(withText(today)).check(matches(isDisplayed()))
// 日付選択ボタン
onView(withText(R.string.label_select_date)).check(matches(isDisplayed()))
// 歩数ラベル
onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
// 歩数ヒント
onView(withHint(R.string.hint_edit_step)).check(matches(isDisplayed()))
// 気分ラベル
onView(withText(R.string.label_level)).check(matches(isDisplayed()))
// 気分ラジオボタン
onView(withText(R.string.level_normal)).check(matches(isDisplayed()))
onView(withText(R.string.level_good)).check(matches(isDisplayed()))
onView(withText(R.string.level_bad)).check(matches(isDisplayed()))
onView(withDrawable(R.drawable.ic_sentiment_neutral_green_24dp)).check(matches(isDisplayed()))
onView(withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp)).check(matches(isDisplayed()))
onView(withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp)).check(matches(isDisplayed()))
// 天気ラベル
onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
// 天気スピナー
onView(withId(R.id.spinner_weather)).check(matches(isDisplayed()))
// 登録ボタン
onView(withText(R.string.resist)).check(matches(isDisplayed()))
}
/**
* 日付選択ボタンでダイアログが表示されるテスト
*/
@Test
fun selectDate() {
activity = activityRule.launchActivity(null)
val today = Calendar.getInstance()
// 日付選択ボタン
onView(withText(R.string.label_select_date)).perform(click())
// CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
// 内容の確認は難しい(表示されているはずの文字列で見つけられない。もしかしたら文字列じゃ無く画像なのかも)
// なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
// そこからCalendarViewのインスタンスを得ている
val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
as DateSelectDialogFragment
// 初期選択時間が、起動前に取得した時間と僅差であることの確認
assertThat(fragment.calendarView.date).isCloseTo(today.timeInMillis, Offset.offset(1000L))
}
/**
* 選択を変更してキャンセルしたときに表示が変わっていないこと
*/
@Test
fun selectDate_cancel() {
activity = activityRule.launchActivity(null)
val today = Calendar.getInstance()
// 日付選択ボタン
onView(withText(R.string.label_select_date)).perform(click())
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
// 内容の確認は難しい
// なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
// そこからCalendarViewのインスタンスを得ている
val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
as DateSelectDialogFragment
val newDate = today.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
// 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
// そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())
// ボタンのクリックはEspressoで書ける
onView(withText(android.R.string.cancel)).perform(click())
// 新しい日付は表示されていない
onView(withText(newDate.getDateStringYMD())).check(doesNotExist())
// 当日のまま
onView(withText(today.getDateStringYMD())).check(matches(isDisplayed()))
}
/**
* 選択を変更してOKしたときに表示が変わっていること
*/
@Test
fun selectDate_ok() {
activity = activityRule.launchActivity(null)
val today = Calendar.getInstance()
// 日付選択ボタン
onView(withText(R.string.label_select_date)).perform(click())
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
// 内容の確認は難しい
// なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
// そこからCalendarViewのインスタンスを得ている
val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
as DateSelectDialogFragment
val newDate = today.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
// 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
// そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())
// ボタンのクリックはEspressoで書ける
onView(withText(android.R.string.ok)).perform(click())
// 新しい日付になっていること
onView(withId(R.id.text_date)).check(matches(withText(newDate.getDateStringYMD())))
}
/**
* ステップ数を入力したときのテスト
*/
@Test
fun editCount() {
activity = activityRule.launchActivity(null)
onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
.perform(replaceText("12345"))
onView(withId(R.id.edit_count)).check(matches(withText("12345")))
// 取り敢えず再入力も
onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
.perform(replaceText("4444"))
onView(withId(R.id.edit_count)).check(matches(withText("4444")))
}
/**
* ラジオグループの初期状態
*/
@Test
fun levelRadioGroup() {
activity = activityRule.launchActivity(null)
// 初期選択状態
onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
.check(matches(isChecked()))
onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
}
/**
* ラジオボタン[GOOD]を押したときのテスト
*/
@Test
fun levelRadioButtonGood() {
activity = activityRule.launchActivity(null)
onView(withId(R.id.radio_good)).perform(click())
// 選択状態
onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
.check(matches(isChecked()))
onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
}
/**
* ラジオボタン[BAD]を押したときのテスト
*/
@Test
fun levelRadioButtonBad() {
activity = activityRule.launchActivity(null)
onView(withId(R.id.radio_bad)).perform(click())
// 選択状態
onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
.check(matches(not(isChecked())))
onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
.check(matches(isChecked()))
}
/**
* スピナーを押したときのテスト
*/
@Test
fun weatherSpinner() {
activity = activityRule.launchActivity(null)
// 初期表示
onView(withText("晴れ")).check(matches(isDisplayed()))
onView(withId(R.id.spinner_weather)).perform(click())
// リスト表示を確認
onView(withText("晴れ")).check(matches(isDisplayed()))
onView(withText("雨")).check(matches(isDisplayed()))
onView(withText("曇り")).check(matches(isDisplayed()))
onView(withText("雪")).check(matches(isDisplayed()))
onView(withText("寒い")).check(matches(isDisplayed()))
onView(withText("暑い")).check(matches(isDisplayed()))
// 初期値以外を選択
onView(withText("雨")).perform(click())
onView(withText("晴れ")).check(doesNotExist())
onView(withText("雨")).check(matches(isDisplayed()))
}
/**
* 登録ボタン押下のテスト:正常
*/
@Test
fun resistButton_success() {
activity = activityRule.launchActivity(null)
val today = Calendar.getInstance().apply {
set(Calendar.YEAR, 2019)
set(Calendar.MONTH, 5)
set(Calendar.DAY_OF_MONTH, 20)
}
activity.runOnUiThread {
activity.viewModel.dateSelected(today)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
.perform(replaceText("12345"))
onView(withId(R.id.radio_good)).perform(click())
onView(withId(R.id.spinner_weather)).perform(click())
onView(withText("曇り")).perform(click())
onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
.perform(click())
assertThat(activityRule.activityResult.resultCode).isEqualTo(Activity.RESULT_OK)
assertThat(activityRule.activityResult.resultData).isNotNull()
val data = activityRule.activityResult.resultData.getSerializableExtra(EXTRA_KEY_DATA)
assertThat(data).isNotNull()
assertThat(data is StepCountLog).isTrue()
val expectItem = StepCountLog("2019/06/20", 12345, LEVEL.GOOD, WEATHER.CLOUD)
assertThat(data).isEqualToComparingFieldByField(expectItem)
}
/**
* 登録ボタン押下のテスト:未来日付エラー
*/
@Test
fun resistButton_error_futureDate() {
activity = activityRule.launchActivity(null)
val next = Calendar.getInstance().addDay(1)
activity.runOnUiThread {
activity.viewModel.dateSelected(next)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
.perform(replaceText("12345"))
onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
.perform(click())
onView(withText(R.string.error_validation_future_date)).check(matches(isDisplayed()))
}
/**
* 登録ボタン押下のテスト:カウント未入力エラー
*/
@Test
fun resistButton_error_emptyCount() {
activity = activityRule.launchActivity(null)
val today = Calendar.getInstance()
activity.runOnUiThread {
activity.viewModel.dateSelected(today)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
.perform(click())
onView(withText(R.string.error_validation_empty_count)).check(matches(isDisplayed()))
}
}
// そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
のコメント部分についてですが、具体的に以下のような記述になっています。
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val selectDate = Calendar.getInstance()!!
本来はprivateでいいのだけど、テストではアクセスしたいのでpublicにしたい・・・という時に疲れる技です。
Robolectric用は、Githubに上がっているブランチqiita_04にpushしてあります。ご興味ある方はチェックしてみて下さい。
※両方のテストを書いているので、工数が半端なくなってきた・・・
※取り敢えず分かったことは、結構アプローチを変えないと行けないテストが多く、まだまだテストコードの完全共有にはほど遠いな、と感じています。
(3) validationのテスト
1. Calendarの拡張関数のテスト
Util.ktに追加したCalendarクラスの拡張関数2本のテストを追加しましょう。
@Test
fun calendar_getStringYMD() {
val cal = Calendar.getInstance()
cal.set(2020, 9 - 1, 11) // 月だけはindex扱いなので、実際の月-1のセットとしなければならない
assertThat(cal.getDateStringYMD()).isEqualTo("2020/09/11")
}
@Test
fun calendar_clearTime() {
val cal = Calendar.getInstance()
// 時間関連が0にならないようにセット
cal.set(Calendar.HOUR, 1)
cal.set(Calendar.MINUTE, 10)
cal.set(Calendar.SECOND, 20)
cal.set(Calendar.MILLISECOND, 300)
// 0でないことの確認
assertThat(cal.get(Calendar.HOUR)).isNotEqualTo(0)
assertThat(cal.get(Calendar.MINUTE)).isNotEqualTo(0)
assertThat(cal.get(Calendar.SECOND)).isNotEqualTo(0)
assertThat(cal.get(Calendar.MILLISECOND)).isNotEqualTo(0)
cal.clearTime()
// 0になっていることの確認
assertThat(cal.get(Calendar.HOUR)).isEqualTo(0)
assertThat(cal.get(Calendar.MINUTE)).isEqualTo(0)
assertThat(cal.get(Calendar.SECOND)).isEqualTo(0)
assertThat(cal.get(Calendar.MILLISECOND)).isEqualTo(0)
}
Githubのプロジェクトには、Util.kt
に他にも便宜上追加した関数があり、それらのテストもUtilTest
に追加してあります。
2. validation関数のテスト
logInputValidation
はpublicなグローバル関数でなので、そのままJUnitのテストとして書けば良いでしょう。
特に難しいことは無いと思いますが、正常系だけで無く、異常系も閾値をしっかりテストしましょう。
3. MainActivityに登録画面からの戻りが反映されるかのテスト
データが登録画面から返ってきたときに表示に反映されるかの確認をします。
このテストはMainActivityTestでonActivityResult
のテストとして実装しますが、onActivityResult
はprotected
で、Javaのprotected
と違い、同じパッケージでも呼び出せないので、androidTestではかなり強引にやります。
他のテスト同様に、登録画面を起動する手順を踏むと、resultActivityが取れますから、resultActivity#setResult()
してresultActiviy.finish()
してしまいます。
そうすれば後は表示内容を確認すれば良いです。
@Test
fun onActivityResult() {
val resultData = Intent().apply {
putExtra(EXTRA_KEY_DATA, StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.SNOW))
}
val monitor = Instrumentation.ActivityMonitor(
LogItemActivity::class.java.canonicalName, null, false
)
getInstrumentation().addMonitor(monitor)
// 登録画面を起動
onView(
Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
).perform(click())
val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
resultActivity.setResult(Activity.RESULT_OK, resultData)
resultActivity.finish()
// 反映を確認
val index = 0
onView(withId(R.id.log_list))
// @formatter:off
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
.check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
.check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp), R.id.levelImageView)))
.check(matches(atPositionOnView(index,
withDrawable(R.drawable.ic_grain_gley_24dp),R.id.weatherImageView)))
// @formatter:on
}
Robolectricでは、resultActivityが取れないので、shadowクラスを使った書き方しか分かりませんでした。Githubにpushしてあるので、サンプルを参照してみて下さい。
※espresso-intentsライブラリを使って、書けないかなと頑張ったのですが、上手くいきませんでした・・・
(4) ViewModelのテスト
1. MainViewModelのテストの修正
MainViewModel.kt
のテストを修正します。
LiveDataの型が変わったのでその対応ですね。
@Test
fun addStepCount() {
viewModel.addStepCount(StepCountLog("2019/06/21", 123))
viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
assertThat(viewModel.stepCountList.value)
.isNotEmpty()
val list = viewModel.stepCountList.value as List<StepCountLog>
assertThat(list.size).isEqualTo(2)
assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))
assertThat(list[1]).isEqualToComparingFieldByField(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
}
2. LogItemViewModelのテスト
MainViewModelと同等なテストを作れば良いでしょう。
class LogItemViewModelTest {
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
lateinit var viewModel: LogItemViewModel
@Before
fun setUp() {
viewModel = LogItemViewModel()
}
@Test
fun init() {
Assertions.assertThat(viewModel.stepCountLog.value)
.isNull() // 初期化したときはnull
}
@Test
fun changeLog() {
viewModel.changeLog(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))
assertThat(viewModel.stepCountLog.value)
.isEqualToComparingFieldByField(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))
}
@Test
fun dateSelected() {
var date = Calendar.getInstance()
date.set(Calendar.YEAR, 2019)
date.set(Calendar.MONDAY, 5)
date.set(Calendar.DAY_OF_MONTH, 15)
date = date.clearTime()
viewModel.dateSelected(date)
assertThat(viewModel.selectDate.value!!.getYear())
.isEqualTo(2019)
assertThat(viewModel.selectDate.value!!.getMonth())
.isEqualTo(5)
assertThat(viewModel.selectDate.value!!.getDay())
.isEqualTo(15)
}
まとめ
data classの使い方を覚えたり、data binding、そしてFragmentの使い方、Activity同士の遷移の仕方を学びました。
結構なボリュームでしたね。
ここまでの状態のプロジェクトをGithubにpushしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_04
次回予告
今は起動するとデータが消えてしまいます。再起動したら、前回入力した値は表示されていたいですよね。
つまりデータを永続化しようというお話になります。
ま、早い話、Room使ってデータベースに保存していきましょ。
あ、でもその前に一つ、番外編を挟むかも知れません。
参考ページなど
-
iconmonstr : ベクターアイコン画像のフリー素材
-
RecyclerViewにLiveDataをDataBindingしたい
- Fragmentでやる場合の参考になります
- https://off2white.hatenablog.com/entry/2018/12/23/224222
-
Espresso matcher for ImageView made easy with Android KTX
- ImaveViewに設定されたリソースファイルの一致検査
withDrawable
の参考にしました - https://medium.com/@miloszlewandowski/espresso-matcher-for-imageview-made-easy-with-android-ktx-977374ca3391
- ImaveViewに設定されたリソースファイルの一致検査
-
EspressoでSpinner内の特定の選択肢をクリックしたい
- EspressoでのSpinnerのテストの参考にしました
- https://qiita.com/teracy/items/ebbe92e2384657800856
-
RobolectricのAlertDialogのテストでShadowAlertDialogがshadowOfで作れなかったときに参考にしたStackOverflow
-
Robolectricの`Activity#onResult"のテストでshadowクラスを使うやり方の参考にしました