Compose의 ViewModel 및 상태

1. 시작하기 전에

이전 Codelab에서는 활동의 수명 주기와 구성 변경과 관련된 수명 주기 문제에 관해 알아보았습니다. 구성 변경이 발생하는 경우 rememberSaveable을 사용하거나 인스턴스 상태를 저장하는 등 다양한 방법으로 앱 데이터를 저장할 수 있습니다. 하지만 이러한 옵션으로 인해 문제가 발생할 수 있습니다. 대부분의 경우 rememberSaveable을 사용할 수 있지만 그렇게 하면 구성 가능한 함수 내부에 또는 근처에 로직을 유지해야 할 수 있습니다. 앱이 성장하면 데이터와 로직을 구성 가능한 함수와 분리해야 합니다. 이 Codelab에서는 Android Jetpack 라이브러리, ViewModel, Android 앱 아키텍처 가이드라인을 사용하여 구성 변경 중에 앱을 설계하고 앱 데이터를 보존하는 효과적인 방법을 알아봅니다.

Android Jetpack 라이브러리는 뛰어난 Android 앱을 더 간편하게 개발하는 데 사용할 수 있는 라이브러리 모음입니다. 이 라이브러리를 사용하면 권장사항을 따를 수 있고 상용구 코드를 작성하지 않아도 되며 복잡한 작업을 간소화하여 앱 로직과 같은 중요한 코드에 집중할 수 있습니다.

앱 아키텍처란 앱의 디자인 규칙 집합을 가리킵니다. 아키텍처는 건물의 청사진과 마찬가지로 앱의 구조를 제공합니다. 좋은 앱 아키텍처는 코드를 강력하고 유연하며 확장 가능하고 테스트 가능하도록 만들고 향후 수년 동안 유지관리할 수 있도록 지원합니다. 앱 아키텍처 가이드에서는 앱 아키텍처 및 추천 권장사항을 제공합니다.

이 Codelab에서는 앱 데이터를 저장할 수 있는 Android Jetpack 라이브러리의 아키텍처 구성요소 중 하나인 ViewModel을 사용하는 방법을 알아봅니다. 프레임워크가 구성 변경 또는 기타 이벤트 중에 활동을 삭제하고 다시 만들어도 저장된 데이터가 손실되지 않습니다. 그러나 프로세스 종료로 인해 활동이 소멸되면 데이터가 손실됩니다. ViewModel은 빠른 활동 재생성을 통해서만 데이터를 캐시합니다.

기본 요건

  • 함수, 람다, 구성 가능한 스테이트리스(Stateless) 함수를 비롯한 Kotlin 지식
  • Jetpack Compose에서 레이아웃을 빌드하는 방법에 관한 기본 지식
  • Material Design 관련 기본 지식

학습할 내용

빌드할 항목

  • 사용자가 글자가 뒤섞인 단어를 추측하는 Unscramble 게임 앱

필요한 항목

  • 최신 버전의 Android 스튜디오
  • 스타터 코드를 다운로드하기 위한 인터넷 연결

2. 앱 개요

게임 개요

Unscramble 앱은 플레이어 한 명이 즐기는 단어 맞히기 게임입니다. 앱이 글자가 섞인 단어를 표시하면 플레이어는 표시된 모든 문자를 사용하여 단어를 추측해야 합니다. 단어를 맞히면 점수를 획득하고 맞히지 못하면 횟수에 제한 없이 재시도할 수 있습니다. 현재 표시된 단어를 건너뛰는 옵션도 있습니다. 앱의 오른쪽 상단에 단어 수, 즉 현재 게임에서 플레이한 단어의 수가 표시됩니다. 한 게임당 글자가 섞인 단어 10개가 제시됩니다.

시작 코드 가져오기

시작하려면 시작 코드를 다운로드하세요.

GitHub 저장소를 클론하여 코드를 가져와도 됩니다.

$ git clone
https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout starter

Unscramble GitHub 저장소에서 스타터 코드를 찾아볼 수 있습니다.

3. 스타터 앱 개요

스타터 코드에 익숙해지려면 다음 단계를 완료하세요.

  1. Android 스튜디오에서 스타터 코드가 있는 프로젝트를 엽니다.
  2. Android 기기나 에뮬레이터에서 앱을 실행합니다.
  3. Submit 버튼과 Skip 버튼을 탭하여 앱을 테스트합니다.

앱에 버그가 있습니다. 글자가 뒤섞인 단어가 표시되지 않고 'scrambleun'으로 하드코딩되어 버튼을 탭할 때 아무 일도 일어나지 않습니다.

이 Codelab에서는 Android 앱 아키텍처를 사용하여 게임의 기능을 구현합니다.

스타터 코드 둘러보기

스타터 코드에는 미리 디자인된 게임 화면 레이아웃이 있습니다. 이 개발자 과정에서는 게임 로직을 구현합니다. 아키텍처 구성요소를 사용하여 권장 앱 아키텍처를 구현하고 앞에서 언급한 문제를 해결하겠습니다. 다음은 시작하는 데 도움이 되는 몇 가지 파일입니다.

WordsData.kt

이 파일에는 게임에서 사용되는 단어의 목록, 게임당 최대 단어 수를 표시하는 상수, 단어를 맞출 때마다 플레이어가 얻는 점수가 포함되어 있습니다.

package com.example.android.unscramble.data

const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20

// Set with all the words for the Game
val allWords: Set<String> =
   setOf(
       "animal",
       "auto",
       "anecdote",
       "alphabet",
       "all",
       "awesome",
       "arise",
       "balloon",
       "basket",
       "bench",
      // ...
       "zoology",
       "zone",
       "zeal"
)

MainActivity.kt

이 파일에는 대부분 템플릿에서 생성된 코드가 포함되어 있습니다. 개발자가 setContent{} 블록에 구성 가능한 함수 GameScreen을 표시합니다.

