본문 바로가기

안드로이드 Android/Android Study Jams

6. Architecture components - ViewModel

Explore the starter code

시작하기

GuessTheWord starter code를 다운로드하고 Android Studio에서 연다.

앱을 실행한다.

버튼을 탭한다. Skip, Got It 버튼은 구현되어 있지만 End Game은 구현되어 있지 않기 때문에 탭해도 아무 일도 일어나지 않는다.

 

코드 살펴보기

 

스타터 앱의 문제점

1. 스타터 코드를 실행하고 각 단어가 뜨고 나서 Skip과 Got It을 탭하면서 게임을 한다.

2. 게임 화면은 이제 단어와 현재 점수를 보여준다. 디바이스나 에뮬레이터를 회전해서 화면의 방향을 바꾼다. 현재 점수가 0이 되는 것에 주목하라.

3. 게임을 몇 단어 더 실행해본다. 게임 화면에 어떤 점수가 뜰 때, 앱을 닫고 다시 열어 본다. 앱의 상태가 저장되지 않기 때문에 게임이 처음부터 다시 시작되는 것을 볼 수 있다.

4. 게임을 몇 단어 하고 나서 End Game 버튼을 누른다. 아무 일도 일어나지 않는 것을 볼 수 있다.

 

앱 구조

앱 구조는 코드가 체계적이도록 앱의 클래스와 그들 간의 관계를 설계하는 방법이고 특정한 시나리오에서 잘 동작하며 함께 사용하기 쉽다. 이 코드랩에서는 GuessTheWord 앱을 개선하는 방법이 Android app architecture 가이드라인을 따르고 Android Architecture Components를 사용한다. Android app architecture는 MVVM(model-view-viewmodel) architecture pattern과 유사하다.

GuessTheWord 앱은 separation of concerns 설계 원칙을 따르고 별개의 문제를 취급하는 각 클래스들로 나뉜다.

 

UI controller:

UI controller는 Activity나 Fragment 같은 UI 기반의 클래스이다. UI controller는 뷰를 띄우고 사용자 입력 감지와 같은 UI를 조작하는 로직과 운영체제 상호작용만을 포함한다. UI controller에 띄울 텍스트를 결정하는 로직 같은 의사 결정 로직은 넣지 말 것.

 

ViewModel:

ViewModel은 ViewModel과 관련된 프래그먼트나 액티비에 띄워야 할 데이터를 갖고 있다. ViewModel은 UI 컨트롤러가 띄울 데이터를 준비하기 위한 간단한 연산과 데이터의 변환이 가능하다. 이 구조에서 ViewModel은 의사 결정을 수행한다.

 

ViewModelFactory:

ViewModelFactory는 생성자 매개변수가 있거나 없이 ViewModel 객체를 바로 생성한다.

 

GameViewModel 생성

ViewModel 클래스는 UI 관련 데이터를 저장하고 관리하도록 설계되었다. 이 앱에서 ViewModel은 한 프래그먼트와 연관되어 있다.

 

GameViewModel 추가

build.gradle (Module) 파일을 연다. dependencies 블럭 안에 다음 내용을 추가한다.

//ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

패키지 screens/game/ 폴더에 Kotlin 클래스 GameViewModel을 생성한다.

다음과 같이 코드를 추가한다. init 블럭은 어떻게 ViewModel이 lifecycle-aware 하는(생명주기를 인식하는) 지를 이해하기 쉽도록 추가한 것이다.

class GameViewModel : ViewModel() {
   init {
       Log.i("GameViewModel", "GameViewModel created!")
   }
}

 

onCleared() 오버라이드와 logging 추가

ViewModel은 연관된 프래그먼트가 분리되거나 액티비티가 종료되면 destroy된다. ViewModel이 destroy되기 직전에 리소스를 정리(clean up)하기 위해 onCleared() 콜백이 호출된다.

GameViewModel 클래스에 다음 코드를 추가한다.

override fun onCleared() {
   super.onCleared()
   Log.i("GameViewModel", "GameViewModel destroyed!")
}

 

GameViewModel을 게임 프래그먼트와 연결

GameFragment 클래스에 다음 코드를 추가한다.

private lateinit var viewModel: GameViewModel

 

ViewModel 초기화

화면 회전 같이 배열 형태가 바뀌는 동안 프래그먼트 같은 UI 컨트롤러는 다시 생성된다. 그러나 ViewModel 인스턴스는 살아 있다. ViewModel 클래스를 사용하여 ViewModel 인스턴스를 생성하면 프래그먼트가 다시 생성될 때 마다 새 객체가 생성된다. 대신 ViewModelProvider를 사용하여 ViewModel 인스턴스를 생성하자.

Important: 직접 ViewModel의 인스턴스를 바로 생성하기 보다는 ViewModel을 생성할 때 항상 ViewModelProvider를 사용할 것

ViewModelProvider의 동작 방법:

  • ViewModel이 존재하면 ViewModelProvider는 ViewModel을 리턴하고 ViewModel이 아직 존재하지 않으면 새 ViewModel을 생성한다.
  • ViewModelProvider는 주어진 범위(액티비티 혹은 프래그먼트)와 연결된 ViewModel 인스턴스를 생성한다.
  • 생성된 ViewModel은 범위가 살아 있는 한 유지된다. 예를 들어 만약 범위가 프래그먼트라면 ViewModel은 그 프래그먼트가 분리될 때까지 유지된다.

