iOSエンジニアのつぶやき

毎朝8:30に iOS 関連の技術について1つぶやいています。まれに釣りについてつぶやく可能性があります。

知識ゼロからの Kotlin Android アプリリリースへの軌跡 / Day16【LiveData and LiveData observers編】

学ぶこと

  • LiveData オブジェクトが役立つ理由
  • ViewModel に保存されているデータに LiveData を追加する方法
  • MutableLiveData をいつどのように使用するか
  • LiveData の変更を監視するための observer method を追加する方法
  • バッキングプロパティを使用して LiveDataカプセル化する方法
  • UI controller とそれに対応する ViewModel の間で通信する方法

すること

  • GuessTheWord アプリの単語とスコアに LiveData を使用する
  • 単語またはスコアの変更を通知する observer を追加します
  • 変更された値を表示する text view を更新します
  • LiveData オブザーバーパターンを使用して、ゲーム終了イベントを追加します
  • Play Again ボタンを実装します

アプリの概要

このコードラボでは、ユーザーがアプリ内の全ての単語を循環した時にゲームを終了するイベントを追加することで、GuessTheWord アプリを改善します。また、score fragment に Play Aganin ボタンを追加して、ユーザがゲームを再度プレイできるようにします。

LiveData を GameViewModel に追加します

LiveData は、ライフサイクルを意識した observable なデータホルダークラスです。例えば、GuessTheWord アプリで現在のスコアの周りに LiveData をラップできます。このコードラボでは、LiveData のいくつかの特性について学習します:

  • LiveData は observable です。つまり、LiveData オブジェクトが保持するデータが変更されると、オブザーバーに通知されます。
  • LiveData はデータを保持します。LiveData は、任意のデータを使用できるラッパーです。
  • LiveData はライフサイクルに対応しています。observer を LiveData にアタッチすると、オブザーバーは LifecycleOwner(通常は Activity or Fragment) に関連付けられます。LiveData は、STARTEDRESUMED などの Active なライフサイクル状態にあるオブザーバーのみを更新します。LiveData と監視について詳しくは、こちら をご覧ください。

このタスクでは、GameViewModel の現在のスコアと現在の単語データを LiveData に変換することにより、任意のデータ型を LiveData オブジェクトにラップする方法を学習します。後のタスクで、これらの LiveData オブジェクトにオブザーバーを追加し、LiveData を監視する方法を学習します。

LiveData を使用するようにスコアと単語を変更します

  1. screens/game パッケージの下で、GameVieModel ファイルを開きます。

  2. scoreword のタイプを MutableLiveData に変更します。

MutableLiveData は、値を変更できる LiveData です。MutableLiveDataジェネリッククラスであるため、保持するデータのタイプを指定する必要があります。

// The current word
val word = MutableLiveData<String>()
// The current score
val score = MutableLiveData<Int>()
  1. GameViewModelinit ブロック内で、scoreword を初期化します。LiveData 変数の値を変更するには、変数で setValue() メソッドを使用します。Kotlin では、value プロパティを使用して setValue() を呼び出すことができます。
init {

   word.value = ""
   score.value = 0
  ...
}

LiveData オブジェクト参照を更新します

scoreword 変数は、LiveData プロパティになりました。このステップでは、value プロパティを使用して、これらの変数への参照を変更します。

  1. GameViewModelonSkip() メソッドないの scorescore.value に変更します。scorenull である可能性があるというエラーに注意してください。次にこのエラーを修正します。

  2. エラーを解決するには、onSkip()score.valuenull チェックを追加します。次に、scoreminus() 関数を呼び出します。この関数は、null で安全に減算を実行します。

fun onSkip() {
   score.value = (score.value)?.minus(1)
   nextWord()
}
  1. onCorrect() メソッドも同様に更新します。scorenull チェックを追加、plus 関数を追加します。
fun onCorrect() {
   score.value = (score.value)?.plus(1)
   nextWord()
}
  1. GameViewModel で、nextWord() メソッド内で、単語参照を word.value に変更します。
private fun nextWord() {
   if (!wordList.isEmpty()) {
       //Select and remove a word from the list
       word.value = wordList.removeAt(0)
   }
}
  1. GameFragmentupdateWordText() メソッド内で、viewModel.word の参照を viewModel.word.value に変更します。
