1. 시작하기 전에
이전 Codelab에서는 활동의 수명 주기와 구성 변경과 관련된 수명 주기 문제에 관해 알아보았습니다. 구성 변경이 발생하는 경우 rememberSaveable
을 사용하거나 인스턴스 상태를 저장하는 등 다양한 방법으로 앱 데이터를 저장할 수 있습니다. 하지만 이러한 옵션으로 인해 문제가 발생할 수 있습니다. 대부분의 경우 rememberSaveable
을 사용할 수 있지만 그렇게 하면 구성 가능한 함수 내부에 또는 근처에 로직을 유지해야 할 수 있습니다. 앱이 성장하면 데이터와 로직을 구성 가능한 함수와 분리해야 합니다. 이 Codelab에서는 Android Jetpack 라이브러리, ViewModel
, Android 앱 아키텍처 가이드라인을 사용하여 구성 변경 중에 앱을 설계하고 앱 데이터를 보존하는 효과적인 방법을 알아봅니다.
Android Jetpack 라이브러리는 뛰어난 Android 앱을 더 간편하게 개발하는 데 사용할 수 있는 라이브러리 모음입니다. 이 라이브러리를 사용하면 권장사항을 따를 수 있고 상용구 코드를 작성하지 않아도 되며 복잡한 작업을 간소화하여 앱 로직과 같은 중요한 코드에 집중할 수 있습니다.
앱 아키텍처란 앱의 디자인 규칙 집합을 가리킵니다. 아키텍처는 건물의 청사진과 마찬가지로 앱의 구조를 제공합니다. 좋은 앱 아키텍처는 코드를 강력하고 유연하며 확장 가능하고 테스트 가능하도록 만들고 향후 수년 동안 유지관리할 수 있도록 지원합니다. 앱 아키텍처 가이드에서는 앱 아키텍처 및 추천 권장사항을 제공합니다.
이 Codelab에서는 앱 데이터를 저장할 수 있는 Android Jetpack 라이브러리의 아키텍처 구성요소 중 하나인 ViewModel
을 사용하는 방법을 알아봅니다. 프레임워크가 구성 변경 또는 기타 이벤트 중에 활동을 삭제하고 다시 만들어도 저장된 데이터가 손실되지 않습니다. 그러나 프로세스 종료로 인해 활동이 소멸되면 데이터가 손실됩니다. ViewModel
은 빠른 활동 재생성을 통해서만 데이터를 캐시합니다.
기본 요건
- 함수, 람다, 구성 가능한 스테이트리스(Stateless) 함수를 비롯한 Kotlin 지식
- Jetpack Compose에서 레이아웃을 빌드하는 방법에 관한 기본 지식
- Material Design 관련 기본 지식
학습할 내용
- Android 앱 아키텍처 소개
- 앱에서
ViewModel
클래스를 사용하는 방법 ViewModel
을 사용하여 기기 구성 변경 시에도 UI 데이터를 유지하는 방법
빌드할 항목
- 사용자가 글자가 뒤섞인 단어를 추측하는 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. 스타터 앱 개요
스타터 코드에 익숙해지려면 다음 단계를 완료하세요.
- Android 스튜디오에서 스타터 코드가 있는 프로젝트를 엽니다.
- Android 기기나 에뮬레이터에서 앱을 실행합니다.
- 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
으로 하드코딩되어 있습니다.
// 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
은 글자가 섞인 단어, 게임 안내, 사용자가 추측 내용을 입력하는 텍스트 필드를 포함해 게임의 기본 기능을 표시하는 구성 가능한 함수입니다.
아래의 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
와 비슷합니다.
텍스트 필드에는 두 가지 유형이 있습니다.
- 색이 채워진 텍스트 필드
- 윤곽선이 있는 텍스트 필드
윤곽선이 있는 텍스트 필드는 색이 채워진 텍스트 필드보다 시각적으로 덜 강조됩니다. 양식과 같이 여러 개의 텍스트 필드가 함께 배치되는 장소에 나타날 때 윤곽선이 있는 텍스트 필드의 눈에 잘 띄지 않는 특징 때문에 레이아웃이 간소화됩니다.
스타터 코드에서 OutlinedTextField
는 사용자가 추측을 입력해도 업데이트되지 않습니다. 이 기능은 Codelab을 진행하면서 업데이트할 것입니다.
GameScreen
구성 가능한 함수인 GameScreen
에는 구성 가능한 함수 GameStatus
및 GameLayout
, 게임 제목, 단어 수가 포함되어 있고 Submit 및 Skip 버튼을 위한 구성 가능한 함수도 포함되어 있습니다.
@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의 뒷부분에서는 게임이 끝났을 때 이 대화상자를 표시하는 로직을 구현할 것입니다.
// 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와 데이터 레이어 간의 상호작용을 간소화하고 재사용하기 위해 도메인 레이어라는 레이어를 추가할 수 있습니다. 이 레이어는 선택사항이며 본 과정의 범위를 벗어납니다.
UI 레이어
UI 레이어(프레젠테이션 레이어)의 역할은 화면에 애플리케이션 데이터를 표시하는 것입니다. 버튼 누르기와 같은 사용자 상호작용으로 인해 데이터가 변경될 때마다 UI가 변경사항을 반영하여 업데이트되어야 합니다.
UI 레이어는 다음 구성요소로 이루어져 있습니다.
- UI 요소: 화면에 데이터를 렌더링하는 구성요소. 이러한 요소는 Jetpack Compose를 사용하여 빌드합니다.
- 상태 홀더: 데이터를 보유하고 UI에 노출하며 앱 로직을 처리하는 구성요소. 상태 홀더의 예로 ViewModel을 들 수 있습니다.
ViewModel
ViewModel
구성요소는 UI가 사용하는 상태를 보유하고 노출합니다. UI 상태는 ViewModel
에 의해 변환된 애플리케이션 데이터입니다. ViewModel
을 사용하면 앱이 모델에서 UI 만들기 아키텍처 원칙을 따르도록 할 수 있습니다.
ViewModel
은 Android 프레임워크에서 활동이 소멸되고 다시 생성될 때 폐기되지 않는 앱 관련 데이터를 저장합니다. 활동 인스턴스와 달리 ViewModel
객체는 소멸되지 않습니다. 앱은 구성 변경 중에 자동으로 ViewModel
객체를 유지하므로 객체가 보유하고 있는 데이터는 재구성 후에 즉시 사용 가능합니다.
앱에 ViewModel
을 구현하려면 아키텍처 구성요소 라이브러리에서 가져온 ViewModel
클래스를 확장하고 이 클래스 내에 앱 데이터를 저장합니다.
UI 상태
사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목입니다. UI는 UI 상태를 시각적으로 나타냅니다. UI 상태가 변경되면 변경사항이 즉시 UI에 반영됩니다.
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
에 저장해야 합니다.
build.gradle.kts (Module :app)
를 열고dependencies
블록으로 스크롤하여ViewModel
의 다음 종속 항목을 추가합니다. 이 종속 항목은 Compose 앱에 수명 주기 인식 ViewModel을 추가하는 데 사용됩니다.
dependencies {
// other dependencies
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
ui
패키지에서GameViewModel
이라는 Kotlin 클래스/파일을 만듭니다.ViewModel
클래스에서 확장합니다.
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
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
에 있는 앱 데이터가 외부 클래스로 인해 원치 않게, 안전하지 않게 변경되지 않도록 보호하지만 외부 호출자는 값에 안전하게 액세스할 수 있습니다.
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>
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
에서 임의의 단어를 선택하고 단어의 글자를 섞는 도우미 메서드를 추가합니다.
GameViewModel
에서String
유형의currentWord
라는 속성을 추가하여 글자가 섞인 현재 단어를 저장합니다.
private lateinit var currentWord: String
- 목록에서 임의 단어를 선택하여 글자를 섞는 도우미 메서드를 추가합니다. 입력 매개변수 없이 이름을
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 스튜디오에서 정의되지 않은 변수 및 함수 오류를 신고합니다.
- 게임에서 사용된 단어를 저장하는 변경 가능 집합으로 기능하도록
GameViewModel
에서currentWord
속성 뒤에 다음 속성을 추가합니다.
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
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)
}
- 게임을 초기화하는 도우미 함수
resetGame()
을 추가합니다. 이 함수는 나중에 게임을 시작하고 다시 시작하는 데 사용합니다. 이 함수에서usedWords
세트에 있는 모든 단어를 지우고_uiState
를 초기화합니다.pickRandomWordAndShuffle()
을 사용하여currentScrambledWord
의 새 단어를 선택합니다.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
GameViewModel
에init
블록을 추가하고 거기에서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가 상태를 표시합니다.
앱 아키텍처에 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
가 사용된 모든 경우에서 재구성이 이루어집니다.
GameScreen
함수에서viewModel()
의 기본값을 갖는GameViewModel
유형의 두 번째 인수를 전달합니다.
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun GameScreen(
gameViewModel: GameViewModel = viewModel()
) {
// ...
}
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()
// ...
}
gameUiState.currentScrambledWord
를 구성 가능한 함수GameLayout()
에 전달합니다. 인수는 나중 단계에서 추가할 것이니 지금은 오류를 무시합니다.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(mediumPadding)
)
currentScrambledWord
를 구성 가능한 함수GameLayout()
에 다른 매개변수로 추가합니다.
@Composable
fun GameLayout(
currentScrambledWord: String,
modifier: Modifier = Modifier
) {
}
currentScrambledWord
를 표시하도록 구성 가능한 함수GameLayout()
을 업데이트합니다. 열의 첫 번째 텍스트 필드의text
매개변수를currentScrambledWord
로 설정합니다.
@Composable
fun GameLayout(
// ...
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = currentScrambledWord,
fontSize = 45.sp,
modifier = modifier.align(Alignment.CenterHorizontally)
)
//...
}
}
- 앱을 실행하고 빌드합니다. 글자가 섞인 단어가 표시됩니다.
추측 단어 표시하기
구성 가능한 함수 GameLayout()
에서 사용자의 추측 단어를 업데이트하는 것은 GameScreen
에서 ViewModel
로 흐르는 이벤트 콜백 중 하나입니다. gameViewModel.userGuess
데이터는 ViewModel
에서 아래쪽의 GameScreen
으로 흐릅니다.
GameScreen.kt
파일의 구성 가능한 함수GameLayout()
에서onValueChange
를onUserGuessChanged
키보드 동작으로,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() }
),
- 구성 가능한 함수
GameLayout()
에서 인수 2개를 더 추가합니다.onUserGuessChanged
람다는String
인수를 받고 아무것도 반환하지 않으며onKeyboardDone
은 아무것도 받지 않고 아무것도 반환하지 않습니다.
@Composable
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
currentScrambledWord: String,
modifier: Modifier = Modifier,
) {
}
GameLayout()
함수 호출에서onUserGuessChanged
및onKeyboardDone
의 람다 인수를 추가합니다.
GameLayout(
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
currentScrambledWord = gameUiState.currentScrambledWord,
)
GameViewModel
의 updateUserGuess
메서드는 조금 뒤에 정의할 것입니다.
GameViewModel.kt
파일에서 사용자의 추측 단어인String
인수를 받는updateUserGuess()
라는 메서드를 추가합니다. 함수 안에서, 전달된guessedWord
로userGuess
를 업데이트합니다.
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
다음으로는 ViewModel에 userGuess
를 추가합니다.
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
GameScreen.kt
파일의GameLayout()
안에서userGuess
의String
매개변수를 추가합니다.OutlinedTextField
의value
매개변수를userGuess
로 설정합니다.
fun GameLayout(
currentScrambledWord: String,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
//...
OutlinedTextField(
value = userGuess,
//..
)
}
}
GameScreen
함수에서userGuess
매개변수를 포함하도록GameLayout()
함수 호출을 업데이트합니다.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
//...
)
- 앱을 빌드하고 실행합니다.
- 단어를 추측하고 입력해 봅니다. 텍스트 필드에 사용자가 추측한 단어가 표시됩니다.
7. 추측 단어 확인 및 점수 업데이트
이 작업에서는 사용자가 추측한 단어를 확인한 후 게임 점수를 업데이트하거나 오류를 표시하는 메서드를 구현합니다. 새 점수와 새 단어로 게임 상태 UI를 업데이트하는 작업은 뒤에서 진행합니다.
GameViewModel
에서checkUserGuess()
라는 메서드를 추가합니다.checkUserGuess()
함수에서 사용자의 추측이currentWord
과 같은지 확인하는if else
블록을 추가합니다.userGuess
를 빈 문자열로 재설정합니다.
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
}
// Reset user guess
updateUserGuess("")
}
- 사용자의 추측이 틀렸으면
isGuessedWordWrong
을true
로 설정합니다.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)
}
}
GameUiState
클래스에서isGuessedWordWrong
이라는Boolean
을 추가하고false
로 초기화합니다.
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
)
다음으로, 사용자가 Submit 버튼 또는 키보드의 완료 키를 클릭할 경우 이벤트 콜백 checkUserGuess()
를 GameScreen
에서 ViewModel
로 전달합니다. gameUiState.isGuessedWordWrong
데이터를 ViewModel
에서 아래쪽의 GameScreen
으로 전달하여 텍스트 필드에 오류를 설정합니다.
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))
}
- 구성 가능한 함수
GameScreen()
에서onKeyboardDone
람다 표현식의gameViewModel.checkUserGuess()
를 전달하도록GameLayout()
함수 호출을 업데이트합니다.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() }
)
- 구성 가능한 함수
GameLayout()
에서Boolean
의 함수 매개변수isGuessWrong
을 추가합니다. 사용자의 추측이 틀린 경우 텍스트 필드에 오류를 표시하도록OutlinedTextField
의isError
매개변수를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() }
),
)
}
}
- 구성 가능한 함수
GameScreen()
에서isGuessWrong
을 전달하도록GameLayout()
함수 호출을 업데이트합니다.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
// ...
)
- 앱을 빌드하고 실행합니다.
- 잘못된 추측을 입력하고 Submit을 클릭합니다. 텍스트 필드가 빨간색으로 표시되어 오류가 있음을 나타냅니다.
텍스트 필드 라벨은 'Enter your word'에서 바뀌지 않는 것을 볼 수 있습니다. 텍스트 필드를 사용자 친화적인 환경으로 만들려면 단어가 틀렸음을 나타내는 오류 텍스트를 추가해야 합니다.
GameScreen.kt
파일의 구성 가능한 함수GameLayout()
에서 다음과 같이isGuessWrong
에 따라 텍스트 필드의 라벨 매개변수를 업데이트합니다.
OutlinedTextField(
// ...
label = {
if (isGuessWrong) {
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
// ...
)
strings.xml
파일에서 오류 라벨에 문자열을 추가합니다.
<string name="wrong_guess">Wrong Guess!</string>
- 앱을 빌드하고 다시 실행합니다.
- 잘못된 추측을 입력하고 Submit을 클릭합니다. 오류 라벨을 확인합니다.
8. 점수 및 단어 수 업데이트
이 작업에서는 사용자가 게임을 플레이하는 과정에서 점수와 단어 수를 업데이트합니다. 점수는 _ uiState
에 속해야 합니다.
GameUiState
에서score
변수를 추가하고 0으로 초기화합니다.
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0
)
- 점수 값을 업데이트하려면
GameViewModel
의checkUserGuess()
함수의 사용자의 추측이 올바른 경우를 위한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 {
//...
}
}
GameViewModel
에서updateGameState
라는 또 다른 메서드를 추가하여 점수를 업데이트하고 현재 단어 수를 늘리고WordsData.kt
파일에서 새 단어를 선택합니다.updatedScore
라는 이름의Int
를 매개변수로 추가합니다. 게임 상태 UI 변수를 다음과 같이 업데이트합니다.
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
score = updatedScore
)
}
}
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("")
}
다음으로, 점수를 업데이트한 것과 마찬가지로 단어 수를 업데이트해야 합니다.
GameUiState
에서 단어 수를 세는 변수를 추가합니다. 이름을currentWordCount
로 지정하고1
으로 초기화합니다.
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
)
GameViewModel.kt
파일의updateGameState()
함수에서 다음과 같이 단어 수를 늘립니다.updateGameState()
함수는 다음 라운드를 위해 게임을 준비하기 위해 호출됩니다.
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
//...
currentWordCount = currentState.currentWordCount.inc(),
)
}
}
점수와 단어 수 전달하기
다음 단계에 따라 점수 및 단어 수 데이터를 ViewModel
에서 아래쪽의 GameScreen
으로 전달합니다.
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
)
// ...
}
- 단어 수를 포함하도록
GameLayout()
함수 호출을 업데이트합니다.
GameLayout(
userGuess = gameViewModel.userGuess,
wordCount = gameUiState.currentWordCount,
//...
)
- 구성 가능한 함수
GameScreen()
에서score
매개변수를 포함하도록GameStatus()
함수 호출을 업데이트합니다.gameUiState
에서 점수를 전달합니다.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
- 앱을 빌드하고 실행합니다.
- 추측 단어를 입력하고 Submit을 클릭합니다. 점수와 단어 수가 업데이트되는 것을 볼 수 있습니다.
- Skip을 클릭하면 아무것도 일어나지 않는 것을 볼 수 있습니다.
건너뛰기 기능을 구현하려면 GameViewModel
에 이벤트 건너뛰기 콜백을 전달해야 합니다.
GameScreen.kt
파일의 구성 가능한 함수GameScreen()
의onClick
람다 표현식에서gameViewModel.skipWord()
를 호출합니다.
아직 함수를 구현하지 않았기 때문에 Android 스튜디오에 오류가 표시됩니다. 다음 단계에서 skipWord()
메서드를 추가하여 이 오류를 수정할 것입니다. 사용자가 단어를 건너뛰면 게임 변수를 업데이트하고 다음 라운드를 위해 게임을 준비해야 합니다.
OutlinedButton(
onClick = { gameViewModel.skipWord() },
modifier = Modifier.fillMaxWidth()
) {
//...
}
GameViewModel
에서skipWord()
메서드를 추가합니다.skipWord()
함수에서updateGameState()
를 호출하여 점수를 전달하고 사용자 추측을 재설정합니다.
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}
- 앱을 실행하고 게임을 플레이합니다. 이제 단어를 건너뛸 수 있을 것입니다.
10단어를 초과해도 게임을 플레이할 수 있습니다. 다음 작업에서는 게임의 마지막 라운드를 처리합니다.
9. 게임의 마지막 라운드 처리
현재 구현에서는 사용자가 단어 10개를 넘게 건너뛰거나 플레이할 수 있습니다. 이 작업에서는 게임 종료 로직을 추가합니다.
게임 종료 로직을 구현하려면 먼저 사용자가 최대 단어 수에 도달하는지 확인해야 합니다.
GameViewModel
에서if-else
블록을 추가하고else
블록에서 기존 함수 본문을 이동합니다.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
)
}
}
}
if
블록에서Boolean
플래그isGameOver
를 추가하고 플래그를true
로 설정하여 게임 종료를 나타냅니다.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
)
}
}
}
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
)
- 앱을 실행하고 게임을 플레이합니다. 이제는 단어 10개를 초과하면 플레이할 수 없습니다.
게임이 끝나면 사용자에게 게임이 끝났음을 알리고 다시 플레이하고 싶은지 물어보는 기능을 구현하면 좋을 것입니다. 다음 작업에서 이 기능을 구현합니다.
게임 종료 대화상자 표시하기
이 작업에서는 ViewModel의 isGameOver
데이터를 아래쪽의 GameScreen
으로 전달하고 이를 사용하여 게임을 종료하거나 다시 시작하는 옵션이 있는 알림 대화상자를 표시합니다.
대화상자는 사용자에게 결정을 내리거나 추가 정보를 입력하라는 메시지를 표시하는 작은 창입니다. 일반적으로 대화상자는 전체 화면을 가득 채우지 않으며 사용자가 조치를 취해야 계속 진행할 수 있도록 합니다. Android는 다양한 유형의 대화상자를 제공합니다. 이 Codelab에서는 알림 대화상자에 관해 배웁니다.
알림 대화상자 분석
- 컨테이너
- 아이콘(선택사항)
- 제목(선택사항)
- 보조 문구
- 구분선(선택사항)
- 작업
시작 코드의 GameScreen.kt
파일은 게임을 종료하거나 다시 시작하는 옵션이 있는 알림 대화상자를 표시하는 함수를 제공합니다.
@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))
}
}
)
}
이 함수에서 title
및 text
매개변수는 알림 대화상자에 제목과 보조 문구를 표시합니다. dismissButton
과 confirmButton
은 텍스트 버튼입니다. dismissButton
매개변수에서는 Exit 텍스트를 표시하고 활동을 종료하여 앱을 종료합니다. confirmButton
매개변수에서는 게임을 다시 시작하고 Play Again 텍스트를 표시합니다.
GameScreen.kt
파일의FinalScoreDialog()
함수에서 점수 매개변수는 알림 대화상자에 게임 점수를 표시합니다.
@Composable
private fun FinalScoreDialog(
score: Int,
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
FinalScoreDialog()
함수에서text
매개변수 람다 표현식은score
를 대화상자 텍스트의 형식 인수로 사용합니다.
text = { Text(stringResource(R.string.you_scored, score)) }
GameScreen.kt
파일의 구성 가능한 함수GameScreen()
끝부분의Column
블록 뒤에gameUiState.isGameOver
를 확인하는if
조건을 추가합니다.if
블록에서 알림 대화상자를 표시합니다.onPlayAgain
이벤트 콜백의score
및gameViewModel.resetGame()
을 전달하는FinalScoreDialog()
를 호출합니다.
if (gameUiState.isGameOver) {
FinalScoreDialog(
score = gameUiState.score,
onPlayAgain = { gameViewModel.resetGame() }
)
}
resetGame()
은 GameScreen
에서 위쪽의 ViewModel
로 전달되는 이벤트 콜백입니다.
GameViewModel.kt
파일에서resetGame()
함수를 다시 호출하고_uiState
를 초기화하고 새 단어를 선택합니다.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- 앱을 빌드하고 실행합니다.
- 게임이 끝날 때까지 게임을 플레이한 후 게임 Exit 또는 Play Again 옵션이 있는 알림 대화상자를 관찰합니다. 알림 대화상자에 표시된 옵션을 사용해 봅니다.
10. 기기 회전 상태
이전 Codelab에서는 Android의 구성 변경사항에 관해 알아보았습니다. 구성 변경이 발생하면 Android가 활동을 처음부터 다시 시작하여 모든 수명 주기 시작 콜백을 실행합니다.
ViewModel
은 Android 프레임워크에서 활동이 소멸되고 다시 생성될 때 폐기되지 않는 앱 관련 데이터를 저장합니다. ViewModel
객체는 자동으로 보관되며 구성 변경 중에 활동 인스턴스처럼 소멸되지 않습니다. 객체가 보유하고 있는 데이터는 재구성 후에 즉시 사용 가능합니다.
이 작업에서는 구성 변경 중에 앱이 상태 UI를 유지하는지 확인합니다.
- 앱을 실행하고 단어 게임을 플레이합니다. 기기 구성을 세로에서 가로로 또는 그 반대로 변경합니다.
ViewModel
의 상태 UI에 저장된 데이터는 구성 변경 중에도 유지되는 것을 볼 수 있습니다.
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를 사용해 작업한 결과물을 소셜 미디어로 공유해 보세요.