<이전글>
과정5 : 아키텍처 구성요소 #View Model
# 시작하며
안드로이드를 조금 공부해보면 생각보다 별거 아닌데라는 생각이 들면서 우매함의 봉우리에 빠지게 된다. 사실은 안드로이드는 객체지향의 끝판왕이자 여러 멀티태스킹 환경을 고려해야하는... 엄청난 것이라는 것을 깨닫게 된다.
나 역시 우매함의 봉우리에 빠졌다가 엄청난 추락세를 맛보고 있는 중이다. 그 중심에는 아키텍처와 코루틴이 있었다.
MVVM이라는 이야기는 수도 없이 들어 봤다. Model + View + ViewModel, 관심사의 분리라는 단어도 들어봤지만 정작 무엇인지는 알 수 없었다. 아마 이 글을 읽으면서 ViewModel에 대한 필요성은 조금이라도 알게 될 것이다. 물론 내가 알아낸게 아닌 코드랩이 알려줬다. 천천히 코드랩을 읽으면 된다. 코드랩의 존재를 조금만 더 빨리 알았더라면 우매함의 봉우리에서 조금은 빨리 벗어나 추락했을텐데 아쉽기도 하다.
# ViewModel을 사용해야 하는 이유
코드랩(codelab)에서는 Charades라는 샘플을 통해서 설명을 진행한다. 이 앱은 흔히 아는 스피드 퀴즈와 비슷하다. 단어를 맞추면 점수가 증가하고 맞추지 못하면 점수가 감소한다. 단어또한 매번 바뀌며, END GAME버튼을 누르면 게임이 종료 된다.
주어진 샘플코드에는 두가지 문제점이 존재한다.
- END GAME 버튼을 눌러도 아무 작동이 하지 않음
- 화면을 가로 or 세로로 전환 시 기존에 있던 데이터가 초기화 됨
그리고 해당 코드랩을 통해서 Lifecycle에 또다른 이해와 ViewModel 그리고 ViewModelFactroy에 대해서 알 수 있게 된다.
# 문제 분석
화면 전환 시에 데이터가 초기화 되는 것은 Life-Cycle 때문이다. 어려운말로 Configuration이 변경 시에 액티비티가 Destroyed됐다가 onCreated 된다. 즉 액티비티가 재시작 된다. 그렇기 때문에 액티비티 안에 코드를 다 때려 박으면 데이터가 초기화(메모리 해제 및 재할당) 되는 것이 당연하다.
이 문제를 해결하기 위해서 onSaveInstanceState()라는 것을 이용 할 수 있지만, 매우 적은 양의 데이터만을 저장 할 수 있는 제한사항이 있다.
However, using the onSaveInstanceState() method requires you to write extra code to save the state in a bundle, and to implement the logic to retrieve that state. Also, the amount of data that can be stored is minimal.
ViewModel을 이용해 구현하는 MVVM 아키텍처 패턴에서는 이러한 관심사를 분리 시킨다. 액티비티나 프래그먼트에서는 UI만을 담당하도록 하고, 데이터의 조작은 ViewModel에서 진행하는 것이다. UI를 조작하는 액티비티나 프래그먼트에서는 elements를 draw하는 것이나, user가 버튼을 누르는 거와 같은 거에만 책임을 지게한다. 버튼을 눌러서 어떠한 로직을 수행하도록 하지는 않는다는 것이다. 그러한 정보의 책임은 ViewModel에게 위임한다.
# ViewModel(뷰모델) 이해
ViewModel에서는 간단한 계산이나 UI controller에게 보여지기 위한 데이터의 변형을 수행하도록한다. 이 주어진 샘플 앱의 초기에는 분리되어야 하는 score,word,wordList와 같은 데이터들이 액티비티에 코딩되어 있으며 nextWord,Skip과 같은 로직들 또한 액티비티에 코딩 되어 있다. 이 부분을 ViewModel로 이전시켜야 화면전환이 일어나도 데이터를 잃지 않을 수 있는 것이다.
그렇다면 왜 데이터를 잃지 않을 수 있는 것일까 생각해 봐야한다.
위 그림을 참고하자, 뷰 모델은 나름 독립적인 생명주기를 가지게 된다. 액티비티가 완전히 종료될때, 프래그먼트가 분리될때까지 메모리에 데이터가 살아 있다. 화면이 전환 되면 액티비티나 프래그먼트가 재시작 된다고 했는데, 그렇다면 데이터도 날라가는 것이 아닌가라고 생각할 수 있다. 당연히 나도 그렇게 생각해서 찰스님의 블로그를 찾아 그 답을 알 수 있었다.
Configuration의 변환을 따로 관리해주기 때문이다.
결국 아래와 같은 흐름으로 데이터를 관리할 수 있다.
ViewModel을 통해서 데이터를 분리시켜 관리하는 것이다.
# ViewModel 사용하기
앱수준의 build.gradle에 종속성을 추가한다.
// 현재 최신은 2.5.0
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
그리고 Game패키지에 ViewModel을 상송받는 GameViewModel클래스를 작성하고 init에 로그를 작성해준다. ViewModel의 생명주기를 이해하기 위함이다. init에 작성해야만, 최초 실행시 즉 생성될때에만 로그를 찍히게 할 수 있다. "생성될때에만"이라는 단어를 염두해 두자.
class GameViewModel : ViewModel() {
init {
Log.i("GameViewModel", "GameViewModel created!")
}
// 뷰모델이 사라질때 호출되는 함수
override fun onCleared() {
super.onCleared()
Log.i("GameViewModel", "GameViewModel destroyed!")
}
}
이제 GameFragment에서 ViewModel을 위한 변수를 만들어 주자. 그리고 ViewModelProvider를 통해서 viewModel을 초기화 해주어야 한다.
class GameFragment : Fragment() {
private lateinit var binding: GameFragmentBinding
private lateinit var viewModel: GameViewModel
private val TAG = "GameFragment"
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
)
// 추가 Define viewModel variable
Log.d(TAG, "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
}
ViewModelProvider는 생성하는 뷰모델이 한개라도 이미 존재하면 새로 생성하지 않는다. ViewModel이 존재하지 않는다면 새로 생성한다. 그렇기 때문에 init블럭에 뷰모델이 화면전환을 여러번해도 한번만 초기화 되는 것을 알 수 있다. 만약 위와 같은 역할을 해주지 않는다면, 화면전환이 일어날때마다 새로운 뷰모델이 생겨나게 될 것이며, 의도했던 행동을 할 수 없을 것이다.
이제 로그를 찍어 확인해보면, 위와같이 최초로 뷰모델이 만들어지고, 화면전환을 계속해도 더이상 뷰모델은 만들어지지 않는다. 만일 프래그먼트나 액티비가 완전히 종료 또는 분리된다면 재 생성 될 것이다.
# 분리하기
MVVM아키텍처에서는 UI 컨트롤러와 ViewModel을 분리한다고 했다. ViewModel에서 비지니스 로직을 작성한다.
다시 말해, 액티비티 또는 프래그먼트는 UI 컨트롤러 역할을 하며 이곳에서는 오로지 사용자 이벤트를 감지, UI(view)를 화면위에 그려주는 역할만을 해야한다.
예를 들어, SKIP버튼을 눌렀을때 우리가 해야하는 일들을 정리해보자.
먼저 단어 리스트중에서 한 단어를 추출하고 그 단어를 화면에 보여주어야 한다. 총 score를 하나 빼주어야 하고 그 score값을 화면에 그려주어야 한다. 데이터 리스트에서 단어를 하나 뺏으면 리스트에서 당연히 하나를 뺀 리스트로 업데이트하여 가지고 있어야한다.
여기서 ViewModel이 word, score, wordList와 같은 데이터를 가지고 있고, nextWord, onSkip, onCorrect와 같이 score값을 조정하고 다음 단어를 추출해내는 일들을 전가한다. 오로지 프래그먼트에서는 화면 업데이트와 온클릭리스너와 같은 이벤트를 감지하게 하게만 한다.
아래와 같이 최종적으로 코드를 분리 시킬 수 있다.
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(
// 데이터들 ...
)
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!")
}
}
/**
* Fragment where the game is played
*/
class GameFragment : Fragment() {
private lateinit var binding: GameFragmentBinding
private lateinit var viewModel: GameViewModel
private val TAG = "GameFragment"
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
)
// 추가 Define viewModel variable
Log.d(TAG, "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
binding.correctButton.setOnClickListener { onCorrect() }
binding.skipButton.setOnClickListener { onSkip() }
binding.endGameButton.setOnClickListener { onEndGame() }
updateScoreText()
updateWordText()
return binding.root
}
private fun onEndGame() {
gameFinished()
}
private fun gameFinished() {
Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
val action = GameFragmentDirections.actionGameToScore()
action.score = viewModel.score
NavHostFragment.findNavController(this).navigate(action)
}
private fun onSkip() {
viewModel.onSkip()
updateWordText()
updateScoreText()
}
private fun onCorrect() {
viewModel.onCorrect()
updateWordText()
updateScoreText()
}
/** Methods for updating the UI **/
private fun updateWordText() {
binding.wordText.text = viewModel.word
}
private fun updateScoreText() {
binding.scoreText.text = viewModel.score.toString()
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "GameFragment Destroyed!")
}
}
# End Game 버튼 구현
최종 스코어를 이어 받아야 한다. 여긴 구현 코드만 첨부하도록 하겠다.
class ScoreFragment : Fragment() {
private lateinit var viewModel: ScoreViewModel
private lateinit var viewModelFactory: ScoreViewModelFactory
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.d("ScoreFragment", "ScoreFragment is Created!!")
// Inflate view and obtain an instance of the binding class.
val binding: ScoreFragmentBinding = DataBindingUtil.inflate(
inflater,
R.layout.score_fragment,
container,
false
)
viewModelFactory = ScoreViewModelFactory(ScoreFragmentArgs.fromBundle(requireArguments()).score)
viewModel = ViewModelProvider(this, viewModelFactory).get(ScoreViewModel::class.java)
binding.scoreText.text = viewModel.score.toString()
// 다음과 같이 사용해도 되지만, 때때로 data를 viewModel에 initialized하는 상황이 필요하기도 함
// binding.scoreText.text = ScoreFragmentArgs.fromBundle(requireArguments()).score.toString()
return binding.root
}
class ScoreViewModel(finalScore: Int): ViewModel() {
var score = finalScore
init {
Log.d("ScoreViewModel", "Final score is $finalScore")
}
override fun onCleared() {
super.onCleared()
Log.d("ScoreViewModel", "ScoreViewModel is Destroyed!")
}
}
class ScoreViewModelFactory(private val finalScore: Int) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ScoreViewModel::class.java)) {
return ScoreViewModel(finalScore) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
네비게이션의 args를 이용해서 데이터를 넘겨 줄 수 있다.
ViewModel을 만들기 위해 factory methode pattern을 이용한다. 객체의 정확한 클래스를 지정하지 않고도 객체 생성 문제를 처리하기 위한 하나의 생성 패턴이다.
# 참고