Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: ViewModel 부터.

Useful Shortcuts

App architecture

Follow Android app architecture guidlines and Android Architecture Components. Similarly MVVM.

ViewModel

ViewModel class to store and manage UI-releated data in a lifecycle-conscious way. (safe for device-configuration changes via ViewModelFactory)

Add dependencies

// build.gradle (app-level)
dependencies {
  // ...

  //ViewModel
  implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
}

Then, sync.

Create GameViewModel in screens/game/ folder.

class GameViewModel : ViewModel() {
  init {
    Log.i("GameViewModel", "GameViewModel created!")
  }

  override fun onCleared() {
    super.onCleared()
    Log.i("GameViewModel", "GameViewModel destoryed!")
  }
}

Associate GameViewModel with the game fragment.

// in GameFragment.kt
private lateinit var viewModel: GameViewModel

ViewModelProvider create the ViewModel instance so that the view model survives during the configuration changes. The provider manages the lifecycle of the view model until the UI is destroyed.

Initialize the viewModel using ViewModelProvider.get().

Log.i("GameFragment", "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)

Remember,

Move all states from Fragment to ViewModel.

Pass data to next fragments.

private fun gameFinished() {
  Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
  val action = GameFragmentDirections.actionGameToScore()
  action.score = viewModel.score
  NavHostFragment.findNavController(this).navigate(action)
}

However, ViewModel is destoryed during the transition. It needs ViewModelFactory for that.

ViewModelFactory

Create ScoreViewModel.

class ScoreViewModel(finalScore: Int): ViewModel() {
  var score = finalScore
  init {
    Log.i("ScoreViewModel", "Final score is $finalScore")
  }
}

Create ScoreViewFactory and override the create() method.

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

Add members in the fragment.

private lateinit var viewModel: ScoreViewModel
private lateinit var viewModelFactory: ScoreViewModelFactory

Initialize viewModel using the factory from onCreateView().

// in `onCreateView()`
// init factory with given arguments via navigation
viewModelFactory = ScoreViewModelFactory(ScoreFragmentArgs.fromBundle(requireArguments()).score)
// get view model from the provider via factory
viewModel = ViewModelProvider(this, viewModelFactory)
  .get(ScoreViewModel::class.java)

Then, the fragment uses data from the viewModel.

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

Parameterized constructor of viewModel is useful when the data should be right during initialization.

LiveData

Data objects that notify views when the underlying database changes. Set up "observers" to the activities or fragments. Also it is lifecycle-aware and watch only active ones.

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

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

Update object references. minus() and plus() are null-safety manipulation.

// score--
score.value = (score.value)?.minus(1)

// score++
score.value = (score.value)?.plus(1)

// word = wordList.removeAt(0)
word.value = wordList.removeAt(0)

References in the fragment needs to update too.

// binding.wordText.text = viewModel.word
binding.wordText.text = viewModel.word.value

// action.score = viewModel.score
action.score = viewModel.score.value?:0

Attach observers to the LiveData objects

Note: fragment's lifecycle and fragment view's lifecycle is a bit different. Always set the observers in onCreateView() and pass viewLifecycleOwner to observers.

// in onCreateView()

viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
  binding.scoreText.text = newScore.toString()
})
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
  binding.wordText.text = newWord.toString()
})

Then, remove updateWordText() and updateScoreText().

Use MutableLiveData for encapsulation

Using backing property, ViewModel uses MutableLiveData internally and exposes LiveData to public access.

// from
val score = MutableLiveData<Int>()

// to
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
  get() = _score

Check Getters and Setters in Kotlin.

Internally, viewModel should use _score now.

// in ViewModel
_score.value = (score.value)?.minus(1)

// for reading
binding.scoreText.text = score.value.toString()

Example: game-finish event

private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
  get() = _eventGameFinish

fun nextWord() {
  if (wordList.isNotEmpty()) {
    //Select and remove a word from the list
    _word.value = wordList.removeAt(0)
  } else {
    onGameFinish()
  }
}

