<이전글>
<코드랩 과정5 : LiveData >
# 시작하며
저번시간 코드랩에서는 ViewModel을 활용하여 UI와 model을 분리시키는 일을 했다. 그렇게 함으로써 라이프사이클에 맞게 데이터를 관리하며 의존성을 주입해서 각각의 컴포넌트에 맞게 데이터를 구성 할 수 있었다.
이번에는 ViewModel에서 사용되는 LiveData에 대해서 공부해볼 수 있었다. 생각보다 막강한 기능이라는 것을 알 수 있었다. 시작전에 간단히 요약하자면, 데이터가 변하면 UI컨트롤러(액티비티 or 프래그먼트)에서 상황마다 추가해주는 것이 아닌, Observer형식으로 데이터가 변화하는지를 관찰하다가 데이터가 변화한다면 UI에도 변화했음을 알려주면서 UI를 변화시키는 기능이다. 저번 코드랩을 활용하여 기능을 개선해 나간다.
겉보기에는 ViewModel에서 완성했던거와 차이가 없어보이지만, 내부 코드를 개선했고 더이상 데이터가 없을 경우 게임이 종료되게 바꾸었다.
그리고LiveData로 개선한 코드에서 화면회전시 생기는 문제점에 대해서도 문제점을 파악하고 바로잡는 과정을 거친다.
- LiveData 사용해보기
- MutableLiveData & LiveData
# MutableLiveData
라이브데이터는 간단히 말해 데이터의 변화에 대해서 관찰하고 있다 알려주는 역할을 한다.
- 데이터와 UI 상태를 일관되게 유지
- 수명주기를 따르기에 메모리 누수 없음
- 최신 데이터 유지
이전 뷰모델(GameViewModel)에서 사용하던 word와 score를 MutableLiveData로 변환 시켜 준다.
var word = ""
var score = 0
val word = MutableLiveData<String>()
val score = MutableLiveData<Int>()
init {
word.value = ""
score.value = 0
}
생성된 LiveData 객체에는 value로 접근하게 된다.
onSkip()과 onCorrect() 함수또한 LiveData로 접근하여 변화시켜주도록 해야한다.
fun onSkip() {
score.value = (score.value)?.minus(1)
nextWord()
}
fun onCorrect() {
score.value = (score.value)?.plus(1)
nextWord()
}
nextWord()로 역시 바꿔준다.
private fun nextWord() {
if (!wordList.isEmpty()) {
//Select and remove a word from the list
word.value = wordList.removeAt(0)
}
}
plust(), minus()를 활용해서 증감시키면 null안정성을 유지할 수 있다.
## 라이브데이터 관찰하기
이제 UI컨트롤러에서는 값이 변화하는 조건(기능)마다 UI를 업데이트하는 식으로 코드를 작성하지 않고, 값이 바뀌면 UI를 일관되게 업데이트 시킬 수 있다. 즉, onSkip() onCorrect()라는 함수마다 UI를 그려주는 함수를 각각 작성해줘야 했지만, 각각의 버튼을 누르면 ViewModel에서 비지니스 로직을 수행하고 데이터(LiveData)를 변경시키면, UI컨트롤러에서는 바뀐것을 인지하고 업데이트 시킨다.
observe를 달아 준다.
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
binding.scoreText.text = newScore.toString()
})
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
binding.wordText.text = newWord
})
Observer { ... } 안에 인자로는 현재 최신 데이터가 들어오게 된다. 이제 updateWordText()와 updateScoreText()를 없애도 정상적으로 작동함을 알 수 있다.
# Encapsulate LiveData
위에서 했던 작업은 전반적으로 LiveData가 어떻게 작동되는지를 알 수 있었다. 하지만 개선할 여지가 남아있다. 추상화를 해야한다. 예컨대 viewModel에서 관리하고 있는 LiveData는 UI컨트롤러(액티비티 or 프래그먼트)에서는 변경하면 안된다. 물론 무조건은 아니지만 아키텍처를 따르기 위해서는 그래야 한다. 그러면 UI컨트롤러에서는 데이터를 읽을 수만 있도록 해야 한다. 그러기 위해서 MutableLiveData와 LiveData를 혼용해서 사용한다.
위에서 알 수 있듯, MutableLiveData는 수정이 가능하지만, LiveData는 ViewModel밖에서는 읽기만 가능하다. 코드랩에서는이 두가지의 혼용을 backing property를 사용하도록 한다.
아래와 같은 방식으로 선언했던 LiveData를
val word = MutableLiveData<String>()
val score = MutableLiveData<Int>()
다음과 같은 형식으로 바꿔주도록 한다.
private val _score = MutableLiveData<Int>()
val score : LiveData<Int>
get() = _score
private val _word = MutableLiveData<String>()
val word : LiveData<String>
get() = _word
이렇게 함으로써, 뷰모델(ViewModel)안에서는 _score,_word(MutableLiveData)를 통해서 데이터를 수정하고, 그 외부 즉, UI컨트롤러에서는 score(LiveData),word(LiveData)를 통해서 값을 가져온다. 이때 get()을 통해 getter를 수정해줌으로써 최신화된 객체를 가져오도록 한다.
init {
_score.value = 0
_word.value = ""
}
fun onSkip() {
_score.value = (score.value)?.minus(1)
}
fun onCorrect() {
_score.value = (score.value)?.plus(1)
}
private fun nextWord() {
if (!wordList.isEmpty()) {
//Select and remove a word from the list
_word.value = wordList.removeAt(0)
}
}
ViewModel에서는 MutableLiveData 즉 underScore(_word, _score) 을 통해서 선언한 LiveData를 사용한다.
ViewModel의 코드를 수정했지만, UI컨트롤러(GameFragment)에서는 코드를 수정하지 않아도 정상 작동 됨을 알 수 있다.
# game-finished event
맨위 완성작에서 보듯이 EndGame을 누르지 않더라도, 더이상 다음 단어가 없다면 자동으로 게임이 종료되어야한다. 그러기 위해서 게임종료 상태를 관리하는 LiveData를 만들어야 한다.
간단히 설명하자면, 다음 단어가 없다면, 특정 변수를 변화시키고, UI컨트롤러에서는 그 변수가 변화됐음을 감지하고 프래그먼트를 전환시키는 것이다. 코드는 아래와 같다.
View 모델에 아래의 LiveData를 만들어 준다.
private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
get() = _eventGameFinish
private fun nextWord() {
if (wordList.isEmpty()) {
// 단어가 더이상 없다면 변수를 바꿔줌
onGameFinish()
} else {
//Select and remove a _word from the list
_word.value = wordList.removeAt(0)
}
}
fun onGameFinish() {
_eventGameFinish.value = true
}
그리고 이 LiveData를 UI컨트롤러에서 관찰하고 있도록 해준다.
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
if (hasFinished) gameFinished()
})
이렇게 코드를 작성하면, 단어가 더이상 없다면 화면이 전환된다.
## 문제발생
그러나, 화면을 전환시킬때 문제가 발생함을 알 수 있다, Toast메시지만 살리고 화면전환은 주석처리해서 확인해보면 화면 전환시 계속 토스트 메시지가 새로 출력됨을 알 수 있다. 이게 한번만 출력되야 하는데 말이다!
문제의 원인은 아래와 같다.
화면전환시 프래그먼트가 re-created되면서, ViewModel이 다시 연결되면서 current data를 받게 되고 그게 observing되면서 다시 gameFinished()함수가 호출되면서 생기는 이슈다. 간단히 flag를 만들어서 해결 할 수 있다.
GameViewModel에 게임이 끝남 설정을 맞췄으면, 값을 False로 바꿔주도록 하자.
fun onGameFinishComplete() {
_eventGameFinish.value = false
}
private fun gameFinished() {
...
viewModel.onGameFinishComplete()
}
private fun gameFinished() {
Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
val action = GameFragmentDirections.actionGameToScore()
action.score = viewModel.score.value?:0
findNavController(this).navigate(action)
viewModel.onGameFinishComplete()
}
이제 성공적으로 작동함을 알 수 있다.
# ScoreViewModel도 LiveData사용하도록 바꾸기
socre도 똑같이 LiveData를 사용하도록 바꿔준다. 똑같은 과정이기에 설명은 생략한다.
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
get() = _score
init {
_score.value = finalScore
}
// Add observer for score
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
binding.scoreText.text = newScore.toString()
})
# PlayAgain 버튼 추가하기
xml에 버튼을 추가한후,
private val _eventPlayAgain = MutableLiveData<Boolean>()
val eventPlayAgain: LiveData<Boolean>
get() = _eventPlayAgain
fun onPlayAgain() {
_eventPlayAgain.value = true
}
fun onPlayAgainComplete() {
_eventPlayAgain.value = false
}
// Navigates back to game when button is pressed
viewModel.eventPlayAgain.observe(viewLifecycleOwner, Observer { playAgain ->
if (playAgain) {
findNavController().navigate(ScoreFragmentDirections.actionRestart())
viewModel.onPlayAgainComplete()
}
})
다음과 같이 코드를 확장해주자.
# 마치며
LiveData가 아주 강력한 기능이라는걸 알았다. 지금껏 코드를 작성할때, 매번 UI바뀌는거 마다 똑같은 함수 쳐주는게 정말 번거롭다는 생각이 들었는데 이렇게 일관적이게 처리해줄 수 있다니 편하면서 좋다는 생각이 들었다. 다음 시간에는 ViewModel과 LiveData를 활용한 Data binding에 대해서 공부해본 후 포스팅 하도록 하겠다.
지금작성하고 있는 방식은 안드로이드 클린아키텍처 기반의 코드들이다.