GameScreen.kt

UI의 구성 가능한 함수는 모두 GameScreen.kt 파일에 정의되어 있습니다. 이어지는 섹션에서는 구성 가능한 함수를 몇 가지 살펴봅니다.

GameStatus

GameStatus는 화면 하단에 게임 점수를 표시하는 구성 가능한 함수입니다. 이 구성 가능한 함수에는 Card에 텍스트 컴포저블이 포함되어 있습니다. 현재 점수는 0으로 하드코딩되어 있습니다.

1a7e4472a5638d61.png

// No need to copy, this is included in the starter code.

@Composable
fun GameStatus(score: Int, modifier: Modifier = Modifier) {
    Card(
        modifier = modifier
    ) {
        Text(
            text = stringResource(R.string.score, score),
            style = typography.headlineMedium,
            modifier = Modifier.padding(8.dp)
        )
    }
}

GameLayout

GameLayout은 글자가 섞인 단어, 게임 안내, 사용자가 추측 내용을 입력하는 텍스트 필드를 포함해 게임의 기본 기능을 표시하는 구성 가능한 함수입니다.

b6ddb1f07f10df0c.png

아래의 GameLayout 코드에는 글자가 섞인 단어 텍스트, 안내 텍스트, 사용자의 단어를 입력받는 텍스트 필드인 OutlinedTextField, 이렇게 세 가지 하위 요소가 있는 Card가 포함되어 있습니다. 지금은 글자가 섞인 단어가 scrambleun으로 하드코딩되어 있습니다. Codelab의 뒷부분에서 WordsData.kt 파일의 단어를 표시하는 기능을 구현할 것입니다.

// No need to copy, this is included in the starter code.

@Composable
fun GameLayout(modifier: Modifier = Modifier) {
   val mediumPadding = dimensionResource(R.dimen.padding_medium)
   Card(
       modifier = modifier,
       elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
   ) {
       Column(
           verticalArrangement = Arrangement.spacedBy(mediumPadding),
           horizontalAlignment = Alignment.CenterHorizontally,
           modifier = Modifier.padding(mediumPadding)
       ) {
           Text(
               modifier = Modifier
                   .clip(shapes.medium)
                   .background(colorScheme.surfaceTint)
                   .padding(horizontal = 10.dp, vertical = 4.dp)
                   .align(alignment = Alignment.End),
               text = stringResource(R.string.word_count, 0),
               style = typography.titleMedium,
               color = colorScheme.onPrimary
           )
           Text(
               text = "scrambleun",
               style = typography.displayMedium
           )
           Text(
               text = stringResource(R.string.instructions),
               textAlign = TextAlign.Center,
               style = typography.titleMedium
           )
           OutlinedTextField(
               value = "",
               singleLine = true,
               shape = shapes.large,
               modifier = Modifier.fillMaxWidth(),
               colors = TextFieldDefaults.textFieldColors(containerColor = colorScheme.surface),
               onValueChange = { },
               label = { Text(stringResource(R.string.enter_your_word)) },
               isError = false,
               keyboardOptions = KeyboardOptions.Default.copy(
                   imeAction = ImeAction.Done
               ),
               keyboardActions = KeyboardActions(
                   onDone = { }
               )
           )
       }
   }
}

구성 가능한 함수 OutlinedTextField는 이전 Codelab의 앱에서 사용된 구성 가능한 함수 TextField와 비슷합니다.

텍스트 필드에는 두 가지 유형이 있습니다.

  • 색이 채워진 텍스트 필드
  • 윤곽선이 있는 텍스트 필드

3df34220c3d177eb.png

윤곽선이 있는 텍스트 필드는 색이 채워진 텍스트 필드보다 시각적으로 덜 강조됩니다. 양식과 같이 여러 개의 텍스트 필드가 함께 배치되는 장소에 나타날 때 윤곽선이 있는 텍스트 필드의 눈에 잘 띄지 않는 특징 때문에 레이아웃이 간소화됩니다.

스타터 코드에서 OutlinedTextField는 사용자가 추측을 입력해도 업데이트되지 않습니다. 이 기능은 Codelab을 진행하면서 업데이트할 것입니다.

GameScreen

구성 가능한 함수인 GameScreen에는 구성 가능한 함수 GameStatusGameLayout, 게임 제목, 단어 수가 포함되어 있고 SubmitSkip 버튼을 위한 구성 가능한 함수도 포함되어 있습니다.

ac79bf1ed6375a27.png

@Composable
fun GameScreen() {
    val mediumPadding = dimensionResource(R.dimen.padding_medium)

    Column(
        modifier = Modifier
            .verticalScroll(rememberScrollState())
            .padding(mediumPadding),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text(
            text = stringResource(R.string.app_name),
            style = typography.titleLarge,
        )

        GameLayout(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .padding(mediumPadding)
        )
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(mediumPadding),
            verticalArrangement = Arrangement.spacedBy(mediumPadding),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            Button(
                modifier = Modifier.fillMaxWidth(),
                onClick = { }
            ) {
                Text(
                    text = stringResource(R.string.submit),
                    fontSize = 16.sp
                )
            }

            OutlinedButton(
                onClick = { },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(
                    text = stringResource(R.string.skip),
                    fontSize = 16.sp
                )
            }
        }

        GameStatus(score = 0, modifier = Modifier.padding(20.dp))
    }
}

버튼 클릭 이벤트는 시작 코드에 구현되어 있지 않습니다. 이러한 이벤트는 Codelab을 진행하면서 구현할 것입니다.

FinalScoreDialog

구성 가능한 함수 FinalScoreDialog는 게임을 Play Again 또는 Exit하는 옵션이 있는 대화상자(사용자에게 입력을 요청하는 작은 창)를 표시합니다. 이 Codelab의 뒷부분에서는 게임이 끝났을 때 이 대화상자를 표시하는 로직을 구현할 것입니다.

