Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: ViewModel 부터.
Useful Shortcuts
- Override Methods: Choose Code > Override Methods, or
CTRL+o - Comment: Choose Code > Comment with Line Comment or
CTRL+/(Command+/on a Mac)
App architecture
Follow Android app architecture guidlines and Android Architecture Components. Similarly MVVM.
- UI-controller:
ActivityorFragment. Display data and capture OS/user events. ViewModel: Hold all of the data needed for the UI and prepare it for displayViewModelFactory: instantiatesViewModel
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,
- Fragment: Display fragment, capture user events, do not survive config changes
- ViewModel: Hold data for fragment, should never contain references to any fragments/activities
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.
LiveDatais observable, which means that an observer is notified when the data held by theLiveDataobject changes.LiveDataholds data;LiveDatais a wrapper that can be used with any dataLiveDatais lifecycle-ware. For this, the observer is associated withLifecycleOwnerso that is working with active state only.
// 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