private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.value.toString()
}
  1. GameFragmentupdateScoreText() メソッド内で、viewModel.score の参照を viewModel.score.value に変更します。
private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.value.toString()
}
  1. GameFragmentgameFinished() メソッド内で、viewModel.score の参照を viewModel.score.value に変更します。必要な null 安全性チェックを追加します。
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. コードにエラーがないことを確認してください。アプリをコンパイルして実行します。アプリの機能は以前と同じである必要があります。

Onservers に LiveData オブジェクトをアタッチする

このタスクは、スコアと単語のデータを LiveData オブジェクトに変換した前のタスクと密接に関連しています。このタスクでは、LiveData オブジェクトを Onserver オブジェクトにアタッチします。Fragment view(viewLifecycleOwner) を LifecycleOwner として使用します。

なぜ viewLifecycleOwner を使用するのですか?

Fragment 自体が破棄されていなくても、ユーザーが Fragment から移動すると、Fragment view は破棄されます。これにより、基本的に、Fragment のライフサイクルと Fragment の view のライフサイクルが2つのライフサイクルが作成されます。Fragment view のライフサイクルではなく Fragment のライフサイクルを参照すると、Fragment の view を更新する時に微妙なバグが発生する可能性があります。したがって、Fragment view に影響を与える observer を設定する時は、次のことを行う必要があります:

  1. onCreateView() で observers をセットアップします
  2. viewLifecycleOwner をオブザーバーに渡します

  3. GameFragmentonCreateView() メソッド内で、現在のスコア viewModel.scoreLiveData オブジェクトに Observer オブジェクトをアタッチします。observe() メソッドを使用し、viewModel の初期化後にコードを配置します。ラムダ式を使用してコードを単純化します。(ラムダ式は、宣言されていない無名関数ですが、式としてすぐに渡されます。)

viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
})

Observer への参照を解決します。これを行うには、Observer をクリックし、Option + Enter を押して、androidx.lifecycle.Observer をインポートします。

  1. 作成したばかりの observer は、監視対象の LiveData オブジェクトが保持するデータが変更された時にイベントを受信します。observer 内で、score TextViewnewScore で更新します。
/** Setting up LiveData observation relationship **/
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})
  1. Observer オブジェクトを現在の単語 LiveData オブジェクトにアタッチします。Observer オブジェクトを現在のスコアにアタッチしたのと同じ方法で行います。
/** Setting up LiveData observation relationship **/
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
   binding.wordText.text = newWord
})

score または word の値が変更されると、画面に表示される score または word が自動的に更新されるようになりました。

  1. GameFragment で、updateWordText()updateScoreText()、およびそれらへの全ての参照を削除します。text view は LiveData observer method によって更新されるため、これらはもう必要ありません。

  2. アプリを実行します。ゲームアプリは以前と全く同じように機能するはずですが、現在は LiveData および LiveData observers を使用しています。

LiveData をカプセル化する

カプセル化は、オブジェクトの一部のフィールドへの直接アクセスを制限する方法です。オブジェクトをカプセル化する時は、プライベート内部フィールドを変更する一連のパブリックメソッドを公開します。カプセル化を使用して、他のクラスがこれらの内部フィールドを操作する方法を制御します。

現在のコードでは、外部クラスは、value プロパティを使用して、scoreword 変数を変更できます。このコードラボで開発しているアプリでは問題にならないかもしれませんが、本番アプリでは、ViewModel オブジェクトのデータを制御する必要があります。

ViewModel のみがアプリのデータを編集する必要があります。ただし。UI controller はデータを読み取る必要があるため、データフィールドを完全にプライベートにすることはできません。アプリのデータをカプセル化するには、ModuleLiveData オブジェクトと LiveData オブジェクトの両方を使用します。

MutableLiveData vs LiveData:

  • 名前が示すように、MutableLiveData オブジェクトのデータは変更できます。ViewModel 内では、データを編集可能である必要があるため、MutableLiveData を使用します。
  • LiveData オブジェクトのデータは読み取ることができますが、変更することはできません。ViewModel の外部からは、データは読み取り可能である必要がありますが、編集可能ではないため、データは LiveData として公開する必要があります。