dba2d9ea62aaa982.png

// No need to copy, this is included in the starter code.

@Composable
private fun FinalScoreDialog(
    score: Int,
    onPlayAgain: () -> Unit,
    modifier: Modifier = Modifier
) {
    val activity = (LocalContext.current as Activity)

    AlertDialog(
        onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onDismissRequest.
        },
        title = { Text(text = stringResource(R.string.congratulations)) },
        text = { Text(text = stringResource(R.string.you_scored, score)) },
        modifier = modifier,
        dismissButton = {
            TextButton(
                onClick = {
                    activity.finish()
                }
            ) {
                Text(text = stringResource(R.string.exit))
            }
        },
        confirmButton = {
            TextButton(onClick = onPlayAgain) {
                Text(text = stringResource(R.string.play_again))
            }
        }
    )
}

4. 앱 아키텍처 알아보기

앱의 아키텍처는 클래스 간에 앱 책임을 할당하는 데 도움이 되는 가이드라인을 제공합니다. 앱 아키텍처가 잘 디자인되어 있으면 앱을 확장하고 더 많은 기능을 포함할 수 있습니다. 아키텍처는 팀 공동작업을 간소화할 수도 있습니다.

가장 일반적인 아키텍처 원칙관심사 분리모델에서 UI 만들기입니다.

관심사 분리

관심사 분리 디자인 원칙은 각각 별개의 책임이 있는 여러 함수 클래스로 앱을 나눠야 한다는 원칙입니다.

모델에서 UI 만들기

모델에서 UI 만들기 원칙은 모델(가능한 경우 영구 모델)에서 UI를 만들어야 한다는 원칙입니다. 모델은 앱의 데이터 처리를 담당하는 구성요소로, 앱의 UI 요소 및 앱 구성요소와 독립되어 있으므로 앱의 수명 주기 및 관련 문제의 영향을 받지 않습니다.

이전 섹션에서 언급한 일반적인 아키텍처 원칙에 따라 각 앱에는 최소한 다음 두 가지 레이어가 포함되어야 합니다.

  • UI 레이어: 화면에 앱 데이터를 표시하지만 데이터와는 무관한 레이어
  • 데이터 레이어: 앱 데이터를 저장하고, 가져오고, 노출하는 레이어.

UI와 데이터 레이어 간의 상호작용을 간소화하고 재사용하기 위해 도메인 레이어라는 레이어를 추가할 수 있습니다. 이 레이어는 선택사항이며 본 과정의 범위를 벗어납니다.

a4da6fa5c1c9fed5.png

UI 레이어

UI 레이어(프레젠테이션 레이어)의 역할은 화면에 애플리케이션 데이터를 표시하는 것입니다. 버튼 누르기와 같은 사용자 상호작용으로 인해 데이터가 변경될 때마다 UI가 변경사항을 반영하여 업데이트되어야 합니다.

UI 레이어는 다음 구성요소로 이루어져 있습니다.

  • UI 요소: 화면에 데이터를 렌더링하는 구성요소. 이러한 요소는 Jetpack Compose를 사용하여 빌드합니다.
  • 상태 홀더: 데이터를 보유하고 UI에 노출하며 앱 로직을 처리하는 구성요소. 상태 홀더의 예로 ViewModel을 들 수 있습니다.

6eaee5b38ec247ae.png

ViewModel

ViewModel 구성요소는 UI가 사용하는 상태를 보유하고 노출합니다. UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터입니다. ViewModel을 사용하면 앱이 모델에서 UI 만들기 아키텍처 원칙을 따르도록 할 수 있습니다.

ViewModel은 Android 프레임워크에서 활동이 소멸되고 다시 생성될 때 폐기되지 않는 앱 관련 데이터를 저장합니다. 활동 인스턴스와 달리 ViewModel 객체는 소멸되지 않습니다. 앱은 구성 변경 중에 자동으로 ViewModel 객체를 유지하므로 객체가 보유하고 있는 데이터는 재구성 후에 즉시 사용 가능합니다.

앱에 ViewModel을 구현하려면 아키텍처 구성요소 라이브러리에서 가져온 ViewModel 클래스를 확장하고 이 클래스 내에 앱 데이터를 저장합니다.

UI 상태

사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목입니다. UI는 UI 상태를 시각적으로 나타냅니다. UI 상태가 변경되면 변경사항이 즉시 UI에 반영됩니다.

9cfedef1750ddd2c.png

UI는 화면에 있는 UI 요소와 UI 상태를 결합한 결과입니다.

// Example of UI state definition, do not copy over

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

불변성

위 예에서 UI 상태 정의는 변경할 수 없습니다. 변경할 수 없는 객체는 여러 소스가 한순간에 앱의 상태를 변경하지 않도록 보장합니다. 덕분에 UI는 상태를 읽고 이에 따라 UI 요소를 업데이트하는 한 가지 역할에 집중할 수 있습니다. 따라서 UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI 상태를 직접 수정해서는 안 됩니다. 이 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가 발생합니다.

5. ViewModel 추가

이 작업에서는 앱에 ViewModel을 추가하여 게임 UI 상태(글자가 섞인 단어, 단어 수, 점수)를 저장합니다. 앞 섹션에서 확인한 시작 코드의 문제를 해결하려면 게임 데이터를 ViewModel에 저장해야 합니다.

  1. build.gradle.kts (Module :app)를 열고 dependencies 블록으로 스크롤하여 ViewModel의 다음 종속 항목을 추가합니다. 이 종속 항목은 Compose 앱에 수명 주기 인식 ViewModel을 추가하는 데 사용됩니다.
