Android
Kotlin
LiveData
AndroidArchitectureComponents

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(2)

前回の続きです。


今回の目標

今回は次の二つが出来るようになるのが目標です。


  • 値を入力する画面(ダイアログ)を起動時に表示

  • 入力した値をActivityに表示(LiveData)


1. 起動したらダイアログを表示する


(1) ダイアログのレイアウト


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

ダイアログのレイアウトを、xmlファイルに作っていきます。


  • 左側にあるプロジェクトパネルのres-layoutで右クリック

  • [New]をクリック

  • [Layout resource file]をクリック

    kotlin_02_001.png



  • 下記設定で[OK]をクリックする


    • ファイル名: dialog_input

    • Root element: androidx.constraintlayout.widget.ConstraintLayout
      kotlin_02_002.png



dialog_input.xmlが開くので、こんなレイアウトを作ってみましょう。

kotlin_02_003.png

Component Treeはこんな感じになります。

kotlin_02_004.png

以下答え合わせ用。

レイアウトxmlファイルはこのような感じになります。


dialog_input.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"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/label_Title"
android:text="今日は何歩歩いた?"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textAlignment="center"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"/>
<EditText
android:id="@+id/editStep"
android:hint="歩数を入力"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:inputType="numberSigned"
android:layout_alignParentStart="true"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@+id/label_Title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:singleLine="true"
android:textSize="24sp"
android:textAlignment="viewEnd"/>
<TextView
android:id="@+id/label_step"
android:text="歩"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@+id/editStep"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="@+id/editStep"/>
</androidx.constraintlayout.widget.ConstraintLayout>


分かりづらい属性は無いと思いますが、一応以下だけ説明。



  • android:inputType="numberSigned" : 整数値だけ入力できる


  • android:singleLine="true" : 改行を許可しない


  • android:textAlignment="viewEnd" : テキストを右寄せ(右から左に文字を書く言語の場合は、左寄せ)



2. 文字列リソースを外に出す(任意作業)

黄色い注意アイコンの、"HardcodedText"というのは、多言語対応しない限りあまり気にしなくても良い物なのですが、警告があると気持ちが悪いという人は、以下の手順で文字列リソースを外出しして下さい。



  • res-valuesにあるstrings.xmlを開く

kotlin_02_005.png

以下の3つのリソースを定義する


strings.xml

    <string name="label_input_title">今日は何歩歩いた?</string>

<string name="label_step"></string>
<string name="hint_edit_step">歩数を入力</string>

xmlなので解説はしません。

この文字列リソースを、レイアウトファイルで参照していきます。

文字列リソースは、レイアウトファイルからは@string/<name>で参照できます。

ということで、それぞれのtext属性の所は次のようになります。


dialog_input.xml

<TextView

android:id="@+id/label_Title"
android:text="@string/label_input_title"
.../>

<EditText
android:id="@+id/editStep"
android:hint="@string/hint_edit_step"
.../>

<TextView
android:id="@+id/label_step"
android:text="@string/label_step"
.../>


これで警告が消え・・・・てませんね。

Missing autofillHints attribute

無視しても良いですが、せっかくなので対応してみましょう。

autofillHintsという属性が設定されていない、という警告のようです。

注意アイコンをクリックすると詳細が説明されているのですが、今回はautofillは無効でいいと思いますので、Suggested Fixesにある[Set importantForAutofill="no"]というのを選んでみましょう。

kotlin_02_006.png

