액티비티는 onCreate 시점에 device configuration 을 참조한다. 만약 device configuration 이 변경되면 onCreate 를 다시 호출하고 이러한 이유로 다른 조치가 없다면 핸드폰을 가로 모드로 하면 view 가 초기화 된다.
이때 Bundle 을 이용해도 되지만 액티비티와 프레그먼트의 코드는 어플이 복잡해질수록 이에 비례해서 복잡해지기 때문에 Bundle 에 대한 처리까지 하게 된다면 상당히 골치 아플 것이다.
뷰 모델은 화면에 나타나는 모든 데이터와 비즈니스 로직을 책임지는 객체이다. 액티비티와 프레그먼트는 화면을 업데이트 해야한다면 뷰 모델에게 물어보고, 비즈니스 로직 함수도 뷰 모델에서 호출한다.
이러한 뷰 모델을 이용함으로써 액티비티와 프레그먼트는 레이아웃 처리에 더 집중할 수 있게 된다. 그리고 또한, device configutarion 이 변경되더라도 view model 객체는 무사하기 때문에 Bundle 에 대한 추가적인 처리가 불필요해진다.
단어 추측 게임을 만들어서 뷰 모델 적용 전과 후를 비교해보자.
메인 액티비티는 네비게이션 프레그먼트 홀더 역할만 하고, GameFragment 와 ResultFragment 를 만든다.
<?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>
<?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>
<?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>
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
}
}
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}"
}
}
오로지 결과 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() 는 뷰 모델이 지워지기 직전에 호출된다. 뷰 모델과 관련된 액티비티 혹은 프레그먼트의 라이프 사이클에 의해 뷰 모델이 사라지기 전에 리소스를 청산하기 위해 사용하면 유용하다. 뷰 모델의 라이프사이클이라고 생각해도 될까?