dependencies {
// other dependencies

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
  1. ui 패키지에서 GameViewModel이라는 Kotlin 클래스/파일을 만듭니다. ViewModel 클래스에서 확장합니다.
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. ui 패키지에서 GameUiState라는 상태 UI의 모델 클래스를 추가합니다. 데이터 클래스로 만들고 글자가 섞인 현재 단어를 위한 변수를 추가합니다.
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow는 현재 상태와 새로운 상태 업데이트를 내보내는 관찰 가능한 데이터 홀더 흐름입니다. StateFlow의 value 속성은 현재 상태 값을 반영합니다. 상태를 업데이트하고 흐름에 전송하려면 MutableStateFlow 클래스의 value 속성에 새 값을 할당합니다.

Android에서 StateFlow는 관찰 가능한 불변 상태를 유지해야 하는 클래스에서 잘 작동합니다.

구성 가능한 함수가 UI 상태 업데이트를 리슨하고 구성 변경에도 화면 상태가 지속되도록 GameUiState에서 StateFlow를 노출할 수 있습니다.

GameViewModel 클래스에서 다음 _uiState 속성을 추가합니다.

import kotlinx.coroutines.flow.MutableStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())

지원 속성

지원 속성을 사용하면 정확한 객체가 아닌 getter에서 무언가를 반환할 수 있습니다.

Kotlin 프레임워크는 var 속성별로 getter와 setter를 생성합니다.

getter 메서드와 setter 메서드 중 하나 또는 둘 모두를 재정의하여 고유한 맞춤 동작을 제공할 수 있습니다. 지원 속성을 구현하려면 읽기 전용 버전의 데이터를 반환하도록 getter 메서드를 재정의합니다. 다음 예는 지원 속성을 보여줍니다.

//Example code, no need to copy over

// Declare private mutable variable that can only be modified
// within the class it is declared.
private var _count = 0

// Declare another public immutable field and override its getter method.
// Return the private property's value in the getter method.
// When count is accessed, the get() function is called and
// the value of _count is returned.
val count: Int
    get() = _count

또 다른 예로, 앱 데이터를 ViewModel에만 공개하려는 경우를 가정해 보겠습니다.

ViewModel 클래스 내부:

  • _count 속성이 private이며 변경 가능합니다. 따라서 ViewModel 클래스 내에서만 액세스하고 수정할 수 있습니다.

ViewModel 클래스 외부:

  • Kotlin의 기본 공개 상태 수정자는 public이므로, count는 공개 속성이고 UI 컨트롤러와 같은 다른 클래스에서 액세스할 수 있습니다. val 유형에는 setter를 포함할 수 없습니다. 이 유형은 변경할 수 없으며 읽기 전용이므로 get() 메서드만 재정의할 수 있습니다. 외부 클래스가 이 속성에 액세스하면 _count의 값이 반환되며, 이 값은 수정할 수 없습니다. 이 지원 속성은 ViewModel에 있는 앱 데이터가 외부 클래스로 인해 원치 않게, 안전하지 않게 변경되지 않도록 보호하지만 외부 호출자는 값에 안전하게 액세스할 수 있습니다.
  1. GameViewModel.kt 파일에서 _uiState라는 uiState에 지원 속성을 추가합니다. 속성 이름을 uiState로 지정하고 유형을 StateFlow<GameUiState>로 지정합니다.

이제 GameViewModel 내에서만 _uiState에 액세스하고 수정할 수 있습니다. UI는 읽기 전용 속성 uiState를 사용하여 값을 읽을 수 있습니다. 초기화 오류는 다음 단계에서 수정할 수 있습니다.

import kotlinx.coroutines.flow.StateFlow

// Game UI state

// Backing property to avoid state updates from other classes
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState>
  1. uiState_uiState.asStateFlow()로 설정합니다.

asStateFlow()는 이 변경 가능 상태 흐름을 읽기 전용 상태 흐름으로 만듭니다.

import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

글자가 섞인 단어 표시하기

이 작업에서는 WordsData.kt에서 임의의 단어를 선택하고 단어의 글자를 섞는 도우미 메서드를 추가합니다.

  1. GameViewModel에서 String 유형의 currentWord라는 속성을 추가하여 글자가 섞인 현재 단어를 저장합니다.
private lateinit var currentWord: String
  1. 목록에서 임의 단어를 선택하여 글자를 섞는 도우미 메서드를 추가합니다. 입력 매개변수 없이 이름을 pickRandomWordAndShuffle()로 지정하고 String을 반환하도록 설정합니다.
import com.example.unscramble.data.allWords

private fun pickRandomWordAndShuffle(): String {
   // Continue picking up a new random word until you get one that hasn't been used before
   currentWord = allWords.random()
   if (usedWords.contains(currentWord)) {
       return pickRandomWordAndShuffle()
   } else {
       usedWords.add(currentWord)
       return shuffleCurrentWord(currentWord)
   }
}

Android 스튜디오에서 정의되지 않은 변수 및 함수 오류를 신고합니다.

  1. 게임에서 사용된 단어를 저장하는 변경 가능 집합으로 기능하도록 GameViewModel에서 currentWord 속성 뒤에 다음 속성을 추가합니다.
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. String을 받아서 순서가 섞인 String을 반환하여 현재 단어의 순서를 섞는 도우미 메서드인 shuffleCurrentWord()를 추가합니다.
private fun shuffleCurrentWord(word: String): String {
   val tempWord = word.toCharArray()
   // Scramble the word
   tempWord.shuffle()
   while (String(tempWord).equals(word)) {
       tempWord.shuffle()
   }
   return String(tempWord)
}
  1. 게임을 초기화하는 도우미 함수 resetGame()을 추가합니다. 이 함수는 나중에 게임을 시작하고 다시 시작하는 데 사용합니다. 이 함수에서 usedWords 세트에 있는 모든 단어를 지우고 _uiState를 초기화합니다. pickRandomWordAndShuffle()을 사용하여 currentScrambledWord의 새 단어를 선택합니다.
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. GameViewModelinit 블록을 추가하고 거기에서 resetGame()을 호출합니다.
init {
   resetGame()
}