消えませんね(イラッ

警告の内容が変わりました。

Attribute importantForAutofill is only used in API level 26 and higher (current min is 24)

API level>=26じゃないと、importantForAutofillは使われませんよ。あなたのプロジェクトのminが24なので、24,25の端末だと使えないですよ。

という警告ですね。

ここで解決策は二つ。


  1. min levelを26に上げる

  2. 26以上用と、26未満用にレイアウトファイルを分ける

  3. 警告は無視する

1が出来る方はしてしまって良いですが、世の中まだ4.4(API level 19)とか6(API level 23)とか使っている人もいます。(余談ですが、4.4はOS5未満の世代で最も安定していたバージョンなので、私も手元に一台残してあります)

私は一応6以上はサポートしておきたいので、2の手段を取ってみることにします。

面倒な人は3でも良いですが、API26未満のOSが入っている端末での動作は保証しませんよ。


3. レイアウトファイルをAPI level別に用意する(任意作業)

先ほどの警告が出ている画面で、Suggested Fixesにある、[Override resource in layout-v26]というFixボタンを押してみましょう。

なにやらファイルがもう一つ開きました。

プロジェクトパネルでres-layoutを開くと、dialog_input.xml(2)となっています。展開すると、ファイルが二つになっていますね。

kotlin_02_007.png

(v26)と書いてある方が、API level 26以上の端末で使われるレイアウトファイルになります。無印の方は、25以下の端末で使われます。

OSが実行時に自動的に選んで適用してくれるのです。

実際のファイルの配置を念のため見ておきましょう。

Macでのキャプチャですが、app/src/main/res/layoutと、app/src/main/res/layout-v26という二つのフォルダがあり、それぞれにdialog_input.xmlというファイルがあるのが分かると思います。

kotlin_02_008.png

このように、resフォルダ内に、特定の命名規則でフォルダを作ると、言語別や、API level別、画面の横向き・縦向き別、等々、さまざまな要因別にリソースファイルを作ることができ、その環境に最も適したファイルをAndroidOSが実行時に自動的に使ってくれるという仕組みがあります。

先ほど文字列リソースの時に多言語対応のことに触れましたが、英語と日本語に対応するならば、次のような感じにファイルを用意しておけば、端末の言語設定に応じて自動的にローカライゼーションがされるというわけです。



  • app/src/main/res/values/strings.xml : 英語文字列を設定


  • app/src/main/res/values-ja/strings.xml : 日本語文字列を設定

上記の場合、端末の言語設定が日本語なら日本語が表示され、それ以外の言語設定ではすべて英語が表示されることになります。

この仕組みは、代替(だいたい)リソースと呼ばれているようです。

詳しくは、こちらをご参考下さい。

https://developer.android.com/guide/topics/resources/providing-resources?hl=ja

さて、v26以上向けのレイアウトファイルは、警告が消えましたが、26未満向けのファイルは相変わらず警告が出たままです。

もう対処したので、この警告は無視することにしましょう。

(v26)の付いてない方のdialog_input.xmlを開きます。android:importantForAutofill="no"の行にカーソルを合わせ、alt+Enterキー(Macの場合)を押して表示されるポップアップで、suppress: Add tools:ignore="UnusedAttribute"というのを選びましょう。

kotlin_02_009.png

xmlファイルはこうなったはずです。


dialog_input.xml

    android:importantForAutofill="no" 

tools:ignore="UnusedAttribute"/>

ぶっちゃけて言うと、ここでやったAPI level別レイアウトファイル分けは、割と無意味です。

警告の所にも、

This is not an error; the application will simply ignore the attribute. 

とあるように、エラーでは無くてただの警告で、OSは単純にこの属性を無視しますよ、と言っています。なので、わざわざv26用のレイアウトファイルを作る必要はあまりなかったのです。

まあでも、代替リソースに触れる良い機会と思ってやってみました。

ただ、レイアウトファイルをバージョン別にどんどん分けていくと、UI改修が入ったときに凄く面倒なことになりますので、個人的にはよほど縦・横でレイアウトをがっつり変えたいとかでない限り、レイアウトファイルは1つにしておいた方が無難かと思います。

今回は、お勉強と言うことで。

さて、レイアウトファイルがこれで出来上がったので、今度はダイアログクラスを作っていきます。


(2) ダイアログクラスを作る


1. Fragmentとは

ダイアログクラスと言っても、作るのは正確には、DialogFragmentです。

Fragmentとは、簡単に言えば、Activityに乗せるスクリーンのことです。

Activityは1画面、という風に考えると、その上に足したり、消したり、そのActivityの機能なんだけど、更にその中で特定のUI(の固まり)と結びついた機能があって、それをまとめやすくしたもの、とでも言えばいいでしょうか。

Googleさんがよく例に出しているのは、ハンドセット(いわゆるスマホ)とタブレットで、明らかにレイアウトが異なり(先ほどの代替リソースを使った場合など、ですね)、その処理をActivity内だけで条件分岐しているコードが汚らしいので、Fragmentで分ければ綺麗になるんじゃない?という事でした。

Activityは、タブレットならFragmentAをセットし、それ以外ならFragmentBをセットするだけ。UIの制御は、各Fragmentが面倒見るので、Activityのコードはほぼ何も無くなってアラすっきり綺麗ね!というわけです。

ダイアログも、Activityに追加するスクリーンの1つ、と思えば、Fragmentを使うという考え方も理解しやすいかなと思います。

ただ、ダイアログは、画面全体を覆わないとか、端末の戻るボタンで閉じられなきゃいけないとか、いわゆる「決まり事」がたくさんあります。

そこで、Googleが用意してくれているのが、Dialog用に特化したFragmentの基本クラスDialogFragmentです。

ということで、このクラスを使って作っていきます。


2. DialogFragmentを作る

[File]メニューの[New]-[Kotlin File/Class]を選び、下記設定で[OK]をクリックします。


  • Name: InputDialogFragment

  • Kind: Class

kotlin_02_010.png

DialogFragmentを継承させましょう。


DialogFragment.kt

import androidx.fragment.app.DialogFragment

class InputDialogFragment : DialogFragment{
}


エラーが出てますか?

デフォルトコンストラクタを呼んでないというエラーですね。

赤線が出ているところにカーソルを合わせ、alt+Enterキー(Macの場合)を押して表示されるポップアップで、[Change to constructor invocation]を選びましょう。

kotlin_02_011.png

あ、カッコが足りなかっただけね(汗)


DialogFragment.kt

import androidx.fragment.app.DialogFragment

class InputDialogFragment : DialogFragment(){
}


DialogFragmentでは、完全にレイアウトを自作することも可能ですが、今回は、AlertDialogのレイアウトを使うことにします。というかよほどのことがない限り、このレイアウトで良いように思います。特に、ボタンの配置が問題になります。

先ほど多言語対応、ローカライゼーションの話をしましたが、[OK]や[キャンセル]といったボタンの配置も、言語の慣習に従って変えないとダメよ、というのがGoogleのお言葉です。AlertDialogクラスは、それを自動的にやってくれます。

完全なカスタムレイアウトでやるときは、ボタンが全部縦に並んでるiOSのアクションシートのようなものを作るときとか、そういう場合に限定される、いや、限定した方が良いのでは無いかと思います。

※余談ですが、昔ゲームプログラマーをしていた際、「○」ボタンが日本では決定を意味し、「×」ボタンはキャンセル、でしたが、他言語では「×」はチェックを入れる、すなわち肯定の意味で、「決定を表す」と知ったときにはカルチャーショックでしたね。

AlertDialogを使う場合は、onCreateDialogをoverrideします。

importを聞かれたら、androidxのパッケージの方をインポートして下さい。


InputDialogFragment.kt

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

val builder = AlertDialog.Builder(context!!)

return builder.create()
}


