Android View Model

timothy jeong·2021년 12월 3일
0

Android with Kotlin

목록 보기
59/69

액티비티는 onCreate 시점에 device configuration 을 참조한다. 만약 device configuration 이 변경되면 onCreate 를 다시 호출하고 이러한 이유로 다른 조치가 없다면 핸드폰을 가로 모드로 하면 view 가 초기화 된다.

이때 Bundle 을 이용해도 되지만 액티비티와 프레그먼트의 코드는 어플이 복잡해질수록 이에 비례해서 복잡해지기 때문에 Bundle 에 대한 처리까지 하게 된다면 상당히 골치 아플 것이다.

view model

뷰 모델은 화면에 나타나는 모든 데이터와 비즈니스 로직을 책임지는 객체이다. 액티비티와 프레그먼트는 화면을 업데이트 해야한다면 뷰 모델에게 물어보고, 비즈니스 로직 함수도 뷰 모델에서 호출한다.

이러한 뷰 모델을 이용함으로써 액티비티와 프레그먼트는 레이아웃 처리에 더 집중할 수 있게 된다. 그리고 또한, device configutarion 이 변경되더라도 view model 객체는 무사하기 때문에 Bundle 에 대한 추가적인 처리가 불필요해진다.

예제 before

단어 추측 게임을 만들어서 뷰 모델 적용 전과 후를 비교해보자.
메인 액티비티는 네비게이션 프레그먼트 홀더 역할만 하고, GameFragment 와 ResultFragment 를 만든다.

framgent_game

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".GameFragment">

    <TextView
        android:id="@+id/word"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textSize="36sp"
        android:letterSpacing="0.1"/>

    <TextView
        android:id="@+id/lives"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="16sp"/>

    <TextView
        android:id="@+id/incorrect_guesses"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="16sp"/>

    <EditText
        android:id="@+id/guess"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="16sp"
        android:hint="Guess a letter"
        android:inputType="text"
        android:maxLength="1"/>

    <Button
        android:id="@+id/guessButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Guess!"/>
</LinearLayout>

framgent_result

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:gravity="center"
    tools:context=".ResultFragment">

    <TextView
        android:id="@+id/won_lost"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textSize="28sp"
        android:layout_margin="18dp"/>

    <Button
        android:id="@+id/new_game_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Start new game"/>

</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/gameFragment">

    <fragment
        android:id="@+id/gameFragment"
        android:name="com.example.guessinggame.GameFragment"
        android:label="Game"
        tools:layout="@layout/fragment_game">
        <action
            android:id="@+id/toResultFragment"
            app:destination="@id/resultFragment"
            app:popUpTo="@id/gameFragment"
            app:popUpToInclusive="true"/>
    </fragment>
    <fragment
        android:id="@+id/resultFragment"
        android:name="com.example.guessinggame.ResultFragment"
        android:label="Result"
        tools:layout="@layout/fragment_game">
        <argument
            android:name="result"
            app:argType="string" />
        <action
            android:id="@+id/toGameFragment"
            app:destination="@id/gameFragment" />
    </fragment>
</navigation>

activity_main

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/navHost"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:navGraph="@navigation/nav_graph"
    app:defaultNavHost="true"
    tools:context=".MainActivity">

</androidx.fragment.app.FragmentContainerView>

GameFragment.kt

class GameFragment: Fragment() {
    private var _binding: FragmentGameBinding? = null
    private val binding get() = _binding!!

    val words = listOf("Android", "Activity", "Fragment")
    val secretWord = words.random().uppercase()
    var secretWordDisplay = ""
    var correctGuess = ""
    var incorrectGuess = ""
    var livesLeft = 8

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentGameBinding.inflate(inflater, container, false)
        val view = binding.root
        secretWordDisplay = deriveSecretWordDisplay()
        updateScreen()

        binding.guessButton.setOnClickListener {
            makeGuess(binding.guess.text.toString().uppercase())
            binding.guess.text = null
            updateScreen()
            if (isWon() || isLost()) {
                val action = GameFragmentDirections
                    .toResultFragment(wonListMessage())
                view.findNavController().navigate(action)
            }
        }
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    fun updateScreen() {
        binding.word.text = secretWordDisplay
        binding.lives.text = "You have $livesLeft lives left"
        binding.incorrectGuesses.text = "Incorrect guesses: $incorrectGuess"
    }

    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 = deriveSecretWordDisplay()
            } else {
                incorrectGuess += "$guess"
                livesLeft--
            }
        }
    }
    fun isWon() = secretWord.equals(secretWordDisplay, true)
    fun isLost() = livesLeft <= 0

    fun wonListMessage() : String  {
        var message = ""
        if (isWon()) message = "You Won!"

        else if (isLost()) message = "You Lost!"

        message += " The word wat $secretWord"
        return message
    }
}

ResultFragment.kt

class ResultFragment: Fragment() {
    private var _binding: FragmentResultBinding? = null
    private val binding get() = _binding!!


    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentResultBinding.inflate(inflater, container, false)
        val view = binding.root
        binding.wonLost.text = ResultFragmentArgs.fromBundle(requireArguments()).result