지금 앱을 빌드해도 UI에 변경사항이 없습니다. ViewModel의 데이터를 GameScreen의 구성 가능한 함수로 전달하지 않고 있기 때문입니다.

6. Compose UI 설계

Compose에서 UI를 업데이트하는 유일한 방법은 앱 상태를 변경하는 것입니다. 개발자가 제어할 수 있는 것은 UI 상태입니다. UI 상태가 변경될 때마다 Compose는 UI 트리 중에서 변경된 부분을 다시 만듭니다. 구성 가능한 함수는 상태를 받아서 이벤트를 노출할 수 있습니다. 예를 들어, TextField/OutlinedTextField는 값을 받아서 콜백 핸들러에 값을 변경하도록 요청하는 콜백 onValueChange를 노출합니다.

//Example code no need to copy over

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

구성 가능한 함수는 상태를 받아서 이벤트를 노출하기 때문에 이 단방향 데이터 흐름 패턴은 Jetpack Compose에 적합합니다. 이 섹션에서는 Compose에서 단방향 데이터 흐름 패턴을 구현하는 방법, 이벤트 및 상태 홀더를 구현하는 방법, Compose에서 ViewModel을 사용하는 방법을 중점적으로 설명합니다.

단방향 데이터 흐름

단방향 데이터 흐름(UDF)은 상태는 아래로 이동하고 이벤트는 위로 이동하는 디자인 패턴입니다. 단방향 데이터 흐름을 따르면 UI에 상태를 표시하는 구성 가능한 함수와 앱에서 상태를 저장하고 변경하는 부분을 서로 분리할 수 있습니다.

단방향 데이터 흐름을 사용하는 앱의 UI 업데이트 루프는 다음과 같습니다.

  • 이벤트: UI의 일부가 이벤트를 생성하여 위쪽으로 전달하거나(예: 처리하기 위해 ViewModel에 전달되는 버튼 클릭) 앱의 다른 레이어에서 이벤트가 전달됩니다(예: 사용자 세션이 종료되었음을 표시).
  • 상태 업데이트: 이벤트 핸들러가 상태를 변경할 수도 있습니다.
  • 상태 표시: 상태 홀더가 상태를 아래로 전달하고 UI가 상태를 표시합니다.

61eb7bcdcff42227.png

앱 아키텍처에 UDF 패턴을 사용하면 다음과 같은 영향이 있습니다.

  • ViewModel은 UI가 사용하는 상태를 보유하고 노출합니다.
  • UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터입니다.
  • UI가 ViewModel에 사용자 이벤트를 알립니다.
  • ViewModel이 사용자 동작을 처리하고 상태를 업데이트합니다.
  • 업데이트된 상태가 렌더링할 UI에 다시 제공됩니다.
  • 상태 변경을 야기하는 모든 이벤트에 대해 이 프로세스가 반복됩니다.

데이터 전달하기

GameScreen.kt 파일에서 ViewModel 인스턴스를 UI로(즉, GameViewModel에서 GameScreen()으로) 전달합니다. GameScreen()에서 collectAsState()를 통해 ViewModel 인스턴스를 사용하여 uiState에 액세스합니다.

collectAsState() 함수는 이 StateFlow에서 값을 수집하고 State를 통해 최신 값을 나타냅니다. StateFlow.value는 초깃값으로 사용됩니다. StateFlow에 새 값이 게시될 때마다 반환된 State가 업데이트되어 State.value가 사용된 모든 경우에서 재구성이 이루어집니다.

  1. GameScreen 함수에서 viewModel()의 기본값을 갖는 GameViewModel 유형의 두 번째 인수를 전달합니다.
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GameScreen(
   gameViewModel: GameViewModel = viewModel()
) {
   // ...
}

de93b81a92416c23.png

  1. GameScreen() 함수에서 gameUiState라는 새 변수를 추가합니다. uiState에서 by 위임을 사용하여 collectAsState()를 호출합니다.

이 접근 방식은 uiState 값이 변경될 때마다 gameUiState 값을 사용하여 구성 가능한 함수가 재구성되도록 합니다.

import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

@Composable
fun GameScreen(
   // ...
) {
   val gameUiState by gameViewModel.uiState.collectAsState()
   // ...
}
  1. gameUiState.currentScrambledWord를 구성 가능한 함수 GameLayout()에 전달합니다. 인수는 나중 단계에서 추가할 것이니 지금은 오류를 무시합니다.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   modifier = Modifier
       .fillMaxWidth()
       .wrapContentHeight()
       .padding(mediumPadding)
)
  1. currentScrambledWord를 구성 가능한 함수 GameLayout()에 다른 매개변수로 추가합니다.
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. currentScrambledWord를 표시하도록 구성 가능한 함수 GameLayout()을 업데이트합니다. 열의 첫 번째 텍스트 필드의 text 매개변수를 currentScrambledWord로 설정합니다.
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //...
    }
}
  1. 앱을 실행하고 빌드합니다. 글자가 섞인 단어가 표시됩니다.

6d93a8e1ba5dad6f.png

추측 단어 표시하기

구성 가능한 함수 GameLayout()에서 사용자의 추측 단어를 업데이트하는 것은 GameScreen에서 ViewModel로 흐르는 이벤트 콜백 중 하나입니다. gameViewModel.userGuess 데이터는 ViewModel에서 아래쪽의 GameScreen으로 흐릅니다.

이벤트 콜백 키보드 완료 키 누름 및 사용자 추측 변경사항이 UI에서 뷰 모델로 전달됩니다.

  1. GameScreen.kt 파일의 구성 가능한 함수 GameLayout()에서 onValueChangeonUserGuessChanged 키보드 동작으로, onKeyboardDone()onDone 키보드 동작으로 설정합니다. 오류는 다음 단계에서 수정합니다.