この戦力を実行するには、Kotlin backing property を使用します。backing property を使用すると、正確なオブジェクト以外のゲッターから何かを返すことができます。このタスクでは、GuessTheWord アプリで score オブジェクトと word オブジェクトの backing property を実装します。

score と word に backing property を追加する

  1. GameViewModel で、現在の score オブジェクトを private にします。

  2. backing property で使用される命名規則に従うには、score_score に変更します。_score プロパティは、内部で使用されるゲームスコアの可変バージョンになりました。

  3. score と呼ばれる LiveData タイプのパブリックバージョンを作成します。

// The current score
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
  1. 初期化エラーが表示されます。このエラーは、GameFragment 内で、scoreLiveData 参照であり、スコアがその setter にアクセスできなくなったために発生します。Kotlin の getter と setter の詳細については、Getters and Setters を参照してください。

エラーを解決するには、GameViewModelscore オブジェクトの get() メソッドをオーバーライドし、backing property _score を返します。

val score: LiveData<Int>
   get() = _score
  1. GameViewModel で、スコアの参照を内部の可変バージョン _score に変更します。
init {
   ...
   _score.value = 0
   ...
}

...
fun onSkip() {
   _score.value = (score.value)?.minus(1)
  ...
}

fun onCorrect() {
   _score.value = (score.value)?.plus(1)
   ...
}
  1. score オブジェクトの場合と同様に、word オブジェクトの名前を _word に変更し、その backing property を追加します。
// The current word
private val _word = MutableLiveData<String>()
val word: LiveData<String>
   get() = _word
...
init {
   _word.value = ""
   ...
}
...
private fun nextWord() {
   if (!wordList.isEmpty()) {
       //Select and remove a word from the list
       _word.value = wordList.removeAt(0)
   }
}

game-finished イベント追加する

ユーザが End Game ボタンをタップすると、現在のアプリはスコア画面に移動します。また、プレーヤーが全ての単語を循環した時に、アプリがスコア画面に移動するようにします。プレーヤーが最後の単語を終了した後、ユーザがボタンをタップする必要が内容に、ゲームを自動的に終了する必要があります。

この機能を実装するには、全ての単語が表示された時にイベントがトリガーされ、ViewModel から Fragment に伝達される必要があります。これを行うには、LiveData オブザーバーパターンを使用して、ゲームが終了したイベントをモデル化します。

Observer pattern

observer pattern は software design pattern です。オブジェクト間の通信を指定します: observable(observable の "subject") と observers。observable は、その状態の変化について observer に通知するオブジェクトです。

このアプリの LiveData の場合、Observable("subject") は LiveData オブジェクトであり、obsrvers は Fragment などの UI controllers です。状態の変更は、LiveData 内にラップされたデータが変更されるたびに発生します。LiveData クラスは、ViewModel から Fragment への通信に不可欠です。

LiveData を使用して、ゲームが終了したイベントを検出します

このタスクでは、LiveData オブザーバーパターンを使用して、ゲームが終了したイベントをモデル化します。

  1. GameViewModel で、_eventGameFinish という BooleanMutableLiveData オブジェクトを作成します。このオブジェクトは、ゲーム終了イベントを保持します。

  2. _eventGameFinish オブジェクトを初期化した後、eventGameFinish という backing property を作成して初期化します。

// Event which triggers the end of the game
private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
   get() = _eventGameFinish
  1. GameViewMode で、onGameFinish() メソッドを追加します。このメソッドで、ゲーム終了イベント eventGameFinishtrue に設定します。
/** Method for the game completed event **/
fun onGameFinish() {
   _eventGameFinish.value = true
}
  1. GameViewModelnextWord() メソッド内で、単語リストが空の場合はゲームを終了します。
private fun nextWord() {
   if (wordList.isEmpty()) {
       onGameFinish()
   } else {
       //Select and remove a _word from the list
       _word.value = wordList.removeAt(0)
   }
}
  1. GameFragmentonCreateView() 内で、viewModel を初期化した後、オブザーバーを eventGameFinish にアタッチします。onserver()) メソッドを使用します。ラムダ関数内で、gameFinished() メソッドを呼び出します。
