Android Live Data

timothy jeong·2021년 12월 3일
0

Android with Kotlin

목록 보기
60/69

이전 포스팅에서 만든 글자 추측 앱은 유저가 프레그먼트와 상호작용하여 데이터를 입력하면, 프레그먼트는 해당 데이터를 뷰 모델로 넘기고 뷰 모델에서 데이터를 가져와서 스크린에 노출하는 구조를 가지고 있다. 이러한 구조에서는 각각의 프레그먼트가 뷰 모델에서 데이터를 가져와서 노출하는 시기를 결정하게 된다. 그리고 이는 무차별적으로 일어나므로, 변경되지 않은 데이터도 참조하게 되는 문제점이 있다.

만약에 뷰 모델이 프레그먼트에게 어떤 프로퍼티 값이 변경되었는지 알려줄 수 있다면, 프레그먼트는 더이상 스스로 뷰를 업데이트하는 시기를 결정하지 않아도 되며, 변경되지 않은 데이터도 참조하는 문제를 해결할 수 있다.

더 일반적인 경우로 생각해보면, 뷰 모델의 데이터가 변경되었을때 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

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) // 가능

라이브 데이터 val로 정의하기

라이브데이터 프로퍼티들은 원래 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
        }
profile
개발자

0개의 댓글