OutlinedTextField(
   value = "",
   singleLine = true,
   modifier = Modifier.fillMaxWidth(),
   onValueChange = onUserGuessChanged,
   label = { Text(stringResource(R.string.enter_your_word)) },
   isError = false,
   keyboardOptions = KeyboardOptions.Default.copy(
       imeAction = ImeAction.Done
   ),
   keyboardActions = KeyboardActions(
       onDone = { onKeyboardDone() }
   ),
  1. 구성 가능한 함수 GameLayout()에서 인수 2개를 더 추가합니다. onUserGuessChanged 람다는 String 인수를 받고 아무것도 반환하지 않으며 onKeyboardDone은 아무것도 받지 않고 아무것도 반환하지 않습니다.
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. GameLayout() 함수 호출에서 onUserGuessChangedonKeyboardDone의 람다 인수를 추가합니다.
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

GameViewModelupdateUserGuess 메서드는 조금 뒤에 정의할 것입니다.

  1. GameViewModel.kt 파일에서 사용자의 추측 단어인 String 인수를 받는 updateUserGuess()라는 메서드를 추가합니다. 함수 안에서, 전달된 guessedWorduserGuess를 업데이트합니다.
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

다음으로는 ViewModel에 userGuess를 추가합니다.

  1. GameViewModel.kt 파일에서 userGuess라는 가변 속성을 추가합니다. Compose가 이 값을 관찰하고 초깃값을 ""로 설정하도록 mutableStateOf()를 사용합니다.
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

var userGuess by mutableStateOf("")
   private set
  1. GameScreen.kt 파일의 GameLayout() 안에서 userGuessString 매개변수를 추가합니다. OutlinedTextFieldvalue 매개변수를 userGuess로 설정합니다.
fun GameLayout(
   currentScrambledWord: String,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       //...
       OutlinedTextField(
           value = userGuess,
           //..
       )
   }
}
  1. GameScreen 함수에서 userGuess 매개변수를 포함하도록 GameLayout() 함수 호출을 업데이트합니다.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   //...
)
  1. 앱을 빌드하고 실행합니다.
  2. 단어를 추측하고 입력해 봅니다. 텍스트 필드에 사용자가 추측한 단어가 표시됩니다.

ed10c7f522495a.png

7. 추측 단어 확인 및 점수 업데이트

이 작업에서는 사용자가 추측한 단어를 확인한 후 게임 점수를 업데이트하거나 오류를 표시하는 메서드를 구현합니다. 새 점수와 새 단어로 게임 상태 UI를 업데이트하는 작업은 뒤에서 진행합니다.

  1. GameViewModel에서 checkUserGuess()라는 메서드를 추가합니다.
  2. checkUserGuess() 함수에서 사용자의 추측이 currentWord과 같은지 확인하는 if else 블록을 추가합니다. userGuess를 빈 문자열로 재설정합니다.
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. 사용자의 추측이 틀렸으면 isGuessedWordWrongtrue로 설정합니다. MutableStateFlow<T>. update()는 지정된 값을 사용하여 MutableStateFlow.value를 업데이트합니다.
import kotlinx.coroutines.flow.update

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
  1. GameUiState 클래스에서 isGuessedWordWrong이라는 Boolean을 추가하고 false로 초기화합니다.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

다음으로, 사용자가 Submit 버튼 또는 키보드의 완료 키를 클릭할 경우 이벤트 콜백 checkUserGuess()GameScreen에서 ViewModel로 전달합니다. gameUiState.isGuessedWordWrong 데이터를 ViewModel에서 아래쪽의 GameScreen으로 전달하여 텍스트 필드에 오류를 설정합니다.

7f05d04164aa4646.png

  1. GameScreen.kt 파일의 구성 가능한 함수 GameScreen() 끝부분에 있는 Submit 버튼의 onClick 람다 표현식 안에서 gameViewModel.checkUserGuess()를 호출합니다.
Button(
   modifier = modifier
       .fillMaxWidth()
       .weight(1f)
       .padding(start = 8.dp),
   onClick = { gameViewModel.checkUserGuess() }
) {
   Text(stringResource(R.string.submit))
}
  1. 구성 가능한 함수 GameScreen()에서 onKeyboardDone 람다 표현식의 gameViewModel.checkUserGuess()를 전달하도록 GameLayout() 함수 호출을 업데이트합니다.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. 구성 가능한 함수 GameLayout()에서 Boolean의 함수 매개변수 isGuessWrong을 추가합니다. 사용자의 추측이 틀린 경우 텍스트 필드에 오류를 표시하도록 OutlinedTextFieldisError 매개변수를 isGuessWrong으로 설정합니다.
fun GameLayout(
   currentScrambledWord: String,
   isGuessWrong: Boolean,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       // ,...
       OutlinedTextField(
           // ...
           isError = isGuessWrong,
           keyboardOptions = KeyboardOptions.Default.copy(
               imeAction = ImeAction.Done
           ),
           keyboardActions = KeyboardActions(
               onDone = { onKeyboardDone() }
           ),
       )
}
}
  1. 구성 가능한 함수GameScreen()에서 isGuessWrong을 전달하도록 GameLayout() 함수 호출을 업데이트합니다.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong,
   // ...
)
  1. 앱을 빌드하고 실행합니다.
  2. 잘못된 추측을 입력하고 Submit을 클릭합니다. 텍스트 필드가 빨간색으로 표시되어 오류가 있음을 나타냅니다.

a1bc55781d627b38.png

텍스트 필드 라벨은 'Enter your word'에서 바뀌지 않는 것을 볼 수 있습니다. 텍스트 필드를 사용자 친화적인 환경으로 만들려면 단어가 틀렸음을 나타내는 오류 텍스트를 추가해야 합니다.

  1. GameScreen.kt 파일의 구성 가능한 함수 GameLayout()에서 다음과 같이 isGuessWrong에 따라 텍스트 필드의 라벨 매개변수를 업데이트합니다.
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. strings.xml 파일에서 오류 라벨에 문자열을 추가합니다.
<string name="wrong_guess">Wrong Guess!</string>
  1. 앱을 빌드하고 다시 실행합니다.
  2. 잘못된 추측을 입력하고 Submit을 클릭합니다. 오류 라벨을 확인합니다.

