본문 바로가기

기타/안드로이드

33. Unscramble App : App architecture와 ViewModel

Scramble App : App architecture와 ViewModel

Compose의 ViewModel 및 상태

 

Compose의 ViewModel 및 상태  |  Android Developers

이 Codelab에서는 아키텍처 구성요소 중 하나인 ViewModel을 사용하는 방법을 알아봅니다. 구성 변경 중에 앱 상태를 유지하도록 ViewModel을 구현합니다.

developer.android.com

위의 페이지를 참고하여 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은 기기를 회전시켜도 변하는 것이 없다

 

 

결과화면