// Observer for the Game finished event
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
   if (hasFinished) gameFinished()
})
  1. アプリを実行し、ゲームをプレイして、全ての単語を確認します。アプリは End Game をタップするまでゲームの断片にとどまるのではなく、自動的にスコア画面に移動します。

単語リストが空になった後、eventGameFinish が設定され、game fragment 内の関連するオブザーバーメソッドが呼び出され アプリは画面 Fragment に移動します。

  1. 追加したコードにより、ライフサイクルの問題が発生しました。この問題を理解するには、GameFragment クラスで、gameFinished() メソッドの navigation コードをコメントアウトします。Toast メッセージを保持するようにしてください。
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. アプリを実行し、ゲームをプレイして、全ての words を確認します。toast メッセージがゲーム画面の下部に短時間表示されます。これは予想される動作です。

次に、デバイスまたはエミュレータを回転させます。Toast がまた表示されます。これはバグです。ゲームが終了すると、Toast は一回だけ表示されるはずだからです。fragment が再作成されるたびに Toast が表示されるわけではありません。この問題は次のタスクで解決します。

ゲーム終了イベントをリセットする

通常、LiveData は、データが更新された場合にのみ observer に更新を配信します。この動作の例外は、observer は非アクティブ状態からアクティブ状態に変化した時にも更新を受信することです。

これが、ゲーム終了の Toast がアプリで繰り返しトリガーされる理由です。画面の回転後に game fragment が再作成されると、game fragment は非アクティブ状態からアクティブ状態に移行します。fragment 内の observer は既存の ViewModel に再接続され、現在のデータを受信します。gameFinished() メソッドが再トリガーされ、Toast が表示されます。

このタスクでは、GameViewModeleventGameFinish フラグをリセットすることにより、この問題を修正し、Toast を一回だけ表示します。

  1. GameViewModel で、onGameFinishComplete() メソッドを追加して、ゲーム終了イベント _eventGameFinish をリセットします。
/** Method for the game completed event **/

fun onGameFinishComplete() {
   _eventGameFinish.value = false
}
  1. GameFragment で、gameFinished() の最後に、viewModel オブジェクトで onGameFinishComplete() を呼び出します。今のところ、gameFinished() のナビゲーションコードはコメントアウトしたままにしておきます。
private fun gameFinished() {
   ...
   viewModel.onGameFinishComplete()
}
  1. アプリを実行してゲームをプレイします。全ての単語をパスし、デバイスの画面の向きを変更します。Toast は一回だけ表示されます。

  2. GameFragmentgameFinished() メソッド内で、navigation code のコメントを解除します。

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()
}

Android Studio からプロンプトが表示されたら、androidx.navigation.fragment.NavHostFragment.findNavController() をインポートします。

  1. アプリを実行してゲームをプレイします。全ての単語を読み終えたら、アプリが自動的に最終スコア画面に移動することを確認してください。

LiveData を ScoreViewModel に追加します

このタスクでは、ScoreViewMode でスコアを LiveData オブジェクトに変更し、それに observer をアタッチします。このタスクは、LiveDataGameViewModel に追加した時に行ったことと似ています。

完全を期すために ScoreViewModel にこれらの変更を加えて、アプリ内の全てのデータが LiveData を使用するようにします。

  1. ScoreViewModel で、スコア変数タイプを MutableLiveData に変更します。慣例により名前を _score に変更し、backing property を追加します。
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
   get() = _score
  1. ScoreViewModelinit ブロック内で、_score を初期化します。必要に応じて、ログを削除するか、init ブロックに残すかを選択します。
init {
   _score.value = finalScore
}
  1. ScoreFragmentonCreateView() 内で、viewModel を初期化した後、スコア LiveData オブジェクトの observer をアタッチします。ラムダ式内で、スコア値を score text view に直接割り当てるコードを ViewModel から削除します。

Code to add:

// Add observer for score
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})

Code to remove:

binding.scoreText.text = viewModel.score.toString()

Android Studio から プロンプトが表示されたら、androidx.lifycycle.Observer をインポートします。

  1. アプリを実行してゲームをプレイします。アプリは以前と同じように機能するはずですが、現在は LiveData とオブザーバーを使用してスコアを更新しています。

Play Again button を追加する