!!てなんぞや?

これもKotlinのNullsafetyな仕様に関わる部分です。not-null assertion operatorと公式ドキュメントには書かれています。

この演算子は、【nullでなければ処理を実行し、nullだったら例外を投げる】という動作をします。

んん?せっかく?の有無でnullableとnon-nullを明示できるのに、ここでnull不許可なの?しかも無理矢理NPE(NullPointerException:ぬるぽですね)起こしてるじゃん?意味あんの?

ってなった方は、?の便利さについて充分出来ている方だと思います。

でも同時に、「そのコードを通るときには絶対にnullであってはいけないし、nullになるはずがない」という状況が、往々にしてあります。

例えば、このコードでのactivity!!です。DialogFragment自身のプロパティとして考えたとき、contextはnullableなのです。

しかし、ダイアログという物は、ActivityまたはFragmentといった、contextに追加されなければなりません。元の画面が存在していて、その上に被せる物だからですね。

だから、onCreateDialogが来たときには、呼び出し元の画面は必ず存在していることになり、contextがnullなわけ無いのです。

ということで、「ここではnullなはずがなく、nullチェックを書くとかえって冗長なコードになるから万が一nullなら例外を起こして良いよ」という時には、この演算子を使います。

ただ、もちろん、せっかくNull safetyな仕組みがあるのに、ここでそれを潰していることは確かですので、多用は禁物です。

ちなみに、ここでちゃんとnullチェックをしようとすると、IDE(AndroidStudio)に怒られないコードはこんな感じになりました。


InputDialogFragment.kt

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

val ctx = context ?: return super.onCreateDialog(savedInstanceState)
val builder = AlertDialog.Builder(ctx)
return builder.create()
}

わざわざ一度ローカル変数に入れているのは、下記のようなコードだと、let{}内でのcontextアクセスで、「この時点でnullでないことは保証されないよ」とやはり警告されるからです。


InputDialogFragment.kt

context?.let{

val builder = AlertDialog.Builder(context)
return builder.create()
}

ところで一つ上のコードで、super.onCreateDialog(savedInstanceState)呼んでますね。onCreateDialogの戻り値はDialogですから、Non-nullです。ということは、super.onCreateDialog(savedInstanceState)は、何かしら絶対にDialogの実体を返しているはずです。ということで、中身を覗いてみます。

super.onCreateDialog(savedInstanceState)にカーソルを合わせ、そこで右クリック-[Go To]-[Declaration]と選択してみて下さい。

※個人的な好みでここのショートカットをF3に変えてありますが気にしないで下さい。

kotlin_02_012.png

androidxのDialogFragmentの実装に跳べるかと思います。


DialogFragment.kt

@NonNull

public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
return new Dialog(requireContext(), getTheme());
}

requireContext()なるものを呼んでますね。さらに定義に跳んでみましょう。


Fragment.kt

@NonNull

public final Context requireContext() {
Context context = getContext();
if (context == null) {
throw new IllegalStateException("Fragment " + this + " not attached to a context.");
}
return context;
}