fun onGameFinish() {
  _eventGameFinish.value = true
}

fun onGameFinishComplete() {
  _eventGameFinish.value = false
}

in the fragment,

// onCreateView()
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
  if (hasFinished) gameFinished()
})

private fun gameFinished() {
  // ...
  viewModel.onGameFinishComplete()
}

Example: transition event

// in ViewModel
private val _eventPlayAgain = MutableLiveData<Boolean>()
val eventPlayAgain: LiveData<Boolean>
    get() = _eventPlayAgain

fun onPlayAgain() {
    _eventPlayAgain.value = true
}
fun onPlayAgainComplete() {
    _eventPlayAgain.value = false
}
// onCreateView() in Fragment
binding.playAgainButton.setOnClickListener { viewModel.onPlayAgain() }

viewModel.eventPlayAgain.observe(viewLifecycleOwner, Observer<Boolean> { playAgain ->
  if (playAgain) {
    findNavController().navigate(ScoreFragmentDirections.actionRestart())
    viewModel.onPlayAgainComplete()
  }
})

Data binding with ViewModel and LiveData

Current:

Views        <- UI Controller          <- ViewModel
(XML layout)    (activity/fragment        (LiveData)
                 with click listeners)

ViewModel passed into the data binding:

Views        <- ViewModel
(XML layout)    (LiveData)

Data binding with ViewModel

Add ViewModel into fragment xml file.

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

  <androidx.constraintlayout...>

Add data binding at onCreateView() in the Fragment.

binding.gameViewModel = viewModel

Use listener bindings for event handling via lamda expression.

<Button
   android:id="@+id/skip_button"
   ...
   android:onClick="@{() -> gameViewModel.onSkip()}"
   ... />

Then, remove all onClickListener() in the acitivities and fragments.

Note: data-binding error messages are quite hard to understand. Read the error and check the spell carefully.

Data binding with LiveData

<TextView
  android:id="@+id/word_text"
  ...
  android:text="@{gameViewModel.word}"
  ... />
<TextView
  android:id="@+id/score_text"
  ...
  android:text="@{String.valueOf(scoreViewModel.score)}"
  ... />
binding.gameViewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner

Then, remove all observe() for updating the content.

Formatting

in string.xml:

<string name="quote_format">\"%s\"</string>
<string name="score_format">Current Score: %d</string>

in the fragment xml file,

<TextView
  android:id="@+id/word_text"
  ...
  android:text="@{@string/quote_format(gameViewModel.word)}"
  ... />

Check Layouts and binding expressions.

As a result, each fragments only observes the navigation related LiveData.

Transformations for LiveData

Transformations manipulates the data in LiveData for sanitizing, deducting, subsetting, etc. e.g. Transformations.map, Transformations.switchMap.

Add a timer

// in ViewModel
companion object {
  private const val DONE = 0L
  private const val ONE_SECOND = 1000L
  private const val COUNTDOWN_TIME = 60000L
}

private val _currentTime = MutableLiveData<Long>()
val currentTime: LiveData<Long>
  get() = _currentTime

private val timer: CountDownTimer

init {
  timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {
    override fun onTick(millisUntilFinished: Long) {
      _currentTime.value = millisUntilFinished / ONE_SECOND
    }
    override fun onFinish() {
      _currentTime.value = DONE
      onGameFinish()
    }
  }

  timer.start()
}

override fun onCleared() {
  super.onCleared()
  timer.cancel()
}

Now, the time should display "MM:SS" format rather than just a number. DataUtils.formatElaspedTime() is utility method takes a long number of milliseconds and format the number to the time format.

// in ViewModel
val currentTimeString = Transformations.map(currentTime) { time ->
  DataUtils.formatElaspedTime(time)
}
// it's like a computed property in Swift and/or Vue.js.

Then, update fragment xml file.

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

다음 챕터: Android Kotlin Fundamentals: 06.1 Create a Room database

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.