        binding.newGameButton.setOnClickListener {
            view.findNavController().navigate(R.id.toGameFragment)
        }
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

일단, 게임 프레그먼트쪽 코드가 길다. 그런데 이 상태에서는 핸드폰이 가로모드가 됐을때 기존 데이터들이 모두 달아나게 된다. 다시 또 Bundle 을 처리하는 코드를 넣기는 부담스럽다. 이제 뷰 모델을 적용해보자.

뷰 모델 적용

뷰 모델은 스크린에 출력되는 데이터와 비즈니스 로직을 책임지는 객체라고 했다. 그에 맞춰서 game fragment 의 프러퍼티와 함수는 뷰모델로 옮겨져야 한다. 남아있을 코드들은 UI 를 컨트롤하는 코드들 뿐이다.

뷰 모델을 적용함으로써 관심사의 분리(separation of concerns) 개념이 적용된다. 액티비티, 프레그먼트 그리고 뷰 모델이 하는 일이 완전히 분리되는 것이다.

뷰모델 의존성 추가

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

뷰 모델 객체 추가

게임프레그먼트에서 비즈니스 로직과 뷰 관련 데이터를 모두 가져온다. 그리고 androidx.lifecycle.ViewModel 를 extend 해야한다.

class GameViewModel: ViewModel() {
    val words = listOf("Android", "Activity", "Fragment")
    val secretWord = words.random().uppercase()
    var secretWordDisplay = ""
    var correctGuess = ""
    var incorrectGuess = ""
    var livesLeft = 8

    init {
        secretWordDisplay = deriveSecretWordDisplay()
    }


    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 = deriveSecretWordDisplay()
            } else {
                incorrectGuess += "$guess"
                livesLeft--
            }
        }
    }
    fun isWon() = secretWord.equals(secretWordDisplay, true)
    fun isLost() = livesLeft <= 0

    fun wonListMessage() : String  {
        var message = ""
        if (isWon()) message = "You Won!"

        else if (isLost()) message = "You Lost!"

        message += " The word wat $secretWord"
        return message
    }
}

게임 프레그먼트에서 직접 객체를 생성하는데, 이때 ViewModelProvider 객체를 통해서 생성한다. 이 객체를 통해서 생성함으로써 뷰모델 객체가 이전에 생성되지 않은 경우에만 새롭게 인스턴스를 만들도록 한다. device configuration 이 바뀌더라도 이미 생성된 뷰 모델이 있기 때문에 새롭게 생성되지 않도록 하기 위함이다.

만약 프레그먼트의 생명주기에 의해 프레그먼트가 destroy 된다면 뷰 모델도 같이 사라진다. 그리고 다시 프레그먼트를 생성한다면 뷰 모델은 새롭게 생성된다.

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)

        updateScreen()

        binding.guessButton.setOnClickListener {
            viewModel.makeGuess(binding.guess.text.toString().uppercase())
            binding.guess.text = null
            updateScreen()
            if (viewModel.isWon() || viewModel.isLost()) {
                val action = GameFragmentDirections
                    .toResultFragment(viewModel.wonListMessage())
                view.findNavController().navigate(action)
            }
        }
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    fun updateScreen() {
        binding.word.text = viewModel.secretWordDisplay
        binding.lives.text = "You have ${viewModel.livesLeft} lives left"
        binding.incorrectGuesses.text = "Incorrect guesses: ${viewModel.incorrectGuess}"
    }
}

ResultViewModel

오로지 결과 String 만 받으면 된다.

class ResultViewModel(finalResult: String): ViewModel() {
    val result = finalResult
}

그런데 생성자에 파라미터를 받는 뷰 모델은 어떻게 만들 수 있을까? 위에서 GameViewModel 은 생성자를 따로 고려하지 않은 경우였다.

이때는 ViewModelFactory 를 이용할 수 있다. 이 객체의 목적은 뷰 모델을 생성하고 초기화하는 것이다.

아래와 같이 만든다.

// 이 인터페이스를 구현함으로써 클래스를 뷰모델 팩토리로 바꿔놓음.
class ResultViewModelFactory(private val finalResult: String)
    :ViewModelProvider.Factory{

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        // 제대로된 타입으로 만들려고 하는지 체크
        if (modelClass.isAssignableFrom(ResultViewModel::class.java))
            return ResultViewModel(finalResult) as T
        // 그렇지 않다면 예외 처리
        throw IllegalArgumentException("Unknown ViewModel")
    }
}

이를 이용해서 ResultFragment 코드를 변경해보자

class ResultFragment: Fragment() {
    private var _binding: FragmentResultBinding? = null
    private val binding get() = _binding!!
    lateinit var viewModelFactory: ResultViewModelFactory
    lateinit var viewModel: ResultViewModel


    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentResultBinding.inflate(inflater, container, false)
        val view = binding.root
        val result = ResultFragmentArgs.fromBundle(requireArguments()).result
        viewModelFactory = ResultViewModelFactory(result)
        viewModel = ViewModelProvider(this, viewModelFactory).get(ResultViewModel::class.java)
        
        binding.wonLost.text = viewModel.result

        binding.newGameButton.setOnClickListener {
            view.findNavController().navigate(R.id.toGameFragment)
        }
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

onCleared()

onCleared() 는 뷰 모델이 지워지기 직전에 호출된다. 뷰 모델과 관련된 액티비티 혹은 프레그먼트의 라이프 사이클에 의해 뷰 모델이 사라지기 전에 리소스를 청산하기 위해 사용하면 유용하다. 뷰 모델의 라이프사이클이라고 생각해도 될까?

profile
개발자

0개의 댓글