ホラね、やっぱり例外起こしてるだけだった^^;

!!を使っていても、例外が起きるのは同じですが、ただまあ、例外メッセージとしては、分かりづらくはなるかも知れません。この場合、IllegalStateExceptionが吐かれる方が実装・デバッグ側には分かりやすいっちゃ分かりやすいです。

なので気になる人は、requireContextFragmentクラスのpublicメソッドのようですから、当然サブクラス(実際には孫クラスくらいですが)のInputDialogFragmentからも使えるので、次のように書き換えれば良いかと思います。


InputDialogFragment.kt

val builder = AlertDialog.Builder(requireContext())

return builder.create()


3. AlertDialogに設定を行う

上記のコードのままだと、空っぽのダイアログが表示されます。・・・と思いました?

実は、ちょっと画面が暗くなるけど、「何も表示されません」(※そもそも、現時点でInputDialogFragmentを呼び出しているコードがないので、本当にそのまま実行したら何も起きませんが、試しにこの状態で、InputDialogFragmentを呼んでみたところ、の動作確認です)

当然です。AlertDialog.Builder作ってますが、何も設定してませんから。

早速設定していきましょう。

設定項目は、今回は次の3つを使います。


  • レイアウト

  • ネガティブボタン

  • ポジティブボタン

レイアウトは、先ほど作ったxmlファイルですね。

ネガティブボタンというのは、否定的動作をするボタン、という意味で、ほとんどの場合、「キャンセル」するような時に使います。

ポジティブボタンというのは、肯定的動作をするボタン、という意味で、決定とか、登録とか、アクションが実際に起こされるボタンに使います。

このボタンの配置に、先ほどの多言語対応でも述べた、[OK]や[キャンセル]といったボタンの配置も、言語の慣習に従って変えないといけないということが関係してきます。

AlertDialogは、言語設定に応じて、ポジティブボタンを右に出すのか、左に出すのか、ということを自動的に制御してくれるのです。これを使わない手はありません。

今回、ネガティブボタンは、「キャンセル」とします。ポジティブボタンは、「登録」としましょうか。

コードは次のようになります。


InputDialogFragment.kt

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

val builder = AlertDialog.Builder(requireContext())
builder.setView(R.layout.dialog_input)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.resist) { _, _ ->
Log.d("MyKotlinApp", "RESIST PUSHED")
}
return builder.create()
}



  • setView(R.layout.dialog_input)で、レイアウトを指定しています。


  • setNegativeButton(android.R.string.cancel, null)で、ネガティブボタンのラベルにandroid.R.string.cancelの文字列リソースを指定し、クリックリスナーにはnullを渡しています。



    • android.R.string.cancelは、androidSDKがデフォルトで用意しているリソースクラスを使っています。androidSDKがデフォルトで用意しているリソースには、このようにandroid.Rからアクセスできます。




  • setPositiveButton(R.string.resist){...}で、ポジティブボタンのラベルにR.string.resistの文字列リソースを指定し、クリックリスナーにラムダで関数を渡しています。取り敢えず中身はログを吐いているだけです。ラムダの中の_,_ ->は、パラメータが2つ渡ってくるが、使わない、ということを明示しています。本来は、DialogInterface!whichというオブジェクトが渡ってきます。whichはInt型で、押されたボタンのID(DialogInterface#BUTTON_POSITIVE等の定義済みIDのどれか)が渡ってきます。

R.string.resistは、次のようにしました。


strings.xml

    <string name="resist">登録</string>


これで、登録ダイアログが出来上がりました。


4. ダイアログを表示する

このまま実行しても何も起こりません。ダイアログを表示するコードをどこにも書いていないからですね。

ダイアログを表示するコードは定型的なので、覚えてしまえばいいかと思います。


MainActivity.kt

    companion object {

const val INPUT_TAG = "input_dialog"
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
}


InputDialogFragment()でInputDialogFragmentクラスのインスタンスを作っています。Javaで言うところのnew InputDialogFragment()ですが、Kotlinではnewキーワードが不要になっています。

DialogFragment#showメソッドが、ダイアログを表示するメソッドです。

第1引数のsupportFragmentManagerというのは、ActivityのFragmentを管理するマネージャーで、Fragmentを操作するときには必ずこれを介して行います。

showメソッドの二つ目の引数は、Dialogに付けるtagです。ここはnullでも構わない場合が多いですが、個人的には後から付ける方が面倒なのでだいたい毎回付けて送っています。

実行してみて下さい。ダイアログが表示されているはずです。

device-2019-06-06-153502.png

入力領域をタップすると、ソフトウェアキーボードが出てきて入力できるはずです。

入力文字を「整数値」に限定しているので、数字パッドが出てきました。この辺は端末や入力ソフトの設定によるかも知れません。

device-2019-06-06-153744.png

「登録」ボタンを押してみて下さい。ダイアログが閉じますね。それ以外に、「キャンセル」ボタンを押したり、ダイアログの外をタップしてみて下さい。いずれも、ダイアログが閉じます。

これらの「ダイアログを閉じる処理」を、何も書いていません。親クラスであるDialogFragmentが全部やってくれているわけです。


5. Logcat

登録ボタンを押したとき、ログ出力だけしていましたね。ログの出力は、Logcatで見ることが出来ます。Logcatの見方だけここで確認しておきましょう。

LogcatはAndroidStudioの下のツールウィンドウにあります。

kotlin_02_013.png

こんな行が出力されていれば、ポジティブボタンのクリックリスナーに確かに処理が飛んでいることが確認できます。

2019-06-06 15:41:51.172 26118-26118/jp.les.kasa.sample.mykotlinapp D/MyKotlinApp: RESIST PUSHED

他のメッセージも出ていて分かりづらいときは、TAGで絞り込む事も出来ます。

Logcatツールウィンドウの右端にあるドロップダウンリストから、[Edit Filter Configuration]を選びます。

kotlin_02_020.png

下記の設定をして[OK]を押すと、Logcatのメッセージウィンドウの部分が絞り込まれて表示されます。


  • Filter Name: 任意の名称

  • Log Tag: 絞り込みたいTAG

kotlin_02_021.png


2. 入力した数値を画面に表示する

ダイアログでせっかく値を入力しても、いまはダイアログが閉じるだけで何も起こりません。

入力した数値を、MainActivityで表示出来るようにしてみましょう。

解決しなければならないのは、下記の2点です。


  1. ダイアログに入力した値を取得する

  2. その値をMainActivityに渡す

1はそれほど難しいことではありません。2は、LiveDataの登場により大きく変わりました。

今回は勿論、LiveDataを使っていきます。


(1) ダイアログに入力した値を取得する

これはポジティブボタンのクリックリスナーの中で取得していきます。

が、少しbuilderにレイアウトをセットする部分の書き方も変えてあります。viewオブジェクトが欲しいからです。


InputDialogFragment.kt

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

val view = View.inflate(context, R.layout.dialog_input, null)
val builder = AlertDialog.Builder(requireContext())

builder.setView(view)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.resist) { _, _ ->
val step = view.editStep.text.toString()
}
return builder.create()
}