このタスクでは、スコア画面に Play Again ボタンを追加し、LiveData イベントを使用してクリックリスナーを実装します。ボタンは、スコア画面からゲーム画面に移動するイベントをトリガーします。

アプリのスターターコードには Play Again ボタンが含まれていますが、ボタンは非表示になっています。

  1. res/layout/score_fragment.xml で、play_again_button ボタンの場合、visibility attributes の値を visible に変更します。
<Button
   android:id="@+id/play_again_button"
...
   android:visibility="visible"
 />
  1. ScoreViewModel で、_eventPlayAgain という Boolean を保持する LiveData オブジェクトを追加します。このオブジェクトは、LiveData イベントを保存して、スコア画面からゲーム画面に移動するために使用されます。
private val _eventPlayAgain = MutableLiveData<Boolean>()
val eventPlayAgain: LiveData<Boolean>
   get() = _eventPlayAgain
  1. ScoreViewModel で、イベント _eventPlayAgain を設定およびリセットするメソッドを定義します。
fun onPlayAgain() {
   _eventPlayAgain.value = true
}
fun onPlayAgainComplete() {
   _eventPlayAgain.value = false
}
  1. ScoreFragment で、eventPlayAgain のオブザーバーを追加します。onCreateView() の最後、return ステートメントの前にコードを配置します。ラムダ式内で、ゲーム画面に戻り、eventPlayAgain をリセットします。
// Navigates back to game when button is pressed
viewModel.eventPlayAgain.observe(viewLifecycleOwner, Observer { playAgain ->
   if (playAgain) {
      findNavController().navigate(ScoreFragmentDirections.actionRestart())
       viewModel.onPlayAgainComplete()
   }
})

Android Studio のプロンプトが表示されたら、androidx.navigation.fragment.findNavController をインポートします。

  1. ScoreFragmentonCreateView() 内で、クリックリスナーを Play Again ボタンに追加し、viewModel.onPlayAgain() を呼び出します。
binding.playAgainButton.setOnClickListener {  viewModel.onPlayAgain()  }
  1. アプリを実行してゲームをプレイします。ゲームを終了すると、スコア画面に最終スコアと Play Again ボタンが表示されます。Play Again ボタンをタップすると、アプリがゲーム画面に移動し、ゲームを再開できます。

まとめ

LiveData

  • LiveData は、Android Architecture Components の1つである、ライフサイクル対応の observable なデータホルダークラスです。
  • LiveData を使用して、データが更新された時に UI が自動的に更新されるようにすることができます。
  • LiveData は observable です。つまり、LiveData オブジェクトが保持するデータが変更された時に、Activity や Fragment などの observer に通知できます。
  • LiveData はデータを保持します。これは、任意のデータで使用できるラッパーです。
  • LiveData はライフサイクルに対応しています。つまり。STARTEDRESUMED などのアクティブなライフサイクル状態にある observer のみを更新します。

LiveData を追加する

  • ViewModel のデータ変数のタイプを LiveData または MutableLiveData に変更します。

MutableLiveData は、値を変更できる LiveData オブジェクトです。MutableLiveDataジェネリッククラスであるため、保持するデータのタイプを指定する必要があります。

  • LiveData が保持するデータの値を変更するには、LiveData 変数で setValue() メソッドを使用します。

LiveData をカプセル化する

  • ViewModel 内の LiveData は編集可能である必要があります。ViewModel の外部では、LiveData が読み取り可能である必要があります。これは、Kotlin backing property を使用して実装できます。
  • Kotlin backing property を使用すると、正確なオブジェクト以外の何かをゲッターから返すことができます。
  • LiveDataカプセル化するには、ViewModel 内で private MutableLiveData を使用し、ViewModel 外で LiveData backing property を返します。

Observable LiveData

  • LiveData はオブザーバーパターンに従います。"Observable" は LiveData オブジェクトであり、オブザーバーは fragment のような UI controller のメソッドです。LiveData 内にラップされたデータが変更されるたびに、UI controller オブザーバーメソッドに通知されます。
  • LiveData を observable にするには、observe()) メソッドを使用して、オブザーバーオブジェクト(activity や fragment) をオブザーバーの LiveData 参照にアタッチします。
  • LiveData オブザーバーパターンは、ViewModel から、UI controller への通信に使用できます。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com