Android Kotlin Fundamentalsで学ぶ その5

17 min読了の目安(約16100字TECH技術記事

はじめに

この記事はGoogleが提供しているCodelabの中のAndroidを作りながら学ぶAndroid Kotlin Fundamentalsコースで学習した内容を自分用に残していくものです。間違っていることなどあればコメントをいただけるとありがたいです!

この記事について

その5では、Lesson5について残していきます。
このレッスンでは、ViewModelLiveDataの使い方について学びます。ViewModelオブジェクトを使って、画面の回転などの構成の変更にデータを耐えられるようにします。またUIデータをカプセル化されたLiveDataに変換し、変更があった際に通知されるObserverについても学びます。
また、ViewModelLiveDataDataBindingと統合してアプリのFragmentを使わず直接レイアウトとViewModelオブジェクトが通信できることで、コードの簡素化やUIを単純化することができます。

5-1 ViewModel

ここでは、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オブジェクトをインスタンス化する。
Imgur

GameViewModel作成の流れ

  1. ViewModelクラスの追加
build.gradle(app)
dependencies{
    ...
    // 追記
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
}
  1. ViewModelクラスの作成
    Viewと同じディレクトリにViewModelを作成する。今回はGameFragment.kt(view)に対応するGameViewModel.kt(ViewModel)を作成。
GameViewModel.kt
...
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!")
    }
  1. ViewとViewModelを関連づける
GameFragment.kt
class GameFragment: Fragment(){
    ...
    // 追記
    private lateinit var viewModel: GameViewModel
    
    override fun onCreateView(...){
    ...
    
  1. ViewModelの初期化
GameFragment.kt
    onCreateView(...){
        ...
	/* フラグメント作成時に呼び出される。チェック用 */
        Log.i("GameFragment", "3. Called ViewModelProvider.get")
        viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
	...
    }

実行して挙動を見てみる。動きと呼び出しを対応づけて表すと、

アプリ開始 GameFragmentへ遷移 画面の回転 画面の回転 強制終了
なし 3 → 1 3 3 2

Imgur
見づらいですがこんな感じに動いた。

画面の回転(構成の変更)でフラグメント(UIコントローラ)の再構築(1)があるが、そのフラグメントに対応したViewModelは再構築されなかった(2)。ViewModelのインスタンスは破棄されずに残ること、vIewModelProviderではViewModelのインスタンスがあれば既存のものを使い、なければ新規作成をすることから画面の変更でViewModelの呼び出し(2)はなかった。

ViewModelにデータを入力する

上でも書いた通り、ViewModelにデータを配置することで、構成変更があってもデータが保持される。そこで、GameFragment.ktにあった変数や処理をGameViewModel.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() { ... }
    
    ...
}
GameFragment.kt
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()
    }
}

ゲーム終了処理

  1. EndボタンにScoreFragmentへ遷移させるようNavigateする。
GameFragment.kt
private fun gameFinished() {
   // GameFragment→ScoreFragment
   val action = GameFragmentDirections.actionGameToScore()
   // スコアを追加する
   action.score = viewModel.score
   // Navigationで遷移させる
   NavHostFragment.findNavController(this).navigate(action)
}
  1. ScoreFragmentで表示を行う
    scoreディレクトリでは、こんな感じのファイル構成になる
├── ScoreFragment.kt
├── ScoreViewModel.kt
└── ScoreViewModelFactory.kt

ViewModelFactoryはオブジェクト作成のデザインパターンの一つで、ViewModelをインスタンス化する