val view = View.inflate(context, R.layout.dialog_input, null)で、先にレイアウトファイルを元にViewを作っています。その後、builder.setView(view)でそのViewを渡しています。このように、レイアウトの指定は、レイアウトファイルのリソースidを渡すほかに、Viewクラスの実体そのものを渡すことも出来ます。例えば、WebViewが1つしかないような単純なレイアウトの場合、わざわざxmlファイルを作成することもないでしょう。そういう時は、setView(WebView())なんてしても良いわけです。

なぜviewオブジェクトが欲しかったかというと、リスナーのラムダの中を見てもらえば分かるかと思います。


InputDialogFragment.kt

val step = view.editStep.text.toString()


editStepが見つからない、と言われる場合は、次のimport文を追加して下さい。


InputDialogFragment.kt

import kotlinx.android.synthetic.main.dialog_input.view.*


変数viewに、ラムダの中からアクセスしているのにお気づきでしょうか。Javaならば、final演算子が必要なところですが、このように、内包するスコープから(その外で宣言されている)変数に普通にアクセスできる、というのも、Kotlinの特徴です。便利になりました。

viewが欲しかったのは、R.id.editStepとidを付けたEditTextにアクセスしたかったからです。

さて、昔Androidの実装を少しやったことがある人はビックリ!なコードかも知れませんね。

レイアウトファイルで定義したidに直接アクセスしてる?!と。findViewByIdどこ行った?!と(笑)

そう、不要なんです。Kotlinならね。

私がKotlinを使う最大の理由と言っても過言ではありません。

Kotlinから始めた方にはこのありがたみをどうお伝えすれば良いのか分かりませんが・・・

以前は、レイアウトファイルの各ウィジェットに付けたidから、そのViewを取得するには、こんなコードが必要でした。

TextView text = findViewById(R.id.editStep)

ちょっと前までは、更にキャストもしてやらなければなりませんでした。

TextView text = (TextView)findViewById(R.id.editStep)

比較的新しめのsupport-libraryで、キャストは不要になりましたが、それでも、こんなコードがあちこちに散らばっていて、コードの可読性を大変下げていました。

それがKotlinでは、editStepと直接まるでEditTextのオブジェクトのようにアクセスすることが出来ます。というか実際にEditTextオブジェトにアクセスしているんですけどね。

これを可能にしているのは、「拡張関数」という仕組みであり、それを使って上手いことidからのViewアクセスを可能にしてくれるプラグインを使っているからだ、ということだけ今は書いておきます。