GameFragment 클래스에서 viewModel 변수를 초기화한다. 이 코드를 onCreateView() 안에 binding 변수 정의 이후에 쓴다.

Log.i("GameFragment", "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)

앱을 실행하고 Play 버튼을 누른 후 Logcat을 열면 다음과 같은 로그를 볼 수 있다.

화면의 방향을 몇 번 바꿔 본다. 그 때마다 GameFragment가 destroy되고 다시 생성되지만 GameViewModel은 한 번만 생성된다.

게임에서 나가거나 게임 프래그먼트에서 벗어나 보자. GameFragment가 destroy된다. 연관된 GameVieModel 또한 destroy되고 콜백 onCleared()가 호출된다.

 

Populate the GameViewModel

 

데이터 필드와 데이터 처리를 ViewModel로 이동

데이터 필드와 메서드를 GameFragment에서 GameViewModel로 이동시킨다.

GameFragment에서 onSkip()과 onCorrect() 클릭 핸들러는 데이터 처리와 UI 업데이트를 위한 코드를 포함한다. UI를 업데이트하는 UI는 프래그먼트에 둬야 하지만 데이터 처리를 위한 코드는 ViewModel로 이동되어야 한다.

리팩토링 후 GameViewModel 코드

class GameViewModel : ViewModel() {
   // The current word
   var word = ""
   // The current score
   var score = 0
   // The list of words - the front of the list is the next word to guess
   private lateinit var wordList: MutableList<String>

   /**
    * Resets the list of words and randomizes the order
    */
   private fun resetList() {
       wordList = mutableListOf(
               "queen",
               "hospital",
               "basketball",
               "cat",
               "change",
               "snail",
               "soup",
               "calendar",
               "sad",
               "desk",
               "guitar",
               "home",
               "railway",
               "zebra",
               "jelly",
               "car",
               "crow",
               "trade",
               "bag",
               "roll",
               "bubble"
       )
       wordList.shuffle()
   }

   init {
       resetList()
       nextWord()
       Log.i("GameViewModel", "GameViewModel created!")
   }
   /**
    * Moves to the next word in the list
    */
   private fun nextWord() {
       if (!wordList.isEmpty()) {
           //Select and remove a word from the list
           word = wordList.removeAt(0)
       }
       updateWordText()
       updateScoreText()
   }
 /** Methods for buttons presses **/
   fun onSkip() {
       score--
       nextWord()
   }

   fun onCorrect() {
       score++
       nextWord()
   }

   override fun onCleared() {
       super.onCleared()
       Log.i("GameViewModel", "GameViewModel destroyed!")
   }
}

리팩토링 후 GameFragment 코드

/**
* Fragment where the game is played
*/
class GameFragment : Fragment() {


   private lateinit var binding: GameFragmentBinding


   private lateinit var viewModel: GameViewModel


   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                             savedInstanceState: Bundle?): View? {

       // Inflate view and obtain an instance of the binding class
       binding = DataBindingUtil.inflate(
               inflater,
               R.layout.game_fragment,
               container,
               false
       )

       Log.i("GameFragment", "Called ViewModelProvider.get")
       viewModel = ViewModelProvider(this).get(GameViewModel::class.java)

       binding.correctButton.setOnClickListener { onCorrect() }
       binding.skipButton.setOnClickListener { onSkip() }
       updateScoreText()
       updateWordText()
       return binding.root

   }


   /** Methods for button click handlers **/

   private fun onSkip() {
       score--
       nextWord()
   }

   private fun onCorrect() {
       score++
       nextWord()
   }


   /** Methods for updating the UI **/

   private fun updateWordText() {
       binding.wordText.text = word
   }

   private fun updateScoreText() {
       binding.scoreText.text = score.toString()
   }
}

 

GameFragment의 클릭 핸들러와 데이터 필드에 대한 참조 업데이트

GameFragment에서 onSkip()과 onCorrect() 메서드를 다음 코드로 대체한다.

private fun onSkip() {
   viewModel.onSkip()
   updateWordText()
   updateScoreText()
}
private fun onCorrect() {
   viewModel.onCorrect()
   updateScoreText()
   updateWordText()
}

GameFragment에서 score와 word 변수를 다음과 같이 업데이트한다.

private fun updateWordText() {
   binding.wordText.text = viewModel.word
}

private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.toString()
}
Reminder: 앱의 액티비티와 프래그먼트, 뷰는 배열 변경에서 살아 남지 않으므로 ViewModel은 앱의 액티비티, 프래그먼트 혹은 뷰의 참조를 포함하면 안 된다.

GameViewModel의 nextWord() 메서드 안에서 updateWordText()와 updateScoreText() 메서드 호출을 제거한다. 이 메서드들은 이제 GameFragment에서 호출된다.

앱을 실행해 보면 화면 방향이 변해도 현재 점수가 유지되는 것을 볼 수 있다.

 

ViewModelFactory 사용

Note: 이 앱에서 score를 viewModel.score 변수에 바로 할당할 수 있기 때문에 굳이 ViewModelFactory를 추가할 필요는 없다. 하지만 간혹 viewModel이 초기화될 때 바로 데이터가 필요한 경우가 있다.

이 태스크에서 ViewModel을 사용하기 위해 ScoreFragment를 구현했다. 또한 ViewModelFactory 인터페이스를 사용해 ViewModel의 매개변수화된 생성자를 생성하는 법을 배웠다.

 

Homework

Answer these questions

Q1. ViewModel / Q2. True / Q3. 3 / Q4. 1