Android Kotlin Fundamentalsで学ぶ その5
はじめに
この記事はGoogleが提供しているCodelabの中のAndroidを作りながら学ぶAndroid Kotlin Fundamentalsコースで学習した内容を自分用に残していくものです。間違っていることなどあればコメントをいただけるとありがたいです!
この記事について
その5では、Lesson5について残していきます。
このレッスンでは、ViewModel
とLiveData
の使い方について学びます。ViewModel
オブジェクトを使って、画面の回転などの構成の変更にデータを耐えられるようにします。またUIデータをカプセル化されたLiveData
に変換し、変更があった際に通知されるObserver
についても学びます。
また、ViewModel
とLiveData
をDataBinding
と統合してアプリのFragment
を使わず直接レイアウトとViewModel
オブジェクトが通信できることで、コードの簡素化やUIを単純化することができます。
ViewModel
5-1ここでは、GuessTheWord
アプリを使って学習を進める。
このアプリの問題点は、画面の回転や強制終了後の再起動でデータが初期化してしまうことである。これは、Lesson4
で行ったようにonSaveInstanceState
メソッドを用いて解決することができるが、バンドルデータに保存する際、データのサイズが大きいとエラーが起きてしまう問題がある。そこで、今回学習することは、データの保存、復元をMVVM(Model-View-ViewModel)
というアーキテクチャを使って解決すること。
今回作成するアプリは、
- UI controller
- ViewModel
- ViewModelFactory
これらのクラスを作成する。
UI controller
アクティビティやフラグメントのUIのベースクラスとなる。
ビューの表示やユーザ入力のキャプチャなど、UIとオペレーティングシステムの相互作用を処理するロジックを実装。
UIコントローラーとして扱うのは、
GameFragment
ScoreFragment
-
TitleFragment
の3つである。
ユーザがそれらのフラグメント内のボタンを押すと、GameViewModel
に情報が渡される。そのため、UIコントローラでは、それ以上の操作はしないようにする必要がある。
ViewModel
アクティビティやフラグメントに関連づけられた表示用データを保持しつつ、UIコントローラによって表示されるデータの準備をおこなう。
例えば、GameFragment
に対して、GameViewModel
でスコアや単語リストのデータを保持、簡単な計算のようなビジネスロジックを実行する。
ViewModelFactory
ViewModelオブジェクトをインスタンス化する。
GameViewModel作成の流れ
- ViewModelクラスの追加
dependencies{
...
// 追記
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
}
- ViewModelクラスの作成
Viewと同じディレクトリにViewModelを作成する。今回はGameFragment.kt
(view)に対応するGameViewModel.kt
(ViewModel)を作成。
...
import androidx.lifecycle.ViewModel
class GameViewModel: ViewModel(){ // ViewModelをインプリメントする
init{
/* ViewModelの初期化時に呼び出される。チェック用 */
Log.i("GameViewModel", "1. GameViewModel created!")
}
/* フラグメントが破棄された時に呼び出される。チェック用 */
override fun onCleared(){
super.onCleared()
Log.i("GameViewModel", "2. GameViewModel destroyed!")
}
- ViewとViewModelを関連づける
class GameFragment: Fragment(){
...
// 追記
private lateinit var viewModel: GameViewModel
override fun onCreateView(...){
...
- ViewModelの初期化
onCreateView(...){
...
/* フラグメント作成時に呼び出される。チェック用 */
Log.i("GameFragment", "3. Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
...
}
実行して挙動を見てみる。動きと呼び出しを対応づけて表すと、
アプリ開始 | GameFragmentへ遷移 | 画面の回転 | 画面の回転 | 強制終了 |
---|---|---|---|---|
なし | 3 → 1 | 3 | 3 | 2 |
見づらいですがこんな感じに動いた。
画面の回転(構成の変更)でフラグメント(UIコントローラ)の再構築(1)があるが、そのフラグメントに対応したViewModelは再構築されなかった(2)。ViewModelのインスタンスは破棄されずに残ること、vIewModelProviderではViewModelのインスタンスがあれば既存のものを使い、なければ新規作成をすることから画面の変更でViewModelの呼び出し(2)はなかった。
ViewModelにデータを入力する
上でも書いた通り、ViewModelにデータを配置することで、構成変更があってもデータが保持される。そこで、GameFragment.kt
にあった変数や処理をGameViewModel.kt
へ移動させる。
class GameViewModel: ViewModel(){
/* 変数の定義 */
var word = ""
var score = 0
private lateinit var wordList: MutableList<String>
private fun resetList(){
wordList = mutableListOf(
"queen",
...
)
wordList.shuffle()
}
init{
resetList()
nextWord()
}
/* 処理の定義 */
private fun nextWord(){ ... }
fun onSkip() { ... }
fun onCorrect() { ... }
...
}
class GameFragment: Fragment(){
/* 変数定義以外そのまま */
private lateinit var binding: GameFragmentBinding
private lateinit var viewModel: GameViewModel
override fun onCreateView(...){...}
/* 処理はViewModelから呼び出す */
private fun onSkip() {
viewModel.onSkip()
...
}
private fun onCorrect() {
viewModel.onCorrect()
...
}
/* 変数'word', 'score' はViewModelからとってくる */
private fun updateWordText() {
binding.wordText.text = viewModel.word
}
private fun updateScoreText() {
binding.scoreText.text = viewModel.score.toString()
}
}
ゲーム終了処理
-
End
ボタンにScoreFragment
へ遷移させるようNavigateする。
private fun gameFinished() {
// GameFragment→ScoreFragment
val action = GameFragmentDirections.actionGameToScore()
// スコアを追加する
action.score = viewModel.score
// Navigationで遷移させる
NavHostFragment.findNavController(this).navigate(action)
}
-
ScoreFragment
で表示を行う
scoreディレクトリでは、こんな感じのファイル構成になる
├── ScoreFragment.kt
├── ScoreViewModel.kt
└── ScoreViewModelFactory.kt
ViewModelFactory
はオブジェクト作成のデザインパターンの一つで、ViewModel
をインスタンス化する
class ScoreViewModel(finalScore: Int) : ViewModel() {
// The final score
var score = finalScore
}
/* ViewModelProvider.Factoryを継承 */
class ScoreViewModelFactory(private val finalScore: Int) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if(modelClass.isAssignableFrom(ScoreViewModel::class.java)){
return ScoreViewModel(finalScore) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
- ScoreFragmentでViewModel, ViewModelFactoryを扱う
class ScoreFragment : Fragment() {
private lateinit var viewModel: ScoreViewModel
private lateinit var viewModelFactory: ScoreViewModelFactory
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate view and obtain an instance of the binding class.
val binding: ScoreFragmentBinding = DataBindingUtil.inflate( ... )
/* ViewModelFactoryを初期化し、バンドルからscoreを取得し、引数にする */
viewModelFactory = ScoreViewModelFactory(ScoreFragmentArgs.fromBundle(requireArguments()).score)
/* ViewModelの初期化をし、getメソッドでViewModelFactoryを渡すことでViewModelオブジェクトが作成される */
viewModel = ViewModelProvider(this, viewModelFactory)
.get(ScoreViewModel::class.java)
binding.scoreText.text = viewModel.score.toString()
return binding.root
}
}
ここで、Factory
パターンとは、スーパークラスでインスタンスの作り方を管理して、サブクラスで具体的な処理を行うパターンのことである。(Android Architecture Blueprints ~ ViewModelでFactoryパターン ~参照)
要はView-ViewModel
の管理を簡単にするためのもの
LiveData and LiveData observers
5-2
LiveData
LiveData
とは、ライフサイクルを意識した監視可能なデータホルダークラスである。
特徴として、
- LiveDataオブジェクトに保持してるデータに変更があれば、オブザーバに通知される
- LiveDataはライフサイクルに対応している
- アクティブなライフサイクル状態にあるオブザーバのみを更新する
LiveDataに定義変更する
val word = ""
val score = 0
↓↓↓
val word = MutableLiveData<String>()
val score = MutableLiveData<Int>()
init{
word.value = ""
score.value = 0
...
}
こんな感じで、.value
いわゆるsetValue()
メソッドで値を変更する。そのため、同じファイル内のword
, score
の値の変更する関数も同時にsetValue()
にする。
オブザーバをLiveDataに紐づける
ビューをLifecycleOwnerとして使用する
...
override fun onCreateView(...){
...
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
binding.scoreText.text = newScore.toString()
})
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
binding.wordText.text = newWord
})
...
こんな感じでlambda式で記述する
例えば、score
では、監視していた値が変更されたらnewScore
へ代入され、bindingを使って(データバインディングされているから)scoreText
へ変更後の値を代入、表示している。そのため、updateScoreText
とupdateWordText
はいらなくなった。便利
カプセル化
カプセル化とはオブジェクトのアクセスを制限する方法。
上の方法だと、外部クラスはviewModel.score.value
を使用することでscoreの値を変更できた。そのため、データのアクセスを制限する必要がある。制限する方法は、MutableLiveData
とLiveData
をうまく使うこと。
- MutableLiveData: データの編集が可能(いじるだけ)
- LiveData: 読み取り専用(見るだけ)
MutableLiveDataは不用意な編集をされてはいけないので、ViewModelの外部への公開(値の取得)はLiveDataで行う必要がある。
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
get() = _score
init{
...
_score.value = 0
...
}
こんな感じでカプセル化できる
終了ボタン・処理
END GAME
ボタンを押すとScoreFragment
へ遷移する。その時、ゲームが終了しているかどうかをフラグで管理する。
private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
get() = _eventGameFinish
...
// END GAMEボタンを押したら
fun onGameFinish() {
_eventGameFinish.value = true
}
private fun nextWord() {
if (wordList.isEmpty()) { // 全問終えたら
onGameFinish()
} else {
_word.value = wordList.removeAt(0)
}
}
override fun onCreateView(...){
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
if (hasFinished) gameFinished()
})
...
}
ただ、このままだと、構成の変更によってフラグメントが再構築されるため、変更のたびに終了の処理をしてしまう(gameFinished()関数が呼び出される)。
これは、LiveDataがデータが変更された時に呼び出されるだけでなく、オブザーバが非アクティブからアクティブ状態に変化した時にも更新を受け取るためである。
そこで、終了フラグをリセットして上げる必要がある。つまり、gameFinished
関数でフラグをリセットさせる。
fun onGameFinishComplete(){
_eventGameFinish.value = false
}
private fun gameFinished(){
...
viewModel.onGameFinishedComplete()
}
今回使ったオブザーバパターンはこちらがとてもわかりやすかっです。
Data binding with ViewModel and LiveData
5-3現状のアプリでは、Views
(xmlファイル)で扱うデータはViewModel
で保持しているが、UI controller
(アクティビティやフラグメント)を介してデータの受け渡し、処理を行なっている。
(codelabsより)
それを、
こんな感じで、UI controller
を介さず通信できたらもっとシンプルになる
ViewModelのデータバインディングを加える
まず、レイアウトファイルにViewModelのデータバインディング変数を追加する。
<layout ...>
<data>
<variable
name="gameViewModel"
type="com.example.android.guesstheword.screens.game.GameViewModel" />
</data>
...
ここで一度Build->Clean Project
してから実行するといいらしい
そして、GameFragment
内でさっき定義したgameViewModel
にviewModel
を割り当てる
override fun onCreateView(...){
...
binging.gameViewModel = viewModel
...
}
そして、イベントリスナーをバインディングする。その際、データバインディングがリスナーを作成し、ビューへリスナーを設定する。また、ラムダ式で扱う。
<Button
android:id="@+id/skip_button"
...
android:onClick="@{() -> gameViewModel.onSkip()}" // ラムダ式
... />
<Button
android:id="@+id/correct_button"
...
android:onClick="@{() -> gameViewModel.onCorrect()}"
... />
<Button
android:id="@+id/end_game_button"
...
android:onClick="@{() -> gameViewModel.onGameFinish()}"
... />
そして、GameFragment
内のイベントリスナの設定を削除する
binding.correctButton.setOnClickListener { onCorrect() }
binding.skipButton.setOnClickListener { onSkip() }
binding.endGameButton.setOnClickListener { onEndGame() }
/** Methods for buttons presses **/
private fun onSkip() {
viewModel.onSkip()
}
private fun onCorrect() {
viewModel.onCorrect()
}
private fun onEndGame() {
gameFinished()
}
同様の書き換えをScore
でも行う。
リスナーバインディングはメソッドにView以外のデータを渡すことができる。便利
LiveDataをデータバインディングに加える
これまではLiveDataで変数を監視し、変更があればデータバインディングを使ってUIを変更していた。今回は、LiveDataオブジェクト自体をデータバインディングソースとして使用することで、オブザーバメソッドを使わずにUIを変更することができる。
- game_fragment.xmlのword, scoreのtextにLiveDataオブジェクトを設定する
// word
<TextView
android:id="@+id/word_text"
...
android:text="@{gameViewModel.word}"
... />
// score
<TextView
android:id="@+id/score_text"
...
android:text="@{String.valueOf(scoreViewModel.score)}"
... />
*scoreの変数はint型だからString型に変更すことを忘れない
- データバインディングのライフサイクルオーナーを設定する
binding.scoreViewModel = ...
binding.lifecycleOwner = viewLifecycleOwner
- オブザーバーメソッドの定義を削除
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
binding.scoreText.text = newScore.toString()
})
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
binding.scoreText.text = newScore.toString()
})
string.xmlで変数のフォーマットも定義できる
<string name="quote_format">\"%s\"</string>
<string name="score_format">Current Score: %d</string>
- %s: 文字列
- %d: 数値
フォーマットを定義することでtoString()
やString.valueOf()
のようなフォーマットを変換する処理を追加しなくても良い
// word
<TextView
android:id="@+id/word_text"
...
android:text="@{@string/quote_format(gameViewModel.word)}"
... />
// score
<TextView
android:id="@+id/score_text"
...
android:text="@{@string/score_format(scoreViewModel.score)}"
... />
LiveData transformations
5-4タイマーの設定
Android開発ではCountDownTimer
というクラスが用意されているため、これを使う。
- タイマーで使う定数を定義
今回はこのような時間で行う。
companion object {
private const val DONE = 0L
private const val ONE_SECOND = 1000L
private const val COUNTDOWN_TIME = 60000L
}
- タイマーの定義と初期化
// タイマーの設定
private val timer: CountDownTimer
// 初期化
init {
...
// タイマーの初期化
// 合計時間: COUNTDOWN_TIME, 間隔: ONE_SECOND を渡す
timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {
override fun onTick(millisUntilFinished: Long) {
// ティック(今回は1秒)ごとに呼び出される, _currentTimeを更新
_currentTime.value = millisUntilFinished / ONE_SECOND
Log.d("GameViewModel", "_currentTime: " + _currentTime.value.toString())
}
// タイマーが終了したら呼び出される, ゲーム終了
override fun onFinish() {
_currentTime.value = DONE
onGameFinish()
}
}
timer.start()
}
- 終了処理
メモリリーク回避のために終了処理が必要
// Right before the ViewModel is destroyed, callback this method
override fun onCleared() {
super.onCleared()
// メモリリーク回避のためにタイマーを削除
timer.cancel()
Log.i("GameViewModel", "2. GameViewModel destroyed!")
}
実行中はこんな感じでonTick
が呼び出される。
タイマーを表示
タイマーを表示形式に変換するためにTransformations.map()
メソッドを使う。
変換前(currentTime): 60
を変換後(currentTimeString): MM:SS
にする。
// currentTime定義後
val currentTimeString = Transformations.map(currentTime){ time ->
DateUtils.formatElapsedTime(time)
}
currentTimeString
はLiveDataに保存されるので、これを使ってビューに表示
<TextView
android:id="@+id/timer_text"
...
android:text="@{gameViewModel.currentTimeString}"
... />
まとめ
今回かなり長めになったので、大雑把ですがデータバインディングとViewModelによる管理の流れをまとめます。
- データバインディングとViewModelを紐付ける
<data>
<variable
name="gameViewModel"
type="com.example.android.guesstheword.screens.game.GameViewModel" />
</data>
- バインディングにViewModelを渡す
binding.gameViewModel = viewModel
- イベントがあればイベントリスナーをバインディング式にする
android:onClick="@{() -> gameViewModel.onSkip()}"
- LiveDataをデータバインディングへ追加
android:text="@{gameViewModel.word}"
これでUIを自動で更新できる - ライフサイクルオーナーを設定
binding.lifecycleOwner = this
- レイアウトファイルの変数をLiveDataオブジェクトから引っ張る
android:text="@{@string/quote_format(gameViewModel.word)}"
今回は、ViewModel
やLiveData
のようなライフサイクルを意識したデザインパターンについて学習していきました。デザインパターンはアプリケーションを作成する上で必要となる考え方なので今回学んだViewModelを使ったMVVMやMVCといったパターンをしっかり抑えていきたいと思いました。また、これをしっかり抑えることができれば、ライフサイクルや変数の流れが見えてくるのかなと思いました。
今回はコードベースで長くなってしまって申し訳ございません。。。何か間違っていることなどがあればコメントいただけると嬉しいです。今回も読んでいただきありがとうございました!
Discussion