ちなみに、プラグインは、build.gradle(app)の以下の場所で指定されています。Kotlinを使うと指定してプロジェクトを作った場合は、デフォルトで入っているはずです。


app/build.gradle

apply plugin: 'kotlin-android-extensions'


尚、viewをわざわざinflateしてもらい、view.editStepと、viewのメンバー変数としてアクセスしているのは、Fragmentだからです。Fragmentの場合、自身のrootのViewを介さないとだめなようです。Activityだともっと簡単に書けます。

val text = editStep.text.toString

Kotlin(というか、kotlin-extensions)様々です。


(2) 値をActivityに渡す

さて、ここからが問題です。

過去のAndroidプログラミンでは、ここは本当に悩ましいところでした。

まあだいたいは、イベントリスナー(interface)を作って、Activityにそれを実装させて、とやっていました。


InputDialogFragment.kt

    interface OnClickListerer{

fun onResist(step:Int)
}


MainActivity.kt

class MainActivity : AppCompatActivity(), InputDialogFragment.OnClickListerer {

override fun onResist(step: Int) {
// ...
}

これの何が問題かというと・・・


  • コードを追うのが面倒くさい。イベントリスナーの嵐になる。


  • onResistの中でUIを更新する処理をすると、ライフサイクル問題でクラッシュしかねない

特に後者は普通に実装していると中々気付かないのですが、実際にアプリを使っていると、電話がかかってきたりして急に自分のアプリは裏に行ってしまうと言うことは普通にあり得るのです。特にFragmentを出したり消したりする操作は致命的でクラッシュすることもしばしば。

これが開発者の頭を長いこと悩ませてきました。

しかし!救世主が現れました。そう、LiveDataです。

LiveDataがあれば、もうIllegalStateExceptionは怖くない。

LiveDataが何をしてくれるかなのですが、これは、LiveDataとして見張られているオブジェクトに、更新がかかったときは、監視者(オブザーバー)に通知を出してくれる、という物です。しかも、ちゃんと監視者の「ライフサイクル」を考慮してくれるのです。

素晴らしい。

あ、ライフサイクルについては、説明を始めるとQiita記事1,2本文くらいになるので、ここなどを見て何となく分かった気分になっておいて下さい。(いや、ホントは、分かった気分じゃ、ダメなのですが・・・)

とにかく、onResume()onPause()の間でなければ、UIの更新は出来ないのだ、と思っておいて下さい。

早速、LiveDataを使っていきます。

kaptプラグインを追加するのと、必要なライブラリをdependenciesに追加しましょう。


  • ファイルの先頭の方に追加


app/build.gradle

apply plugin: 'kotlin-kapt'



  • dependenciesに追加


app/build.gradle

def lifecycle_version = "2.0.0"

implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"


1. ViewModelクラス作成

LiveDataを直接使うことも出来ますが、MVVMで言うところのViewModelを介して行くことにします。AACはその名そのままのViewModelクラスを用意してくれています。

MainViewModelクラスのファイルを新規に作成し、MainViewModelクラスを、ViewModelクラスの派生とします。


MainViewModel.kt

import androidx.lifecycle.ViewModel

class MainViewModel : ViewModel() {

}


このViewModelに、ダイアログで入力した値を受け取るLiveDataな変数を定義します。MainActivityはこの変数を監視することで、変更通知を受け取って表示に使えるようになります。


MainViewModel.kt

class MainViewModel : ViewModel() {

val inputStepCount = MutableLiveData<Int>()
}

inputStepCountは、MutableLiveData<Int>のオブジェクトとして宣言、実体化されました。

MutableLiveDataというのは、LiveDataの派生クラスで、setValue/postValueといった「値を変更する」メソッドがpublicで定義されています。つまり、ViewModelクラス外から更新がかけられることになります。今回は、InputDialogFragmentクラスからこの値を更新したいので、こちらを使います。


2. LiveDataの値を更新する

InputDialogFragmentクラスからLiveDataの値を更新します。


InputDialogFragment.kt

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

val viewModel = ViewModelProviders.of(activity!!).get(MainViewModel::class.java)

val builder = AlertDialog.Builder(requireContext())
builder.setView(R.layout.dialog_input)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.resist) { _, _ ->
val step = editStep.text.toString()
viewModel.inputStepCount.value = step.toInt()
}
return builder.create()
}


val viewModel = ViewModelProviders.of(activity!!).get(MainViewModel::class.java)の行と、viewModel.inputStepCount.value = step.toInt()の行が追加です。

val viewModel = ViewModelProviders.of(activity!!).get(MainViewModel::class.java)

この行は、ViewModelクラスを使うときの定型句みたいなものです。activity!!は、先ほどの通り、プロパティとしてはnullableだけど、このタイミングではnullは有り得ないので、!!演算子を使っています。

