이전 포스팅에서 만든 글자 추측 앱은 유저가 프레그먼트와 상호작용하여 데이터를 입력하면, 프레그먼트는 해당 데이터를 뷰 모델로 넘기고 뷰 모델에서 데이터를 가져와서 스크린에 노출하는 구조를 가지고 있다. 이러한 구조에서는 각각의 프레그먼트가 뷰 모델에서 데이터를 가져와서 노출하는 시기를 결정하게 된다. 그리고 이는 무차별적으로 일어나므로, 변경되지 않은 데이터도 참조하게 되는 문제점이 있다.
만약에 뷰 모델이 프레그먼트에게 어떤 프로퍼티 값이 변경되었는지 알려줄 수 있다면, 프레그먼트는 더이상 스스로 뷰를 업데이트하는 시기를 결정하지 않아도 되며, 변경되지 않은 데이터도 참조하는 문제를 해결할 수 있다.
더 일반적인 경우로 생각해보면, 뷰 모델의 데이터가 변경되었을때 UI 컨트롤러(액티비티 혹은 프레그먼트)가 그에 맞춰서 스크린에 출력되는 데이터를 바꿔줄 수 있도록 하는 것이다. 이를 도와주는게 라이브 데이터이다.
dependencies {
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
}
뷰 모델의 프로퍼티에 라이브 데이터를 적용할 것이다. 어떤 프로퍼티 값의 변화를 프레그먼트가 알도록 해야할까?
class GameViewModel: ViewModel() {
val words = listOf("Android", "Activity", "Fragment")
val secretWord = words.random().uppercase()
var secretWordDisplay = "" // liveData 적용
var correctGuess = ""
var incorrectGuess = "" // liveData 적용
var livesLeft = 8 // liveData 적용
init {
secretWordDisplay = deriveSecretWordDisplay()
}
...
}
라이브 데이터를 적용하기 위해서는 데이터 타입을 MutableLiveData<Type>
으로 변경해야 한다.
위의 코드를 변경하자면 아래와 같이 된다.
class GameViewModel: ViewModel() {
...
val livesLeft = MutableLiveData<Int>(8) // 8이라는 숫자로 초기화 된다.
val incorrectGuess = MutableLiveData<String>("") // 공백으로 초기화 된다.
val secretWordDisplay = MutableLiveData<String>() // init 에서 초기화 예정
init {
secretWordDisplay.value = deriveSecretWordDisplay()
}
...
}
MutableLiveData 프로퍼티를 이용할때, 이 값을 변경하려면 해당 타입의 .value 를 호출하여 변경해야한다. init 블록에 그 예시가 나와 있다. 이렇게 value 를 이용해서 값을 변경하는 것을 통해 연관된 컴포넌트들이 값이 변경되었다는 사실을 인지하게 된다.
이러한 value 값은 null 이 될 수 있다. 즉, 라이브데이터 프로퍼티를 코드 상에서 이용할때는 null Check 를 해줘야하고, 그렇지 않으면 컴파일 에러가 나온다.
var livesLeft = MutableLiveData<Int>(8)
의 value 는 Int value 혹은 null 을 가지고 있을 수 있으므로, 아래처럼 -- 연산자를 이용할 수 없다. 대신, value 의 minus 함수를 호출해서 원하는 만큼 숫자를 감소시킬 수 잇다.
livesLest.value-- // 불가능
livesLest.value = livesLest.value?.minus(1) // 가능
라이브데이터 프로퍼티들은 원래 var 로 정의된 변수들이었지만, 라이브 데이터는 객체를 참조하는 변수가 되었고, 변수들의 참조값이 변하지 않고, 참조하고 있는 객체의 value 가 변하는 것이기 때문에 val 로 정의할 수 있다.
프로퍼티와 함수들까지 변경하면 다음과 같이 변한다.
class GameViewModel: ViewModel() {
val words = listOf("Android", "Activity", "Fragment")
val secretWord = words.random().uppercase()
val secretWordDisplay = MutableLiveData<String>()
var correctGuess = ""
var incorrectGuess = MutableLiveData<String>("")
var livesLeft = MutableLiveData<Int>(8)
init {
secretWordDisplay.value = deriveSecretWordDisplay()
}
private fun deriveSecretWordDisplay() : String {
var display = ""
secretWord.forEach {
display += checkLetter(it.toString())
}
return display
}
fun checkLetter(str: String) = when (correctGuess.contains(str)) {
true -> str
false -> "_"
}
fun makeGuess(guess: String) {
if (guess.length == 1) {
if (secretWord.contains(guess)) {
correctGuess += guess
secretWordDisplay.value = deriveSecretWordDisplay()
} else {
incorrectGuess.value += guess
livesLeft.value = livesLeft.value?.minus(1)
}
}
}
fun isWon() = secretWord.equals(secretWordDisplay.value, true)
fun isLost() = livesLeft.value ?: 0 <= 0
fun wonListMessage() : String {
var message = ""
if (isWon()) message = "You Won!"
else if (isLost()) message = "You Lost!"
message += " The word wat $secretWord"
return message
}
}
livesLeft 가 라이브 데이터의 변화를 관찰 (observe) 하게 하려면 observe() 함수를 이용하면 된다. 아래와 같은 구조이다.
viewModel.livesLeft.observe(viewLifeCycleOwner, Observer { newValue ->
// new value 를 이용하는 코드
})
observe() 함수는 viewLifeCycleOwner 와 Observer 를 파라미터로 갖는다.
viewLifeCycleOwner 은 프레그먼트의 뷰 라이프사이클을 참조하고 있다. 이 파라미터는 프레그먼트가 view 에 접근하는 순간 프레그먼트 라이프사이클에 붙게된다. 즉, onCreateView() 에서부터 onDestryView() 까지 프레그먼트의 라이프사이클을 계속 파악하게 된다.
Observer 는 live data 를 받는 클래스이다. 이 클래스는 viewLifeCycleOwner 에 붙게된다. 그러므로 오로지 프레그먼트가 뷰를 활성화시킨 동안에만 라이브 데이터에 접근할 수 있게 된다. 만약, 프레그먼트가 뷰에 접근하지 못하는 동안에 뷰 모델의 프로퍼티가 변경된다면 프레그먼트는 이러한 변화를 인지하지 못하게 된다.
또한 Observer 클래스는 람다를 파라미터로 받는데, 이 람다는 새로운 값을 프레그먼트에서 어떻게 이용할지 정의하는데 이용된다.
전체 코드는 다음과 같다. updateScreen() 함수는 더 이상 필요하지 않고, observe 함수를 통해 뷰 모델과 연동되도록 하였다.
class GameFragment: Fragment() {
private var _binding: FragmentGameBinding? = null
private val binding get() = _binding!!
lateinit var viewModel: GameViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentGameBinding.inflate(inflater, container, false)
val view = binding.root
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
viewModel.incorrectGuess.observe(viewLifecycleOwner, Observer { newValue ->
binding.incorrectGuesses.text = "Incorrect guesses: $newValue"
})
viewModel.livesLeft.observe(viewLifecycleOwner, Observer { newValue ->
binding.lives.text = "You have $newValue lives left"
})
viewModel.secretWordDisplay.observe(viewLifecycleOwner, Observer { newValue ->
binding.word.text = newValue
})
binding.guessButton.setOnClickListener {
viewModel.makeGuess(binding.guess.text.toString().uppercase())
binding.guess.text = null
if (viewModel.isWon() || viewModel.isLost()) {
val action = GameFragmentDirections
.toResultFragment(viewModel.wonListMessage())
view.findNavController().navigate(action)
}
}
return view
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
그런데 위의 뷰 모델 코드에 대한 문제가 있다. 프레그먼트가 라이브데이터 자체에 접근해서 조작할 가능성이 있다는 것이다. 여러 방법이 있겠지만, binding 에서 했던것 처럼 private 변수를 만들고, get() 만 구현한 public 변수를 따로 만들어서 이 문제를 해결할 수 있다. 라이브 데이터의 경우 MutableLiveData<>() 는 value 를 업데이트 할 수 있기 때문에 이 데이터 타입까지 LiveData<> 로 바꿔줘야한다.
private val _secretWordDisplay = MutableLiveData<String>()
val secretWordDisplay: LiveData<String>
get() = _secretWordDisplay
private val _incorrectGuess = MutableLiveData<String>("")
val incorrectGuess: LiveData<String>
get() = _incorrectGuess
private val _livesLeft = MutableLiveData<Int>(8)
val livesLeft: LiveData<Int>
get() = _livesLeft
이러한 구조에서 _secretWordDisplay 등은 backing property 라고 불린다.
정말 완벽하게 viewmodel 이 로직과 데이터를 가져간걸까? 아직 프레그먼트에서는 게임이 승패에 대해 판단하는 로직이 들어있으므로 완벽하게 나눠졌다고 할 수 없는 상태다.
binding.guessButton.setOnClickListener {
viewModel.makeGuess(binding.guess.text.toString().uppercase())
binding.guess.text = null
if (viewModel.isWon() || viewModel.isLost()) {
val action = GameFragmentDirections
.toResultFragment(viewModel.wonListMessage())
view.findNavController().navigate(action)
}
}
이 부분인데, 유저가 추측 결과를 제출할 때마다 게임의 승패를 체크하고 있다. 뷰 모델이 추측마다 체크하여 게임의 승패를 담는 프로퍼티를 갖도록 하고, 프레그먼트가 해당 프로퍼티를 계속 점검하도록 구조를 변경하자.
...
// 프로퍼티 추가
private val _gameOver = MutableLiveData<Boolean>(false)
val gameOver: LiveData<Boolean>
get() = _gameOver
....
fun makeGuess(guess: String) {
if (guess.length == 1) {
if (secretWord.contains(guess)) {
correctGuess += guess
_secretWordDisplay.value = deriveSecretWordDisplay()
} else {
_incorrectGuess.value += guess
_livesLeft.value = livesLeft.value?.minus(1)
}
// 게임 승패 판단 추가
if (isWon() || isLost()) _gameOver.value = true
}
}
// 프레그먼트가 해당 프로퍼티를 관찰하도록 함.
viewModel.gameOver.observe(viewLifecycleOwner, {newValue ->
if (newValue) {
val action = GameFragmentDirections
.toResultFragment(viewModel.wonListMessage())
view.findNavController().navigate(action)
}
})
...
// 불필요한 부분 삭제 해서 클릭 리스너의 역할을 줄임
binding.guessButton.setOnClickListener {
viewModel.makeGuess(binding.guess.text.toString().uppercase())
binding.guess.text = null
}