Android Kotlin基礎講座 05.2: LiveDataとLiveData observers

タスク:ゲーム終了時イベントを追加する

現在アプリはユーザーがEND GAMEボタンをタップすると得点画面に遷移するようになっています。これからはプレイヤーが全ての単語を周回してしまった場合にも得点画面に遷移するようにしていきます。プレイヤーが最後の単語を終了した際に、ユーザーがボタンをタップすることなく、自動でゲームが終了するようにします。

この機能を実装するためには、全ての単語が終わった際にViewModelからフラグメントに対して、情報を伝達し、ゲーム終了の引き金となるイベントが必要になります。それにはLiveDataオブザーバーパターンを使ってゲーム終了イベントをモデリングします。

オブザーバーパターン

オブザーバーパターンはソフトウェアデザインパターンの一つです。これは監視可能なオブジェクト(監視されるオブジェクト)とオブザーバー(監視するオブジェクト)の二つのオブジェクト間での情報伝達についてのパターンです。
監視される側はオブザーバーに自らの状態が変化したことを通知するオブジェクトです。

今回のアプリのLiveDataの場合は、監視されるオブジェクトはLiveDataオブジェクトで、オブザーバーはフラグメントのようなUI controller中のメソッドです。LiveData中にラッピングされたデータに変化があったときに状態変化が起こります。LiveDataクラスはViewModelからフラグメントに情報を伝達する上で重要な役割を担っています。

ステップ1:ゲーム終了イベントを検知するためにLiveDataを使用する

このタスクでは、ゲーム終了イベントをモデリングするためにLiveDataオブザーバーパターンを使います。

  1. GameViewModel中に、Boolean型のMutableLiveDataオブジェクトである_eventGameFinishを作成してください。このオブジェクトはゲーム終了イベントを保持します。
  2. _eventGameFinishオブジェクトを初期化の後に、eventGameFinishというバッキングプロパティの初期化文を作成してください。
// Event which triggers the end of the game
private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
   get() = _eventGameFinish
  1. GameViewModel中にonGameFinish()という目祖度を追加してください。このメソッド中で、_eventGameFinishをtrueに設定してください。
/** Method for the game completed event **/
fun onGameFinish() {
   _eventGameFinish.value = true
}
  1. さらにnextWord()メソッド中に、wordListの中身が空、つまり全ての単語を巡回してしまった場合にonGameFinishを呼び出してゲームを終了させるようにします。
private fun nextWord() {
   if (wordList.isEmpty()) {
       onGameFinish()
   } else {
       //Select and remove a _word from the list
       _word.value = wordList.removeAt(0)
   }
}
  1. GameFragment中のonCreateView()のviewModelの初期化文の後で、ventGameFinishにオブザーバーを取り付ける文を追加してください。observe()メソッドを使います。ラムダ式の中で、gameFinished()メソッドを呼び出します。
// Observer for the Game finished event
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
   if (hasFinished) gameFinished()
})
  1. アプリを起動してゲームをプレイしてください。全ての単語を巡回してください。アプリが自動で得点画面に遷移するはずです。

    単語リストが空になった後、eventGameFinishがセットされ、ゲームフラグメント中の紐づいたオブザーバーメソッドが呼び出されます。そして別画面へと遷移されます。
  2. 先ほど追加したコードはライフサイクルに関する問題を含んでいました。この問題を理解するために、GameFragmentクラスのgameFinished()メソッド中のナビゲーションに関するコードをコメントアウトしてください。メソッド中のトーストメッセージはそのままにしておいてください。
private fun gameFinished() {
       Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
//        val action = GameFragmentDirections.actionGameToScore()
//        action.score = viewModel.score.value?:0
//        NavHostFragment.findNavController(this).navigate(action)
   }
  1. 再度アプリを起動し、全ての単語を巡回させてください。”Game has just finished”というトーストメッセージが画面下部に表示されます。

そこで実機またはエミュレーターを回転させてください。トーストが再び表示されます。もう数回画面を回転させてみてください。毎回トーストが表示されるはずです。これはバグです。なぜならトーストはゲーム終了時に一度のみ表示されるべきだからです。トーストはフラグメントが再生成される度に表示されるべきではありません。次のタスクでこの問題を解決します。

ステップ2:ゲーム終了時イベントをリセットする

通常、LiveDataはデータが変化した場合にのみオブザーバーに更新を知らせます。この挙動の例外はオブザーバーが非アクティブ状態からアクティブ状態に変化した際にも更新を受け取ってしまうという点です。

これが原因でゲーム終了時のトーストがアプリ内で何度も引き起こされていたのです。ゲームフラグメントが画面回転の後で再生成される際、非アクティブからアクティブ状態になります。フラグメントのオブザーバーは存在するViewModelに再接続され、現在のデータを受け取ります。gameFinished()メソッドが再び呼び出され、トーストが表示されるのです。

このタスクでは、GameViewModel内のeventGameFinishフラグをリセットすることで、この問題を修正し、トーストを一度だけ表示するようにします。

  1. GameViewModel中にゲーム終了時イベントをリセットするためのonGameFinishComplete()メソッドを追加してください。
/** Method for the game completed event **/

fun onGameFinishComplete() {
   _eventGameFinish.value = false
}
  1. GameFragmentのgameFinished()の最後で、onGameFinishComplete()をviewModelオブジェクト上で呼び出してください。(まだナビゲーションコードのコメントアウトはそのままにしておいてください)
private fun gameFinished() {
   ...
   viewModel.onGameFinishComplete()
}
  1. アプリを起動しゲームをプレイしてください。全ての単語を巡回した後に画面の向きを変えてください。トーストは一度しか表示されないはずです。
  2. GameFragment中の、gameFinished()メソッドの中のナビゲーションコードのコメントアウトを解除してください。

    Android Studioでコメントアウトを解除するには、コメントアウトされているコードを選択した状態でControl + /(Macの場合Command+/)を押します。
private fun gameFinished() {
   Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
   val action = GameFragmentDirections.actionGameToScore()
   action.score = viewModel.score.value?:0
   findNavController(this).navigate(action)
   viewModel.onGameFinishComplete()
}

androidx.navigation.fragment.NavHostFragment.findNavControllerをインポートするように促された場合は、インポートしてください。

  1. アプリを起動し、ゲームをプレイしてください。全ての単語を巡回した後で得点画面に遷移することを確認してください。

これでアプリは単語リストが空になったことをGameViewModelからゲームフラグメントに伝達するためのゲーム終了時イベントを引き起こすLiveDataを使用するようになりました。その後ゲームフラグメントは得点フラグメントに遷移します。