ちなみに、

ViewModelProviders.of(this)

とも書けます。ViewModelProviders.of(this)の引数はFragmentも受け取れるパターンが定義されているからです。(※実際には、LifecycleOwnerをimplementしていれば受け取れる感じ)

しかし、今回は、Activityで監視したいViewModelと、同じViewModelのインスタンスで無いと意味がありません。

値をセットするViewModelのインスタンス(実体)と、値の変更を監視しているViewModelのインスタンスが別物だったら・・・連動しませんよね?

ということで、今回は、Activiyに紐付いたViewModelのインスタンスを使うため、Activityを渡しています。Fragment内で閉じたViewModelを扱う場合には、Fragmentのインスタンスを渡せば良いです。


3. LiveDataを監視する

今度はMainActivityでLiveDataを監視するためのコードを書きます。


MainActivity.kt

lateinit var viewModel: MainViewModel


MainViewModelという型のメンバー変数、viewModelを宣言しました。

lateinit varというのが出てきました。varは前回説明しましたね。変更可能な変数の場合に使うのでしたが・・・

今回は、lateinitというのが付いているので少し性質が変わってきます。

というのも、viewModelは、Non-nullであって欲しいのですが、値を初期化しておくことが出来ません。(ContextがAttachされた後、まあつまりActivityのonCreateまで来たところで無いと、ViewModel自体作成が出来ないからです)

でも、var viewModel: MainViewModel?とすると、nullチェックが大変です。

onCreate通過後ならnullは有り得ないのだから、!!演算子を使う? いえ、先述したとおり、!!演算子は多用すべきではないのです。

そこでlateinitの出番です。呼んで字のとごく、「後で初期化します」という宣言になります。実装上、必ずnull値以外で初期化されることが分かっている、そのタイミングが不定ではなくはっきり分かっている、というようなときに使える修飾子と言えるでしょう。

lateinit varとセットで覚えておくのが良いでしょうね。

で、実際に初期化するのは、onCreateの中、ということになります。

コードはこちらです。


MainActivity.kt

    override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
viewModel.inputStepCount.observe(this, Observer {
textView.text = it.toString()
})

InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
}


viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)の部分は、InputDialogFragmentで使ったときとほぼ同じですね。


MainActivity.kt

viewModel.inputStepCount.observe(this, Observer {

textView.text = it.toString()
})

ここが、LiveDataを監視するコードです。(Observerは、androidx.lifecycle.Observerの方をインポートして下さいね)

関数observeに、1つめの引数は、オブザーバーとしてthis(自分)を渡しています。これにより、MainAcitivyのライフサイクルに準じてくれることになります。2つ目の引数が、コールバックです。ラムダにしていますが、本来は、Observer<T>#onChanged(it:T)が呼ばれています。値が上書きされたとき、何度でもここに通知されます。ActivityがUIを更新できるライフサイクル状態であるときに限って、です。

itというのは、汎用的にラムダで受ける引数として使えるキーワードで、省略が可能です。ちゃんと書くとすると、

    Observer { it ->

textView.text = it.toString()
}

ですが、itだけは宣言しておかなくても暗黙に宣言されていることになっていて、省略してit.toStringのようにいきなり使っても大丈夫と言うことになっています。引数が二つ以上あるときは、素直に全部宣言する必要があります。itだと渡ってくる値の性質が分からないと言うことで、ちゃんと宣言することも勿論可能です。

    Observer { count ->

textView.text = count.toString()
}

textViewは、activity_main.xmlでTextViewにIdを付けておきました。


activity_main.xml

    <TextView

android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:id="@+id/textView"/>

実行してみましょう。

何か数字を入れて登録ボタンを押すと、"Hello World!"だったところが書き換わるはずです。

device-2019-06-06-180957.png


3. テスト

さて、ここまでのテストも書きましょう。

とはいえ、ここで出来ることは、以下の点くらいでしょうか。


  • 起動したときにダイアログが表示されていることのテスト(UIテスト)

  • 登録ボタンを押すと、EditTextの内容がMainActivityに反映されること(UIテスト)


(1) 起動したときにダイアログが表示されているかのテスト

MainActivityのテストなので、MainActivityTest.ktに追加したいところです。

ところが、このテストは、Robolectricだとどういうわけか、上手く通りません。どうやら、Dialog上のViewを取得することがRobolectricの方で出来ないでいるようです。(findFragmentByTagすると、InputDialogFragmentはちゃんといるので・・・)

androidTestであれば同じコードで通るので、ここは妥協して、いったんandroidTestの方にもう一つMainActivityTestI.ktというファイルでも作って対処することにします。

せっかくRobolectricで動かせるようにしていたのに、残念。(IはInstrumentationTestのIのつもり)