8c17eb61e9305d49.png

8. 점수 및 단어 수 업데이트

이 작업에서는 사용자가 게임을 플레이하는 과정에서 점수와 단어 수를 업데이트합니다. 점수는 _ uiState에 속해야 합니다.

  1. GameUiState에서 score 변수를 추가하고 0으로 초기화합니다.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. 점수 값을 업데이트하려면 GameViewModelcheckUserGuess() 함수의 사용자의 추측이 올바른 경우를 위한 if 조건 내에서 score 값을 늘립니다.
import com.example.unscramble.data.SCORE_INCREASE

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
   } else {
       //...
   }
}
  1. GameViewModel에서 updateGameState라는 또 다른 메서드를 추가하여 점수를 업데이트하고 현재 단어 수를 늘리고 WordsData.kt 파일에서 새 단어를 선택합니다. updatedScore라는 이름의 Int를 매개변수로 추가합니다. 게임 상태 UI 변수를 다음과 같이 업데이트합니다.
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. checkUserGuess() 함수에서 사용자의 추측이 올바르면 다음 라운드를 위해 게임을 준비하도록 업데이트된 점수로 updateGameState를 호출합니다.
fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       //...
   }
}

완성된 checkUserGuess()는 다음과 같습니다.

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
   // Reset user guess
   updateUserGuess("")
}

다음으로, 점수를 업데이트한 것과 마찬가지로 단어 수를 업데이트해야 합니다.

  1. GameUiState에서 단어 수를 세는 변수를 추가합니다. 이름을 currentWordCount로 지정하고 1으로 초기화합니다.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
)
  1. GameViewModel.kt 파일의 updateGameState() 함수에서 다음과 같이 단어 수를 늘립니다. updateGameState() 함수는 다음 라운드를 위해 게임을 준비하기 위해 호출됩니다.
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

점수와 단어 수 전달하기

다음 단계에 따라 점수 및 단어 수 데이터를 ViewModel에서 아래쪽의 GameScreen으로 전달합니다.

546e101980380f80.png

  1. GameScreen.kt 파일의 구성 가능한 함수 GameLayout()에서 단어 수를 인수로 추가하고 wordCount 형식 인수를 텍스트 요소에 전달합니다.
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   wordCount: Int,
   //...
) {
   //...

   Card(
       //...
   ) {
       Column(
           // ...
       ) {
           Text(
               //..
               text = stringResource(R.string.word_count, wordCount),
               style = typography.titleMedium,
               color = colorScheme.onPrimary
           )

// ...

}
  1. 단어 수를 포함하도록 GameLayout() 함수 호출을 업데이트합니다.
GameLayout(
   userGuess = gameViewModel.userGuess,
   wordCount = gameUiState.currentWordCount,
   //...
)
  1. 구성 가능한 함수 GameScreen()에서 score 매개변수를 포함하도록 GameStatus() 함수 호출을 업데이트합니다. gameUiState에서 점수를 전달합니다.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
  1. 앱을 빌드하고 실행합니다.
  2. 추측 단어를 입력하고 Submit을 클릭합니다. 점수와 단어 수가 업데이트되는 것을 볼 수 있습니다.
  3. Skip을 클릭하면 아무것도 일어나지 않는 것을 볼 수 있습니다.

건너뛰기 기능을 구현하려면 GameViewModel에 이벤트 건너뛰기 콜백을 전달해야 합니다.

  1. GameScreen.kt 파일의 구성 가능한 함수 GameScreen()onClick 람다 표현식에서 gameViewModel.skipWord()를 호출합니다.

아직 함수를 구현하지 않았기 때문에 Android 스튜디오에 오류가 표시됩니다. 다음 단계에서 skipWord() 메서드를 추가하여 이 오류를 수정할 것입니다. 사용자가 단어를 건너뛰면 게임 변수를 업데이트하고 다음 라운드를 위해 게임을 준비해야 합니다.

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier.fillMaxWidth()
) {
   //...
}
  1. GameViewModel에서 skipWord() 메서드를 추가합니다.
  2. skipWord() 함수에서 updateGameState()를 호출하여 점수를 전달하고 사용자 추측을 재설정합니다.
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. 앱을 실행하고 게임을 플레이합니다. 이제 단어를 건너뛸 수 있을 것입니다.

e87bd75ba1269e96.png

10단어를 초과해도 게임을 플레이할 수 있습니다. 다음 작업에서는 게임의 마지막 라운드를 처리합니다.

9. 게임의 마지막 라운드 처리

현재 구현에서는 사용자가 단어 10개를 넘게 건너뛰거나 플레이할 수 있습니다. 이 작업에서는 게임 종료 로직을 추가합니다.

d3fd67d92c5d3c35.png

게임 종료 로직을 구현하려면 먼저 사용자가 최대 단어 수에 도달하는지 확인해야 합니다.

  1. GameViewModel에서 if-else 블록을 추가하고 else 블록에서 기존 함수 본문을 이동합니다.
  2. if 조건을 추가하여 usedWords 크기가 MAX_NO_OF_WORDS와 같은지 확인합니다.
import com.example.android.unscramble.data.MAX_NO_OF_WORDS

private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. if 블록에서 Boolean 플래그 isGameOver를 추가하고 플래그를 true로 설정하여 게임 종료를 나타냅니다.
  2. if 블록에서 score를 업데이트하고 isGuessedWordWrong을 재설정합니다. 다음 코드는 함수가 어떻게 수정되었는지 보여줍니다.
private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game, update isGameOver to true, don't pick a new word
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               score = updatedScore,
               isGameOver = true
           )
       }
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. GameUiState에서 Boolean 변수 isGameOver를 추가하고 false로 설정합니다.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
   val isGameOver: Boolean = false
)
  1. 앱을 실행하고 게임을 플레이합니다. 이제는 단어 10개를 초과하면 플레이할 수 없습니다.