ScoreViewModel.kt
class ScoreViewModel(finalScore: Int) : ViewModel() {
   // The final score
   var score = finalScore
}
ScoreViewModelFactory.kt
/* 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")
    }
}

  1. ScoreFragmentでViewModel, ViewModelFactoryを扱う
ScoreFragment.kt
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の管理を簡単にするためのもの

5-2 LiveData and LiveData observers

LiveData

LiveDataとは、ライフサイクルを意識した監視可能なデータホルダークラスである。
特徴として、

  • LiveDataオブジェクトに保持してるデータに変更があれば、オブザーバに通知される
  • LiveDataはライフサイクルに対応している
  • アクティブなライフサイクル状態にあるオブザーバのみを更新する

LiveDataに定義変更する

GameViewModel.kt
val word = ""
val score = 0

↓↓↓

GameViewModel.kt
val word = MutableLiveData<String>()
val score = MutableLiveData<Int>()

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

こんな感じで、.valueいわゆるsetValue()メソッドで値を変更する。そのため、同じファイル内のword, scoreの値の変更する関数も同時にsetValue()にする。

オブザーバをLiveDataに紐づける

ビューをLifecycleOwnerとして使用する

GameFragment.kt
...
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へ変更後の値を代入、表示している。そのため、updateScoreTextupdateWordTextはいらなくなった。便利

カプセル化

カプセル化とはオブジェクトのアクセスを制限する方法。
上の方法だと、外部クラスはviewModel.score.valueを使用することでscoreの値を変更できた。そのため、データのアクセスを制限する必要がある。制限する方法は、MutableLiveDataLiveDataをうまく使うこと。

  • MutableLiveData: データの編集が可能(いじるだけ)
  • LiveData: 読み取り専用(見るだけ)
    MutableLiveDataは不用意な編集をされてはいけないので、ViewModelの外部への公開(値の取得)はLiveDataで行う必要がある。
GameViewModel.kt
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
    get() = _score
init{
    ...
    _score.value = 0
    ...
}

こんな感じでカプセル化できる

終了ボタン・処理

END GAMEボタンを押すとScoreFragmentへ遷移する。その時、ゲームが終了しているかどうかをフラグで管理する。

GameViewModel.kt
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)
   }
}
GameFragment.kt
override fun onCreateView(...){
    viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
        if (hasFinished) gameFinished()
    })
   ...
}

ただ、このままだと、構成の変更によってフラグメントが再構築されるため、変更のたびに終了の処理をしてしまう(gameFinished()関数が呼び出される)。
これは、LiveDataがデータが変更された時に呼び出されるだけでなく、オブザーバが非アクティブからアクティブ状態に変化した時にも更新を受け取るためである。
そこで、終了フラグをリセットして上げる必要がある。つまり、gameFinished関数でフラグをリセットさせる。

GameViewModel.kt
    fun onGameFinishComplete(){
        _eventGameFinish.value = false
    }
GameFragment.kt
    private fun gameFinished(){
         ...
	 viewModel.onGameFinishedComplete()
    }

今回使ったオブザーバパターンはこちらがとてもわかりやすかっです。

5-3 Data binding with ViewModel and LiveData

現状のアプリでは、Views(xmlファイル)で扱うデータはViewModelで保持しているが、UI controller(アクティビティやフラグメント)を介してデータの受け渡し、処理を行なっている。
Imgur(codelabsより)

それを、
Imgur
こんな感じで、UI controllerを介さず通信できたらもっとシンプルになる

ViewModelのデータバインディングを加える

まず、レイアウトファイルにViewModelのデータバインディング変数を追加する。

game_fragment.xml
<layout ...>
    <data>
        <variable
	     name="gameViewModel"
	     type="com.example.android.guesstheword.screens.game.GameViewModel" />
    </data>
    ...

ここで一度Build->Clean Projectしてから実行するといいらしい
そして、GameFragment内でさっき定義したgameViewModelviewModelを割り当てる

GameFragment.kt
override fun onCreateView(...){
    ...
    binging.gameViewModel = viewModel
    ...
}

そして、イベントリスナーをバインディングする。その際、データバインディングがリスナーを作成し、ビューへリスナーを設定する。また、ラムダ式で扱う。

game_fragment.xml
<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内のイベントリスナの設定を削除する

GameFragment.kt
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を変更することができる。

  1. game_fragment.xmlのword, scoreのtextにLiveDataオブジェクトを設定する
game_fragment.xml
    // 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型に変更すことを忘れない

  1. データバインディングのライフサイクルオーナーを設定する
GameFragment.kt
    binding.scoreViewModel = ...
    binding.lifecycleOwner = viewLifecycleOwner
  1. オブザーバーメソッドの定義を削除
GameFragment.kt
    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.xml
<string name="quote_format">\"%s\"</string>
<string name="score_format">Current Score: %d</string>
  • %s: 文字列
  • %d: 数値

フォーマットを定義することでtoString()String.valueOf()のようなフォーマットを変換する処理を追加しなくても良い

game_fragment.xml
    // 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)}"
   ... />

5-4 LiveData transformations

タイマーの設定

Android開発ではCountDownTimerというクラスが用意されているため、これを使う。

  1. タイマーで使う定数を定義
    今回はこのような時間で行う。
GameViewModel.kt
    companion object {
        private const val DONE = 0L
        private const val ONE_SECOND = 1000L
        private const val COUNTDOWN_TIME = 60000L
    }
  1. タイマーの定義と初期化
GameViewModel.kt
    // タイマーの設定
    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()
    }
  1. 終了処理
    メモリリーク回避のために終了処理が必要
GameViewModel.kt
    // Right before the ViewModel is destroyed, callback this method
    override fun onCleared() {
        super.onCleared()
        // メモリリーク回避のためにタイマーを削除
        timer.cancel()
        Log.i("GameViewModel", "2. GameViewModel destroyed!")
    }

実行中はこんな感じでonTickが呼び出される。
Imgur

タイマーを表示

タイマーを表示形式に変換するためにTransformations.map()メソッドを使う。
変換前(currentTime): 60を変換後(currentTimeString): MM:SSにする。

GameViewModel.kt
    // currentTime定義後
    val currentTimeString = Transformations.map(currentTime){ time ->
        DateUtils.formatElapsedTime(time)
    }

currentTimeStringはLiveDataに保存されるので、これを使ってビューに表示

game_fragment.xml
<TextView
   android:id="@+id/timer_text"
   ...
   android:text="@{gameViewModel.currentTimeString}"
   ... />

まとめ

今回かなり長めになったので、大雑把ですがデータバインディングとViewModelによる管理の流れをまとめます。

  1. データバインディングとViewModelを紐付ける
***.xml
   <data>

       <variable
           name="gameViewModel"
           type="com.example.android.guesstheword.screens.game.GameViewModel" />
   </data>
  1. バインディングにViewModelを渡す
    binding.gameViewModel = viewModel
  2. イベントがあればイベントリスナーをバインディング式にする
    android:onClick="@{() -> gameViewModel.onSkip()}"
  3. LiveDataをデータバインディングへ追加
    android:text="@{gameViewModel.word}"
    これでUIを自動で更新できる
  4. ライフサイクルオーナーを設定
    binding.lifecycleOwner = this
  5. レイアウトファイルの変数をLiveDataオブジェクトから引っ張る
    android:text="@{@string/quote_format(gameViewModel.word)}"

今回は、ViewModelLiveDataのようなライフサイクルを意識したデザインパターンについて学習していきました。デザインパターンはアプリケーションを作成する上で必要となる考え方なので今回学んだViewModelを使ったMVVMやMVCといったパターンをしっかり抑えていきたいと思いました。また、これをしっかり抑えることができれば、ライフサイクルや変数の流れが見えてくるのかなと思いました。

今回はコードベースで長くなってしまって申し訳ございません。。。何か間違っていることなどがあればコメントいただけると嬉しいです。今回も読んでいただきありがとうございました!