一応、ShadowDialogという手法があるそうなのですが、Espressoで書けなくなるので、どちらが良いのか、といったところですね。時間が出来ればShadowDialogを使った手法も調べて追記しておきますが、あまり期待しないで下さい。

どうしても実機を繋いだテストは作りたくない人は、こちら辺りを参考にしてみて下さい。

https://stackoverflow.com/questions/54827801/androidx-test-dialog

https://www.amryousef.me/robolectric-base-test

ということで、androidTest用のdependenciesを復活させておきます。


app/build.gradle


// 追加する
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'

デバイスを準備して、下記のテストを実装します。


MainActivityTestI.kt

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.rule.ActivityTestRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityTestI {
@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)

@Test
fun inputDialogFragmentShown(){
onView(withText(R.string.label_input_title)).check(matches(isDisplayed()))
onView(withText(R.string.label_step)).check(matches(isDisplayed()))
onView(withId(R.id.editStep)).check(matches(isDisplayed()))
onView(withText(R.string.resist)).check(matches(isDisplayed()))
onView(withText(android.R.string.cancel)).check(matches(isDisplayed()))
}
}


やっていることは順番に次の通りです。



  • R.string.label_input_titleの文字列が設定されたViewが表示されているかの確認


  • R.string.label_stepの文字列が設定されたViewが表示されているかの確認


  • R.id.editStepというidのViewが表示されているかの確認


  • R.string.resistの文字列が設定されたViewが表示されているかの確認

実行&Passしましたか?実機で動いていることを確認して下さいね。


(2) 登録ボタンを押すと、EditTextの内容がMainActivityに反映されること

これもandroidTestの方に実装します。


MainActivityTestI.kt

    @Test

fun inputStep(){
onView(withId(R.id.editStep)).perform(replaceText("12345"))
onView(withText(R.string.resist)).perform(click())

onView(withText(R.string.label_input_title)).check(doesNotExist())
onView(withId(R.id.textView)).check(matches(withText("12345")))
}




  • R.id.editStepというidのViewのtextを"12345"をセット


  • R.string.resistという文字列の表示されたViewをクリック


  • R.string.label_input_titleの文字列が設定されたViewが表示されていないのを確認(Dialogが消えたのを確認)

  • MainActivityのR.id.textViewというidのtextが"12345"であることの確認

前回せっかく用意したRobolectricテストですが、今後分散するのもアレなので、androidTestの方に書いていくことにします。

ホントに残念・・・

業務の方でもRobolectricに変更を検討していたけど、無理そうですねえ・・・


まとめ

DialogFragmentを表示し、ViewModelとLiveDataを介してMainActivityとデータをやりとりする方法について学びました。

ここまでの状態のプロジェクトをGithubにpushしてあります。

https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_02


次回予告

今は登録が一回のみで、値も1つしか表示されないので、どんどん値を追加して、リスト表示出来るようにする予定です。使うのはRecyclerViewです。Androidではかなり標準的なレイアウトですので、使い方を覚えておくと役立つと思います。


参考ページなど

ライフサイクルについての参考ページ(再掲)

RobolectricでDialogのテストをするなら参考になるかも知れないページ(再掲)


追記


  • 2019/06/10

Dialogの絡んだテストを、Robolectric向けにも書いてみたので掲載しておきます。

Espressoがやってくれていることを自分でやらなきゃならない感じですね・・・


MainActivityTest.kt


private fun getString(resId: Int) = context.applicationContext.getString(resId)

@Test
fun inputDialogFragmentShown() {
// Robolectricのバグか、DialogのテストはEspressoで行えない
val dialog = ShadowAlertDialog.getLatestDialog() as AlertDialog
assertThat(dialog).isNotNull()

assertThat(dialog.editStep).isNotNull()
assertThat(dialog.label_Title.text).isEqualTo(getString(R.string.label_input_title))
assertThat(dialog.label_step.text).isEqualTo(getString(R.string.label_step))

val negative = dialog.getButton(DialogInterface.BUTTON_NEGATIVE)
assertThat(negative.text).isEqualTo(getString(android.R.string.cancel))
val positive = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
assertThat(positive.text).isEqualTo(getString(R.string.resist))
}

@Test
fun inputStep() {
// Robolectricのバグか、DialogのテストはEspressoで行えない
val dialog = ShadowAlertDialog.getLatestDialog() as AlertDialog
assertThat(dialog.isShowing).isTrue()

dialog.editStep.setText("12345")
val positive = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
positive.performClick()

assertThat(dialog.isShowing).isFalse()

// Dialogが消えた後なのでEspressoでテスト可
onView(ViewMatchers.withId(R.id.textView)).check(matches(withText("12345")))
}


リポジトリにもpushしてあります。