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:
Activity
orFragment
. 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.
LiveData
is observable, which means that an observer is notified when the data held by theLiveData
object changes.LiveData
holds data;LiveData
is a wrapper that can be used with any dataLiveData
is lifecycle-ware. For this, the observer is associated withLifecycleOwner
so 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