ac8a12e66111f071.png

게임이 끝나면 사용자에게 게임이 끝났음을 알리고 다시 플레이하고 싶은지 물어보는 기능을 구현하면 좋을 것입니다. 다음 작업에서 이 기능을 구현합니다.

게임 종료 대화상자 표시하기

이 작업에서는 ViewModel의 isGameOver 데이터를 아래쪽의 GameScreen으로 전달하고 이를 사용하여 게임을 종료하거나 다시 시작하는 옵션이 있는 알림 대화상자를 표시합니다.

대화상자는 사용자에게 결정을 내리거나 추가 정보를 입력하라는 메시지를 표시하는 작은 창입니다. 일반적으로 대화상자는 전체 화면을 가득 채우지 않으며 사용자가 조치를 취해야 계속 진행할 수 있도록 합니다. Android는 다양한 유형의 대화상자를 제공합니다. 이 Codelab에서는 알림 대화상자에 관해 배웁니다.

알림 대화상자 분석

eb6edcdd0818b900.png

  1. 컨테이너
  2. 아이콘(선택사항)
  3. 제목(선택사항)
  4. 보조 문구
  5. 구분선(선택사항)
  6. 작업

시작 코드의 GameScreen.kt 파일은 게임을 종료하거나 다시 시작하는 옵션이 있는 알림 대화상자를 표시하는 함수를 제공합니다.

78d43c7aa01b414d.png

@Composable
private fun FinalScoreDialog(
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
   val activity = (LocalContext.current as Activity)

   AlertDialog(
       onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onDismissRequest.
       },
       title = { Text(stringResource(R.string.congratulations)) },
       text = { Text(stringResource(R.string.you_scored, 0)) },
       modifier = modifier,
       dismissButton = {
           TextButton(
               onClick = {
                   activity.finish()
               }
           ) {
               Text(text = stringResource(R.string.exit))
           }
       },
       confirmButton = {
           TextButton(
               onClick = {
                   onPlayAgain()
               }
           ) {
               Text(text = stringResource(R.string.play_again))
           }
       }
   )
}

이 함수에서 titletext 매개변수는 알림 대화상자에 제목과 보조 문구를 표시합니다. dismissButtonconfirmButton은 텍스트 버튼입니다. dismissButton 매개변수에서는 Exit 텍스트를 표시하고 활동을 종료하여 앱을 종료합니다. confirmButton 매개변수에서는 게임을 다시 시작하고 Play Again 텍스트를 표시합니다.

a24f59b84a178d9b.png

  1. GameScreen.kt 파일의 FinalScoreDialog() 함수에서 점수 매개변수는 알림 대화상자에 게임 점수를 표시합니다.
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. FinalScoreDialog() 함수에서 text 매개변수 람다 표현식은 score를 대화상자 텍스트의 형식 인수로 사용합니다.
text = { Text(stringResource(R.string.you_scored, score)) }
  1. GameScreen.kt 파일의 구성 가능한 함수 GameScreen() 끝부분의 Column 블록 뒤에 gameUiState.isGameOver를 확인하는 if 조건을 추가합니다.
  2. if 블록에서 알림 대화상자를 표시합니다. onPlayAgain 이벤트 콜백의 scoregameViewModel.resetGame()을 전달하는 FinalScoreDialog()를 호출합니다.
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

resetGame()GameScreen에서 위쪽의 ViewModel로 전달되는 이벤트 콜백입니다.

  1. GameViewModel.kt 파일에서 resetGame() 함수를 다시 호출하고 _uiState를 초기화하고 새 단어를 선택합니다.
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. 앱을 빌드하고 실행합니다.
  2. 게임이 끝날 때까지 게임을 플레이한 후 게임 Exit 또는 Play Again 옵션이 있는 알림 대화상자를 관찰합니다. 알림 대화상자에 표시된 옵션을 사용해 봅니다.

c6727347fe0db265.png

10. 기기 회전 상태

이전 Codelab에서는 Android의 구성 변경사항에 관해 알아보았습니다. 구성 변경이 발생하면 Android가 활동을 처음부터 다시 시작하여 모든 수명 주기 시작 콜백을 실행합니다.

ViewModel은 Android 프레임워크에서 활동이 소멸되고 다시 생성될 때 폐기되지 않는 앱 관련 데이터를 저장합니다. ViewModel 객체는 자동으로 보관되며 구성 변경 중에 활동 인스턴스처럼 소멸되지 않습니다. 객체가 보유하고 있는 데이터는 재구성 후에 즉시 사용 가능합니다.

이 작업에서는 구성 변경 중에 앱이 상태 UI를 유지하는지 확인합니다.

  1. 앱을 실행하고 단어 게임을 플레이합니다. 기기 구성을 세로에서 가로로 또는 그 반대로 변경합니다.
  2. ViewModel의 상태 UI에 저장된 데이터는 구성 변경 중에도 유지되는 것을 볼 수 있습니다.

4a63084643723724.png

4134470d435581dd.png

11. 솔루션 코드 가져오기

완료된 Codelab의 코드를 다운로드하려면 다음 git 명령어를 사용하면 됩니다.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout viewmodel

또는 ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

이 Codelab의 솔루션 코드는 GitHub에서 확인하세요.

12. 결론

축하합니다. Codelab을 완료했습니다. 이제 Android 앱 아키텍처 가이드라인에서는 책임이 서로 다른 클래스를 분리하고 모델에서 UI를 만들도록 권장한다는 사실을 알게 되었습니다.

#AndroidBasics를 사용해 작업한 결과물을 소셜 미디어로 공유해 보세요.

자세히 알아보기