Scramble App : App architecture와 ViewModel
위의 페이지를 참고하여 App architecture와 ViewModel에 대해서 배워보자
개요
필자는 최근 건축관련회사에서 사무보조알바를 하고 있다
업무를 하다보면 건축물 도면을 자주 볼 수 있는데
정말 깨알같이 많은 정보가 함축되어있는 것을 볼 수 있다
건축물에는 도면이 있듯이 앱에는 앱 아키텍쳐가 있다고 보면 될 것 같다
말 그대로 앱의 구조로서, 앱이 동작하는 방식과 구성을 결정해준다
Data Layer와 UI Layer, ViewModel
대표적인 앱 아키텍쳐에는 Data Layer와 UI Layer 두가지로 구성된 방식이 있다
Data Layer는 UI Layer에서 받아온 event를 가지고 데이터를 추가, 제거, 편집하고
UI Layer는 Data Layer에서 받아온 data를 가지고 예쁘게 그려준다
그리고 여기서 UI Layer는 ViewModel과 UI element로 나뉠 수 있는데,
UI element는 화면에 그려지는 친구들, 즉 컴포저블이고
ViewModel은 UI의 상태들, 즉 remember 같은 친구들이라고 생각하면 된다
그동안 컴포저블의 코드 앞에 remember 변수가 한 두개씩 껴있었는데
이제는 UI element가 진짜 그리기는 역할만 하면 되는 거고
ViewModel이 remember 변수들을 관리해주는 형태로 만들어줄 수 있게 된 것이다
ViewModel 개요
ViewModel은 자주 쓰이는 앱 아키텍쳐의 요소인 만큼 Jetpack Compose에서 지원하는 Class가 있다
이 녀석을 추가하고 이용해보는 실습을 해보자
ViewModel 추가 및 프로젝트 세팅
우선 Unscramble App을 추가해주자
다음 url에서 코드를 clone 해오고 starter branch로 이동해주자
https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
Project tree에서 Gradle Scripts->build.gradle (Module: Unscramble.app)의 dependencies 부분에 다음 코드를 추가해주자
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
ui 디렉토리에 GameViewModel, GameUiState라는 Kotlin Class 파일들을 추가해주자
GameViewModel은 ViewModel을 상속해주고
GameUiState는 data class로 변경해주고 ""값을 가지는 String 타입의 currentScrumbledWord를 추가해주자
GameViewModel.kt
import androidx.lifecycle.ViewModel
class GameViewModel: ViewModel() {
}
GameUiState.kt
data class GameUiState(val currentScrumbledWord: String = "")
Flow와 StateFlow 개념
ViewModel을 이용하기 위해서 알아야 할 개념인 Flow가 있다
이 친구는 무슨 coroutine이라는 친구에서 나오는 개념이라는데
필자도 아직 잘 모르겠어서 자세히 설명은 못하겠다
이 다음 과정을 따라오기 위한 가장 필수적인 개념만 설명해보도록 하겠다
Flow란 데이터의 흐름, 스트림이고 일종의 파이프라고 생각하면 된다
파이프의 왼쪽에는 데이터를 넣어주는 생산자 친구가 있고
파이프의 오른쪽에는 데이터를 받아서 쓰는 소비자 친구가 있다
보통 이 데이터를 받아서 쓰는 소비자 친구는 UI Layer이다
데이터를 가지고 화면에 출력을 한다는 뜻이다
반대편에 있는 생산자 친구가 Data Layer인지는 정확하진 않지만
맞다고 가정해보면 이렇게 설명할 수 있다
Data Layer가 UI Layer에게 데이터를 보내주고 UI Layer는 이 데이터를 '소비'한다
그러면 이제 이 데이터는 어디 따로 저장되어있는 것이 아니라 사라지는 것이다
이후에 데이터를 다시 쓸 일이 생기면(우리가 아는 예시로는 recomposition, onDestory->onCreate가 있음)
다시 Data Layer가 보내줘야 한다
그런데 계속 다시 보내달라고 하는 건 비효율적이니까 받은 데이터를 따로 어디 저장해두면 좋겠다는 생각이 들 것이다
그래서 상태값(데이터)을 저장하는 곳이 있는 파이프를 만들어 냈으니
그게 바로 StateFlow이다
그리고 이 상태값들(StateFlow)과 상태값을 다루는 로직들을
ViewModel에 모아두고 UIelement가 가져가서 출력하는 것이다
필자도 아직 잘 모르는 개념이라 그런 게 있다고 대충 이해만 하고 넘어가자
ViewModel에 StateFlow 추가
우리의 코드에서 상태값을 모아놓은 상자가 바로 GameUiState이다
그럼 이제 ViewModel에 상태값을 저장하는 곳이 있는 파이프, StateFlow를 추가하고 상자를 옮겨놓으면 된다
GameViewModel에 다음과 같은 변수를 추가해주자
private val _uiState = MutableStateFlow(GameUiState())
여기서 State와 MutableState의 차이는 무엇이냐? 고 한다면
remember를 배울 때의 기억을 되살려보자
stateOf로 컴포저블의 상태를 저장하면 그 값을 읽어올 수만 있다
recomposition 전에 상태가 뭐였는지 알아보기만 할 수 있다
MutableStateOf로 컴포저블의 상태를 저장하면 그 값을 읽어올 수 있을 뿐 아니라 변경도 가능해진다
화면에 보이는 숫자를 바꿀 수도 있게 되는 것이다
즉 둘다 state를 읽어올 수 있는 건 같은데 MutableState는 거기에 더해서 state를 변경할 수 있게도 해준다는 것이다
여기서 uiState를 private로 선언해준 이유는 다른 객체에서 uiState를 변경할 수 없게 하기 위해서이다
대신 읽어갈 수는 있게 uiState라는 public 변수에 _uiState의 값을 넣어주려고 한다
그런데 읽기 전용 StateFlow로 바꿔주기 위해서(파이프에서 빼내기만 가능) _uiState뒤에 .asStateFlow()를 붙여준다
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
단어 shuffle 메소드
data 패키지(디렉토리)에 가보면 WordsData.kt라는 파일이 있다
allWords라는 집합 collection에 문제로 제시할 단어들이 들어가 있다
이 allWords라는 집합에서 랜덤으로 하나의 단어를 고르고 글자의 배열을 섞어주는 메소드를 만들어보자
private lateinit var currentWord: String
private var usedWords: MutableSet<String> = mutableSetOf()
private fun shuffleCurrentWord(word: String): String{
val tempWord = word.toCharArray()
tempWord.shuffle()
while(String(tempWord).equals(word)){
tempWord.shuffle()
}
return String(tempWord)
}
private fun pickRandomWordNShuffle(): String{
currentWord = allWords.random()
if(usedWords.contains(currentWord)){
return pickRandomWordNShuffle()
} else {
usedWords.add(currentWord)
return shuffleCurrentWord(currentWord)
}
}
선택된 단어의 글자 배열을 섞어주는 shuffleCurrentWord 함수와
랜덤 단어를 선택해서 shuffleCurrentWord에 넘겨주는 pickRandomWordNShuffle 함수가 있다
기본적으로 제공되는 String 타입의 메소드들에 익숙하지 않고
오랜만에 보는 재귀함수가 나와서 그렇지
크게 어려운 내용은 아니다 그냥 천천히 읽어보면 이해할 수 있을 것이다
아마 문법적으로 거슬리는 내용은 lateinit 하나 뿐일 거라고 생각한다
lateinit은 말 그대로 '좀 있다가 초기화 할게'라는 뜻이다
그래서 잘 보면 처음에 currentWord라는 변수 선언 후 값을 할당하지 않았다
다만 이런 '늦은 초기화'는 여러 제약사항이 있다
우선 var로 선언되어야 하고 기초 데이터 타입(Int, Boolean 등)이 아니어야 하며
non nullable이어야 하는 등등의 제한이 있다
구체적인 건 굳이 지금 알 필요는 없어보이니 그냥 그런게 있다고만 알아두자
추가적으로 게임 초기화 함수를 만들고
GameViewModel에 다음과 같은 init block을 추가해 최종적으로 다음과 같은 코드를 작성하면 된다
package com.example.android.unscramble.ui
import androidx.compose.runtime.MutableState
import androidx.lifecycle.ViewModel
import com.example.android.unscramble.data.allWords
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class GameViewModel: ViewModel() {
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
private lateinit var currentWord: String
private var usedWords: MutableSet<String> = mutableSetOf()
private fun shuffleCurrentWord(word: String): String{
val tempWord = word.toCharArray()
tempWord.shuffle()
while(String(tempWord).equals(word)){
tempWord.shuffle()
}
return String(tempWord)
}
private fun pickRandomWordNShuffle(): String{
currentWord = allWords.random()
if(usedWords.contains(currentWord)){
return pickRandomWordNShuffle()
} else {
usedWords.add(currentWord)
return shuffleCurrentWord(currentWord)
}
}
init {
resetGame()
}
fun resetGame(){
usedWords.clear()
_uiState.value = GameUiState(currentScrumbledWord = pickRandomWordNShuffle())
}
}
아마 의문점이 두 가지가 있을 것이다
1. init block은 뭐하는 놈인가?
2. _uiState의 value 속성이 무엇인가?
1.
예전에 클래스의 생상자에 대해 얘기할 때 설명을 건너뛰었던 친구가 있다
저기서 말하는 init 구역이 바로 init block인데
객체가 만들어질 때 기본생성자가 호출되고 그 다음에 호출되는 함수(이지만 반환값이 없고 인자도 없음)라고
생각하면 된다
이름 그대로 객체가 초기에 할 행동들을 명시해두는 구역이라고 생각하자
참고로 초기화 블럭이라고 생각하면 당연히 코드의 앞부분에 와야한다고 생각할 수도 있는데 그렇지 않다
예를 들어 우리가 지금 작성한 GameViewModel에서 init 블럭을 맨 앞으로 옮기면
init블럭->resetGame->usedWords.clear() 순서로 진입하는데 usedWords가 아직 초기화가 안된 상태라서 튕긴다
(나도 알고 싶지 않았다..)
2.
_uiState는 StateFlow 타입(정확히는 MutableStateFlow)이고
저장할 상태값에 GameUiState()를 넘겨주었었다
여기서 _uiState의 value 속성은 바로 자신이 저장하는 상태값인 GameUiState()이다
currentScrumbleWord에 값을 넘겨주면서 상태값을 재설정하고 있다고 보면 된다
단방향 데이터 흐름
우리가 다루고 있는 UI element와 ViewModel은 서로 이벤트와 데이터(상태값)를 주고 받는 사이이다
다만 UI element는 이벤트'만' 넘겨주고 ViewModel은 데이터(상태값)'만' 넘겨준다
그렇기 때문에 양방향이 아니라 단방향 데이터 흐름(unidirectional data flow)이라고 부른다
단방향 데이터 흐름 - 상태값
그러면 단방향 데이터 흐름을 구현하려면 어떤 식으로 코드를 작성하는지 차근차근 살펴보자
UI element는 코드상에서 GameScreen이고 ViewModel은 코드상에서 GameViewModel이라고 보면 된다
우선 GameScreen이 GameViewModel에게서 상태값을 가져오도록 해보자
GameScreen에 uiState를 넘겨주고 GameScreen이 GameLayout에 currentScrambledWord를 넘겨주면 끝이다
그러니까 GameScreen에 다음과 같은 인자를 추가해주고
gameViewModel: GameViewModel = viewModel()
GameScreen 본문에 다음과 같은 변수를 추가해준다
val gameUiState by gameViewModel.uiState.collectAsState()
그리고 GameLayout에 다음과 같은 인자를 추가해주고
currentScrambledWord: String
GameScreen에서 다음과 같이 인자를 넘겨준다
GameLayout(gameUiState.currentScrumbledWord)
근데 여기서 두가지 문법적 의문점이 든다
1. 왜 gameUiState에 할당을 안하고 by를 쓰는가?
2. gameViewModel은 GameViewModel 타입 변수인데 그 부모 객체를 넘겨주는게 다형성 관점에서 옳은 일인가?
1번은 나름 답을 내놨는데 2번은 아직도 모르겠다...
1.
일단 by의 역할을 짚고 넘어가자
이전에 property delegation이라는 '상황'만 특정하게 짚고 넘어갔어서
by가 뭐하는 친구인지는 뒷전이었다
by는 위임 즉 delegation을 해주는 친구이다
여기서 위임이 뭐냐면 쉽게 말해서
'뒤에 나오는 클래스의 속성과 메소드를 똑같이 가지는 친구로 만들어줘' 이다
그러니까 이름만 다르고 복제를 했다고 보면 될 것 같다 일단은...
아무튼 왜 할당을 안했냐 하면
그냥 할당을 하면 uiState: StateFlow에서 collectAsState로 뽑아온 State 객체를 저장하게 된다
그러면 평생 그 순간의 State 객체만 가지고 있게 되지만
by로 넣어주면 gameUiState가 gameViewModel.uiState.collectAsState()랑 같아야 한다고 명시해준 것이기 때문에
uiState가 바뀔 때마다 gameUiState도 바뀌기 때문이다
2.
일단 GameScreen에 있는 다음 인자를
gameViewModel: GameViewModel = viewModel()
다음과 같이 바꿔도 똑같다
gameViewModel: GameViewModel = GameViewModel()
내가 이해가 안되는 부분은 왜 굳이 viewModel로 넣었느냐보다
GameViewModel 타입(자식 클래스)의 변수에 viewModel 객체(부모 클래스)를 넣었고
밑에서 gameViewModel.uiState에 접근했는데 잘 굴러갔다는 점이다
gameViewModel이라는 변수의 관점에서는 uiState라는 애가 보이겠지만
문제는 그 메모리 주소에는 아무 값도 없을 거라는 점인데..
이건... 진짜 모르겠다 kotlin이라 되는건가?
아무튼 문법적 의문점 두가지를 넘어서면 섞여있는 글자가 뜨는 화면을 볼 수 있을 것이다
단방향 데이터 흐름 - 이벤트
이젠 GameScreen에서 ViewModel로 event를 전달해보자
보내는 event에는
1. 텍스트 박스에 추측값을 입력하는 event
2. submit 버튼을 눌러 추측값을 제출하는 event
3. skip 버튼을 눌러 다른 단어로 바꾸는 event
이렇게 3가지가 있다
1.
첫번째 event부터 추가해보자
우선 추측값을 담는 변수 userGuess를 GameViewModel에 만든다
그리고 외부에서 값을 바꿀 수 없도록 set을 private으로 설정해놓고 updateUserGuess라는 메소드를 만들어 준다
var userGuess by mutableStateOf(" ")
private set
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
왜 userGuess에 remember가 안쓰였냐면
remember는 composable에만 쓰이는 친구이기 때문이다
이제는 GameLayout에서 추측값을 입력했을 때 userGuess를 변경해주면 된다
GameLayout의 매개변수에 onValueChange로 할당할 람다인 onUserGuessChange와
value에 할당할 값은 userGuess를 추가해주고
fun GameLayout(
onUserGuessChange: (String) -> Unit,
userGuess: String,
currentScrambledWord: String,
modifier: Modifier = Modifier
)
OutlinedTextField에서 onValueChange에 onUserGuessChange, value에 userGuess를 할당해준다
OutlinedTextField(
value = userGuess,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
onValueChange = onUserGuessChange,
label = { Text(stringResource(R.string.enter_your_word)) },
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { }
),
)
그리고 GameScreen에서 GameLayout을 호출할 때 onUserGuess에 해당하는 람다와
userGuess에 해당하는 String 값을 인자로 넘겨준다
GameLayout(
onUserGuessChange = { gameViewModel.updateUserGuess(it) },
userGuess = gameViewModel.userGuess,
gameUiState.currentScrumbledWord
)
updateUserGuess의 인자가 it인 점에 주목하자
2.
두번째 이벤트를 구현해보자
두번째 event는 GameScreen에서 추측값을 확인을 요청하고
GameViewModel에서 추측값이 정답인지 아닌지 판별을 해서 상태를 바꾸면 된다
바꿔야할 상태로는 점수, 현재 섞인 단어, 지금까지 사용된 단어 개수, 맞췄는지 여부가 있다
우선 GameScreen에서 추측값 확인을 요청하는 것은 그냥 onKeyboardDone에 함수만 연결해주면 된다
GameViewModel에서 추측값이 정답인지 확인을 하는 checkUserGuess 함수를 만들자
정답이면 점수, 지금까지 사용된 단어 개수, 현재 섞인 단어, 맞췄는지 여부를 바꿔줘야 하고
정답이 아니면 맞췄는지 여부만 바꿔주면 된다
그러면 일단 맞췄는지 여부, 점수, 지금까지 사용된 단어 개수 변수를 만들어줘야 한다
data class GameUiState(
val currentScrumbledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0,
val currentWordCount: Int = 0
)
이런식으로 GameUiState에 변수 3개를 추가해주자
checkUserGuess 함수는 다음과 같다
equals의 ignoreCase는 대소문자 구분여부를 뜻하고
SCORE_INCREASE는 20짜리 상수이다
그리고 StateFlow의 value, update의 사용법을 확인하자
fun checkUserGuess(){
if(userGuess.equals(currentWord, ignoreCase = true)){
val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
updateGameState(updatedScore)
updateUserGuess("")
} else {
_uiState.update { currentState ->
currentState.copy(isGuessedWordWrong = true)
}
}
}
fun updateGameState(updatedScore: Int){
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
score = updatedScore,
currentScrumbledWord = pickRandomWordNShuffle(),
currentWordCount = currentState.currentWordCount.inc()
)
}
}
이제 OutlinedTextField의 keyboardActions의 onDone에 checkUserGuess만 연결해주면 된다
거기에 더해 isError와 label 값을 변경해서 조금 더 예쁘게 만들어줄 수 있다
그러려면 GameLayout에 인자를 추가해줘야 한다
참고로 label에 들어갈 R.string.enter_your_word는 string.xml 파일에 직접 추가해줘야 한다
fun GameLayout(
onUserGuessChange: (String) -> Unit,
userGuess: String,
onKeyboardDone: () -> Unit,
isGuessWrong: Boolean = false,
currentScrambledWord: String,
modifier: Modifier = Modifier
)
OutlinedTextField(
value = userGuess,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
onValueChange = onUserGuessChange,
label = {
if(isGuessWrong){
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
isError = isGuessWrong,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onKeyboardDone() }
),
)
GameScreen에서 GameLayout 호출시
GameLayout(
onUserGuessChange = { gameViewModel.updateUserGuess(it) },
userGuess = gameViewModel.userGuess,
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
currentScrambledWord = gameUiState.currentScrumbledWord,
)
마지막으로 GameStatus라는 친구에서 지금까지 사용한 단어의 개수랑 점수 값을 출력할 수 있게 넣어줘야 한다
여타 다른 언어를 사용할 때 배워봤을 서식문자가 stringResource에도 있기 때문에 다음과 같이 작성할 수 있다
참고로 R.string.word_count는 "%d of 10 words"이고 R.string.score는 "Score: %d"이다
두번째 오는 인자를 %d자리에 넣어서 출력해준다
@Composable
fun GameStatus(wordCount: Int, score: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.size(48.dp),
) {
Text(
text = stringResource(R.string.word_count, wordCount),
fontSize = 18.sp,
)
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
text = stringResource(R.string.score, score),
fontSize = 18.sp,
)
}
}
GameScreen에서 호출 시
GameStatus(
wordCount = gameUiState.currentWordCount,
score = gameUiState.score
)
3.
마짐가 skip 버튼을 눌렀을 때 단어를 건너뛰는 event를 만들어보자
GameViewModel에 skipWord라는 메소드를 만들고
GameScreen의 skip 버튼의 onClick에 연결해주자
fun skipWord(){
updateGameState(_uiState.value.score)
updateUserGuess("")
}
GameScreen의 OutlinedButton
OutlinedButton(
onClick = { gameViewModel.skipWord() },
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
) {
Text(stringResource(R.string.skip))
}
게임 종료 처리
지금 상태에서는 단어 10개를 써도 게임이 종료가 안되기 때문에
게임 종료 처리를 해주자
GameUiState에 게임 종료 여부 변수를 하나 추가해주고
updateGameState에서 마지막 라운드일 때만 따로 처리해주면 된다
data class GameUiState(
val currentScrumbledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0,
val currentWordCount: Int = 0,
val isGameOver: Boolean = false
)
fun updateGameState(updatedScore: Int){
if(usedWords.size == MAX_NO_OF_WORDS){
_uiState.update { currentState ->
currentState.copy(
isGameOver = true,
isGuessedWordWrong = false,
score = updatedScore,
currentWordCount = currentState.currentWordCount.inc()
)
}
} else {
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
score = updatedScore,
currentScrumbledWord = pickRandomWordNShuffle(),
currentWordCount = currentState.currentWordCount.inc()
)
}
}
}
이제 게임 종료시 다시 플레이할지 물어보는 알람상자를 출력해보자
GameScreen에 이미 FinalScoreDialog라는 이름으로 만들어져 있다
코드를 찬찬히 보면 dissmissButton과 confirmButton으로 이루어져 있고
confirmButton에 onPlayAgain만 붙여주면 된다는 것을 알 수 있을 것이다
심지어 우리는 resetGame이라는 메소드가 이미 있다
이제 우리는 여기에 점수 출력기능을 넣어주고
게임이 실제로 끝났을 때 알람상자가 출력이 되도록 해주면 된다
점수는 그냥 매개변수에서 점수를 받아와서 text의 stringResource의 두번째 인자에 넣어주면 된다
private fun FinalScoreDialog(
onPlayAgain: () -> Unit,
score: Int,
modifier: Modifier = Modifier
)
text = { Text(stringResource(R.string.you_scored, score)) },
알람상자가 출력되게 하려면
GameScreen의 Column 맨 아래에서 다음과 같이 추가해주면 된다
if(gameUiState.isGameOver) {
FinalScoreDialog(
onPlayAgain = { gameViewModel.resetGame() },
score = gameUiState.score
)
}
기기회전시
전에 configuration change라고 불리는
기기 환경이 너무 많이 바뀌어서 앱 화면을 업데이트 하는 가장 쉬운 방법이 껏다가 켜는 것
이었던 상황이 기억이 나는가?
ViewModel은 이런 configuration change 상태에서도 사라지지 않고 상태값을 저장하고 있기 때문에
이번에 만든 unscramble app은 기기를 회전시켜도 변하는 것이 없다
결과화면
'기타 > 안드로이드' 카테고리의 다른 글
35. Dessert Clicker App : ViewModel 추가 (0) | 2022.12.06 |
---|---|
34. Unscramble App : unit test (0) | 2022.12.06 |
32. Dessert Clicker App : Activity Life Cycle (2) | 2022.11.15 |
31. 30 Days Of Habit App (0) | 2022.11.10 |
30. SuperHero App: Material Design, App Icon 복습 (0) | 2022.11.06 |