Jetpack Compose의 상태

1. 시작하기 전에

이 Codelab에서는 Jetpack Compose에서 상태를 사용하는 것과 관련된 핵심 개념을 설명합니다. 앱의 상태에 따라 UI에 표시되는 항목이 결정되는 방식, 상태가 변경될 때 다양한 API를 사용해 Compose에서 UI를 업데이트하는 방법, 구성 가능한 함수의 구조를 최적화하는 방법, Compose 환경에서 ViewModel을 사용하는 방법을 보여줍니다.

기본 요건

학습할 내용

  • Jetpack Compose UI에서 상태 및 이벤트를 고려하는 방법
  • Compose에서 상태를 사용하여 화면에 표시할 요소를 결정하는 방법
  • 상태 호이스팅의 정의
  • 구성 가능한 스테이트풀(Stateful) 및 스테이트리스(Stateless) 함수의 작동 방식
  • Compose에서 State<T> API를 사용하여 상태를 자동으로 추적하는 방법
  • 구성 가능한 함수에서 메모리 및 내부 상태가 작동하는 방식: rememberrememberSaveable API 사용
  • 목록 및 상태를 사용하는 방법: mutableStateListOftoMutableStateList API 사용
  • Compose와 함께 ViewModel을 사용하는 방법

필요한 항목

권장/선택사항

빌드할 항목

간단한 Wellness 앱을 구현합니다.

775940a48311302b.png

앱에는 두 가지 주요 기능이 있습니다.

  • 물 섭취량을 추적하는 워터 카운터
  • 하루 동안 해야 할 웰니스 작업 목록

이 Codelab을 진행하는 동안 추가 지원을 받으려면 다음 코드를 함께 확인하세요.

2. 설정

새 Compose 프로젝트 시작

  1. 새 Compose 프로젝트를 시작하려면 Android 스튜디오를 엽니다.
  2. Welcome to Android Studio 창에 있다면 Start a new Android Studio project를 클릭합니다. 이미 Android 스튜디오 프로젝트가 열려 있다면 메뉴 바에서 File > New > New Project를 선택합니다.
  3. 새 프로젝트의 경우 제공되는 템플릿에서 Empty Activity를 선택합니다.

새 프로젝트

  1. Next를 클릭하고 'BasicStateCodelab'이라는 프로젝트를 구성합니다.

minimumSdkVersion으로 API 수준 21 이상을 선택해야 합니다. 이는 Compose에서 지원하는 최소 API 수준입니다.

Empty Compose Activity 템플릿을 선택하면 Android 스튜디오는 프로젝트에서 다음을 설정합니다.

  • 화면에 일부 텍스트를 표시하는 구성 가능한 함수로 구성된 MainActivity 클래스
  • 앱의 권한, 구성요소, 맞춤 리소스를 정의하는 AndroidManifest.xml 파일
  • Compose에 필요한 옵션과 종속 항목이 포함되어 있는 build.gradle.ktsapp/build.gradle.kts 파일

Codelab 솔루션

GitHub에서 BasicStateCodelab의 솔루션 코드를 가져올 수 있습니다.

$ git clone https://github.com/android/codelab-android-compose

또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.

솔루션 코드는 BasicStateCodelab 프로젝트에서 확인할 수 있습니다. 자신의 속도에 맞게 Codelab을 단계별로 진행하고 도움이 필요한 경우 솔루션을 확인하는 것이 좋습니다. Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다.

3. Compose의 상태

앱의 '상태'는 시간이 지남에 따라 변할 수 있는 값입니다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함됩니다.

모든 Android 앱에서는 사용자에게 상태가 표시됩니다. 다음은 Android 앱 상태의 몇 가지 예시입니다.

  • 채팅 앱에서 가장 최근에 수신된 메시지
  • 사용자의 프로필 사진
  • 항목 목록의 스크롤 위치

Wellness 앱을 작성해 보겠습니다.

편의상 Codelab을 진행하면서 다음을 실행합니다.

  • app 모듈의 루트 com.codelabs.basicstatecodelab 패키지에 모든 Kotlin 파일을 추가할 수 있습니다. 그러나 프로덕션 앱에서는 파일이 하위 패키지에 논리적으로 구조화되어야 합니다.
  • 스니펫에서 모든 문자열을 인라인으로 하드코딩합니다. 실제 앱에서는 strings.xml 파일에 문자열 리소스로 추가하고 Compose의 stringResource API를 사용하여 참조해야 합니다.

빌드해야 하는 첫 번째 기능은 하루 동안 마신 물잔 개수를 계산하는 워터 카운터입니다.

물잔 개수를 표시하는 Text 컴포저블이 포함된 WaterCounter라는 구성 가능한 함수를 만듭니다. 물잔 개수는 count라는 값에 저장해야 하며 이는 지금 하드코딩할 수 있습니다.

다음과 같이 구성 가능한 WaterCounter 함수를 사용하여 새 파일 WaterCounter.kt를 만듭니다.

import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   val count = 0
   Text(
       text = "You've had $count glasses.",
       modifier = modifier.padding(16.dp)
   )
}

전체 화면을 나타내는 구성 가능한 함수를 만들어 보겠습니다. 여기에는 2개의 섹션, 즉 워터 카운터와 웰니스 작업 목록이 있습니다. 지금은 카운터만 추가합니다.

  1. 기본 화면을 나타내는 WellnessScreen.kt 파일을 만들고 WaterCounter 함수를 호출합니다.
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   WaterCounter(modifier)
}
  1. MainActivity.kt를 엽니다. GreetingDefaultPreview 컴포저블을 삭제합니다. 다음과 같이 새로 만들어진 WellnessScreen 컴포저블을 Activity의 setContent 블록 내에서 호출합니다.
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           BasicStateCodelabTheme {
               // A surface container using the 'background' color from the theme
               Surface(
                   modifier = Modifier.fillMaxSize(),
                   color = MaterialTheme.colorScheme.background
               ) {
                   WellnessScreen()
               }
           }
       }
   }
}
  1. 지금 앱을 실행하면 하드코딩된 물잔 개수와 함께 기본 워터 카운터 화면이 표시됩니다.

7ed1e6fbd94bff04.jpeg

구성 가능한 WaterCounter 함수의 상태는 count 변수입니다. 그러나 정적 상태는 수정할 수 없기 때문에 그다지 유용하지 않습니다. 이 문제를 해결하려면 Button을 추가하여 개수를 늘리고 하루 동안 마신 물잔 개수를 추적합니다.

상태가 수정되도록 하는 작업을 '이벤트'라고 하며 다음 섹션에서 자세히 알아봅니다.

4. Compose의 이벤트

앞서 이야기했듯 상태는 시간이 지남에 따라 변하는 값(예: 채팅 앱에서 마지막으로 받은 메시지)입니다. 하지만 상태가 업데이트되는 이유는 무엇일까요? Android 앱에서는 이벤트에 대한 응답으로 상태가 업데이트됩니다.

이벤트는 애플리케이션 외부 또는 내부에서 생성되는 입력입니다. 예를 들면 다음과 같습니다.

  • 버튼 누르기 등으로 UI와 상호작용하는 사용자
  • 기타 요인(예: 새 값을 전송하는 센서 또는 네트워크 응답)

앱 상태로 UI에 표시할 항목에 관한 설명이 제공되고, 이벤트라는 메커니즘을 통해 상태가 변경되고 UI도 변경됩니다.

이벤트는 어떤 일이 발생했다고 프로그램 일부에 알려줍니다. 모든 Android 앱에는 다음과 같은 핵심 UI 업데이트 루프가 있습니다.

f415ca9336d83142.png

  • 이벤트: 이벤트는 사용자 또는 프로그램의 다른 부분에 의해 생성됩니다.
  • 상태 업데이트: 이벤트 핸들러가 UI에서 사용하는 상태를 변경합니다.
  • 상태 표시: 새로운 상태를 표시하도록 UI가 업데이트됩니다.

Compose에서 상태 관리는 상태와 이벤트가 서로 상호작용하는 방식을 이해하는 것이 핵심입니다.

이제 사용자가 물잔을 더 추가하여 상태를 수정할 수 있도록 버튼을 추가합니다.

구성 가능한 WaterCounter 함수로 이동하여 라벨 Text 아래에 Button을 추가합니다. Column을 사용하면 Button 컴포저블에 맞게 Text를 세로로 정렬할 수 있습니다. 외부 패딩을 Column 컴포저블로 이동하고 Button 상단에 추가 패딩을 더하여 Text에서 분리되도록 할 수 있습니다.

구성 가능한 Button 함수는 onClick 람다 함수를 수신합니다. 이는 버튼을 클릭할 때 발생하는 이벤트입니다. 람다 함수의 추가 예시는 나중에 알아봅니다.

countval 대신 var로 변경하여 변경 가능한 상태가 되도록 합니다.

import androidx.compose.material3.Button
import androidx.compose.foundation.layout.Column

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count = 0
       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

앱을 실행하고 버튼을 클릭해도 아무 일도 일어나지 않습니다. count 변수에 다른 값을 설정해도 Compose에서 이 값을 상태 변경으로 감지하지 않으므로 아무 일도 일어나지 않습니다. 이는 상태가 변경될 때 Compose에 화면을 다시 그려야 한다고(즉, 구성 가능한 함수를 '재구성') 알리지 않았기 때문입니다. 다음 단계에서 이 문제를 해결합니다.

e4dfc3bef967e0a1.gif

5. 구성 가능한 함수의 메모리

Compose 앱은 구성 가능한 함수를 호출하여 데이터를 UI로 변환합니다. 컴포저블을 실행할 때 Compose에서 빌드한 UI에 관한 설명을 컴포지션이라고 합니다. 상태가 변경되면 Compose는 영향을 받는 구성 가능한 함수를 새 상태로 다시 실행합니다. 그러면 리컴포지션이라는 업데이트된 UI가 만들어집니다. 또한 Compose는 데이터가 변경된 구성요소만 재구성하고 영향을 받지 않는 구성요소는 건너뛰도록 개별 컴포저블에 필요한 데이터를 확인합니다.

이렇게 하려면 Compose가 추적할 상태를 알아야 합니다. 그래야 업데이트를 받을 때 리컴포지션을 예약할 수 있습니다.

Compose에는 특정 상태를 읽는 컴포저블의 리컴포지션을 예약하는 특별한 상태 추적 시스템이 있습니다. 이를 통해 Compose가 세분화되어 전체 UI가 아닌 변경해야 하는 이러한 구성 가능한 함수만 재구성할 수 있습니다. 이 작업은 '쓰기'(즉, 상태 변경)뿐만 아니라 상태에 대한 '읽기'도 추적하여 실행됩니다.

Compose의 StateMutableState 유형을 사용하여 Compose에서 상태를 관찰할 수 있도록 합니다.

Compose는 상태 value 속성을 읽는 각 컴포저블을 추적하고 그 value가 변경되면 리컴포지션을 트리거합니다. mutableStateOf 함수를 사용하여 관찰 가능한 MutableState를 만들 수 있습니다. 이 함수는 초깃값을 State 객체에 래핑된 매개변수로 수신한 다음, value의 값을 관찰 가능한 상태로 만듭니다.

count가 초깃값이 0mutableStateOf API를 사용하도록 WaterCounter 컴포저블을 업데이트합니다. mutableStateOfMutableState 유형을 반환하므로 value를 업데이트하여 상태를 업데이트할 수 있고 Compose는 value를 읽는 이러한 함수에 리컴포지션을 트리거합니다.

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       // Changes to count are now tracked by Compose
       val count: MutableState<Int> = mutableStateOf(0)

       Text("You've had ${count.value} glasses.")
        Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

앞서 언급했듯이 count가 변경되면 countvalue를 자동으로 읽는 구성 가능한 함수의 리컴포지션이 예약됩니다. 이 경우 WaterCounter는 버튼을 클릭할 때마다 재구성됩니다.

지금 앱을 실행해도 여전히 아무 일도 일어나지 않습니다.

e4dfc3bef967e0a1.gif

리컴포지션 예약은 잘 작동합니다. 그러나 리컴포지션이 발생하면 count 변수가 다시 0으로 초기화되므로 리컴포지션 간에 이 값을 유지할 방법이 필요합니다.

이를 위해 구성 가능한 인라인 함수 remember를 사용할 수 있습니다. remember로 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 간에 유지됩니다.

일반적으로 remembermutableStateOf는 구성 가능한 함수에서 함께 사용됩니다.

Compose 상태 문서에 설명된 대로 이를 작성하는 몇 가지 유사한 방법은 다음과 같습니다.

WaterCounter를 수정하여 인라인 구성 가능한 함수 remembermutableStateOf 호출을 둘러쌉니다.

import androidx.compose.runtime.remember

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        val count: MutableState<Int> = remember { mutableStateOf(0) }
        Text("You've had ${count.value} glasses.")
        Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}

또는 Kotlin의 위임된 속성을 사용하여 count 사용을 간소화할 수 있습니다.

by 키워드를 사용하여 count를 var로 정의할 수 있습니다. 위임의 getter 및 setter 가져오기를 추가하면 매번 MutableStatevalue 속성을 명시적으로 참조하지 않고도 count를 간접적으로 읽고 변경할 수 있습니다.

이제 WaterCounter는 다음과 같이 표시됩니다.

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }

       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

작성 중인 컴포저블에서 가장 읽기 쉬운 코드를 생성하는 문법을 선택해야 합니다.

이제 지금까지 한 작업을 살펴보겠습니다.

  • 시간이 지남에 따라 기억하는 count라는 변수를 정의했습니다.
  • 기억한 숫자를 사용자에게 알려주는 텍스트 표시를 만들었습니다.
  • 클릭할 때마다 기억한 숫자를 늘리는 버튼을 추가했습니다.

다음 배열은 사용자와의 데이터 흐름 피드백 루프를 형성합니다.

  • UI에서 사용자에게 상태를 표시합니다(현재 개수가 텍스트로 표시됨).
  • 사용자가 기존 상태와 결합된 이벤트를 생성하여 새 상태를 생성합니다(버튼을 클릭하면 현재 개수에 하나가 추가됨).

이제 카운터가 작동할 준비를 마쳤습니다.

a9d78ead2c8362b6.gif

6. 상태 기반 UI

Compose는 선언형 UI 프레임워크입니다. 상태가 변경될 때 UI 구성요소를 삭제하거나 공개 상태를 변경하는 대신 특정 상태의 조건에서 UI가 어떻게 존재하는지 설명합니다. 재구성이 호출되고 UI가 업데이트된 결과, 컴포저블이 결국 컴포지션을 시작하거나 종료할 수 있습니다.

7d3509d136280b6c.png

이 접근 방식을 사용하면 뷰 시스템과 마찬가지로 뷰를 수동으로 업데이트하는 복잡성을 방지할 수 있습니다. 새 상태에 따라 뷰를 업데이트하는 일이 자동으로 발생하므로(개발자가 기억할 필요가 없음) 오류도 적게 발생합니다.

구성 가능한 함수가 초기 컴포지션 중에 또는 리컴포지션에서 호출되는 경우 컴포지션에 이 함수가 있는 것입니다. 호출되지 않는 구성 가능한 함수(예: 함수가 if 문 내에서 호출되는데 조건이 충족되지 않기 때문)는 컴포지션에 없습니다.

컴포저블의 수명 주기에 관한 자세한 내용은 문서를 참고하세요.

컴포지션의 출력은 UI를 설명하는 트리 구조입니다.

Android 스튜디오의 Layout Inspector 도구를 사용하여 Compose에서 생성된 앱 레이아웃을 검사할 수 있습니다. 바로 시작해 보겠습니다.

이를 보여주려면 상태에 따라 UI를 표시하도록 코드를 수정합니다. WaterCounter를 열고 count가 0보다 크면 Text를 표시합니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }

       if (count > 0) {
           // This text is present if the button has been clicked
           // at least once; absent otherwise
           Text("You've had $count glasses.")
       }
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

앱을 실행하고 Android 스튜디오에서 Tools > Layout Inspector로 이동하여 Layout Inspector 도구를 엽니다.

분할 화면이 표시됩니다. 왼쪽에는 구성요소 트리가, 오른쪽에는 앱 미리보기가 있습니다.

화면 왼쪽에서 루트 요소 BasicStateCodelabTheme을 탭하여 트리를 탐색합니다. 모두 펼치기 버튼을 클릭하여 구성요소 트리 전체를 펼칩니다.

오른쪽 화면의 요소를 클릭하면 트리에서 상응하는 요소로 이동합니다.

677bc0a178670de8.png

앱에서 Add one 버튼을 누르면 다음 작업이 실행됩니다.

  • 개수가 1로 증가하고 상태가 변경됩니다.
  • 리컴포지션이 호출됩니다.
  • 화면이 새 요소로 재구성됩니다.

Android 스튜디오의 Layout Inspector 도구로 구성요소 트리를 검사하면 이제 Text 컴포저블도 표시됩니다.

1f8e05f6497ec35f.png

상태는 특정 순간에 UI에 표시되는 요소를 유도합니다.

UI의 여러 부분이 동일한 상태에 종속될 수 있습니다. Button을 수정하여 count가 10이 될 때까지 사용 설정되고 그 후에 사용 중지되도록 합니다. 그러면 목표를 달성하는 것입니다. Buttonenabled 매개변수를 사용하면 됩니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    ...
        Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
    ...
}

이제 앱을 실행합니다. count 상태의 변경으로 인해 Text의 표시 여부와 Button의 사용 설정 여부가 결정됩니다.

1a8f4095e384ba01.gif

7. 컴포지션의 Remember

remember는 컴포지션에 객체를 저장하고, remember가 호출되는 소스 위치가 리컴포지션 중에 다시 호출되지 않으면 객체를 삭제합니다.

이 동작을 시각화하려면 앱에 다음 기능을 구현합니다. 사용자가 물을 한 잔 이상 마셨을 때 사용자가 할 웰니스 작업을 표시하고 닫을 수 있도록 합니다. 컴포저블은 작고 재사용 가능해야 하므로 WellnessTaskItem이라는 새 컴포저블을 만듭니다. 이 컴포저블은 매개변수로 수신된 문자열에 기반하여 웰니스 작업을 표시하고 닫기 아이콘 버튼을 표시합니다.

새 파일 WellnessTaskItem.kt를 만들고 다음 코드를 추가합니다. 이 Codelab의 후반부에서 이 구성 가능한 함수를 사용합니다.

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding

@Composable
fun WellnessTaskItem(
    taskName: String,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier.weight(1f).padding(start = 16.dp),
            text = taskName
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

WellnessTaskItem 함수는 내장된 Button 컴포저블이 onClick을 수신하는 것과 마찬가지로 작업 설명과 onClose 람다 함수를 수신합니다.

WellnessTaskItem은 다음과 같이 표시됩니다.

6e8b72a529e8dedd.png

더 많은 기능으로 앱을 개선하려면 count가 0보다 클 때 WellnessTaskItem이 표시되도록 WaterCounter를 업데이트하세요.

count가 0보다 크면 WellnessTaskItem 표시 여부를 결정하는 showTask 변수를 정의하고 true로 초기화합니다.

showTask가 true인 경우 WellnessTaskItem을 표시하도록 새 if 문을 추가합니다. 이전 섹션에서 배운 API를 사용하여 showTask 값이 리컴포지션에도 유지되도록 합니다.

@Composable
fun WaterCounter() {
   Column(modifier = Modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }
       if (count > 0) {
           var showTask by remember { mutableStateOf(true) }
           if (showTask) {
               WellnessTaskItem(
                   onClose = { },
                   taskName = "Have you taken your 15 minute walk today?"
               )
           }
           Text("You've had $count glasses.")
       }

       Button(onClick = { count++ }, enabled = count < 10) {
           Text("Add one")
       }
   }
}

X 버튼을 누르면 showTask 변수가 false로 변경되어 작업이 더 이상 표시되지 않도록 WellnessTaskItemonClose 람다 함수를 사용합니다.

   ...
   WellnessTaskItem(
      onClose = { showTask = false },
      taskName = "Have you taken your 15 minute walk today?"
   )
   ...

그런 다음 'Clear water count'라는 텍스트가 포함된 새 Button을 추가하고 'Add one' Button 옆에 배치합니다. Row를 사용하면 두 버튼을 더 쉽게 정렬할 수 있습니다. Row에 패딩을 추가할 수도 있습니다. 'Clear water count' 버튼을 누르면 count 변수가 다시 0으로 재설정됩니다.

구성 가능한 함수 WaterCounter는 다음과 같습니다.

import androidx.compose.foundation.layout.Row

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }
       if (count > 0) {
           var showTask by remember { mutableStateOf(true) }
           if (showTask) {
               WellnessTaskItem(
                   onClose = { showTask = false },
                   taskName = "Have you taken your 15 minute walk today?"
               )
           }
           Text("You've had $count glasses.")
       }

       Row(Modifier.padding(top = 8.dp)) {
           Button(onClick = { count++ }, enabled = count < 10) {
               Text("Add one")
           }
           Button(
               onClick = { count = 0 },
               Modifier.padding(start = 8.dp)) {
                   Text("Clear water count")
           }
       }
   }
}

앱을 실행하면 화면에 초기 상태가 표시됩니다.

개수가 0개인 앱의 초기 상태를 보여주는 구성요소 트리 다이어그램

오른쪽에는 단순화된 버전의 구성요소 트리가 있어 상태 변경 시 발생하는 상황을 분석할 수 있습니다. countshowTask는 기억된 값입니다.

이제 앱에서 다음 단계를 따르면 됩니다.

  • Add one 버튼을 누릅니다. 그러면 count가 증가하고(리컴포지션이 발생함) WellnessTaskItem 및 카운터 Text가 모두 표시되기 시작합니다.

상태 변경을 보여주는 구성요소 트리 다이어그램. Add one 버튼을 클릭하면 도움말이 포함된 텍스트와 잔 개수가 포함된 텍스트가 표시됨

865af0485f205c28.png

  • WellnessTaskItem 구성요소의 X를 누릅니다(또 다른 리컴포지션이 발생함). 이제 showTask가 false이므로 WellnessTaskItem이 더 이상 표시되지 않습니다.

닫기 버튼을 클릭하면 작업 컴포저블이 사라지는 것을 보여주는 구성요소 트리 다이어그램

82b5dadce9cca927.png

  • Add one 버튼을 누릅니다(또 다른 리컴포지션 발생). showTask는 잔 개수를 계속 추가하면 다음 리컴포지션에서 WellnessTaskItem을 닫았음을 기억합니다.

  • Clear water count 버튼을 눌러 count를 0으로 재설정하면 리컴포지션이 발생합니다. count를 표시하는 TextWellnessTaskItem과 관련된 모든 코드가 호출되지 않고 컴포지션을 종료합니다.

ae993e6ddc0d654a.png

  • remember showTask가 호출되는 코드 위치가 호출되지 않았으므로 showTask가 삭제되었습니다. 다시 첫 번째 단계로 돌아왔습니다.

  • Add one 버튼을 눌러 count를 0보다 크게 만듭니다(리컴포지션).

7624eed0848a145c.png

  • WellnessTaskItem 컴포저블이 다시 표시됩니다. 위의 컴포지션을 종료할 때 showTask의 이전 값이 삭제되었기 때문입니다.

count가 0으로 돌아간 후 remember에서 허용하는 것보다 더 오래 showTask를 유지해야 한다면(즉, remember가 호출되는 코드 위치가 리컴포지션 중에 호출되지 않더라도) 어떻게 될까요? 다음 섹션에서는 이러한 시나리오를 해결하는 방법과 더 많은 예를 살펴봅니다.

이제 UI와 상태가 컴포지션을 종료할 때 재설정되는 방식을 이해했으므로 코드를 삭제하고 이 섹션의 시작 부분에 있었던 WaterCounter로 돌아갑니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count > 0) {
            Text("You've had $count glasses.")
        }
        Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
            Text("Add one")
        }
    }
}

8. Compose에서 상태 복원

앱을 실행하고 물잔을 카운터에 추가한 다음 기기를 회전합니다. 기기의 자동 회전 설정이 켜져 있어야 합니다.

활동은 구성 변경(이 경우 방향) 후에 다시 생성되므로 저장된 상태는 삭제됩니다. 카운터가 0으로 돌아가면서 사라집니다.

2c1134ad78e4b68a.gif

언어를 변경하거나 어두운 모드와 밝은 모드 간에 전환하거나 실행 중인 활동을 Android에서 다시 생성하게 하는 다른 구성 변경을 하는 경우에도 같은 상황이 발생합니다.

remember를 사용하면 리컴포지션 간에 상태를 유지하는 데 도움이 되지만 구성 변경 간에는 유지되지 않습니다. 이를 위해서는 remember 대신 rememberSaveable을 사용해야 합니다.

rememberSaveableBundle에 저장할 수 있는 모든 값을 자동으로 저장합니다. 다른 값의 경우에는 맞춤 Saver 객체를 전달할 수 있습니다. Compose에서 상태 복원에 관한 자세한 내용은 문서를 참고하세요.

WaterCounter에서 rememberrememberSaveable로 바꿉니다.

import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
        ...
        var count by rememberSaveable { mutableStateOf(0) }
        ...
}

지금 앱을 실행하여 몇 가지 구성 변경을 시도해 보세요. 카운터가 제대로 저장되는 것을 확인할 수 있습니다.

bf2e1634eff47697.gif

활동 재생성은 rememberSaveable의 사용 사례 중 하나일 뿐입니다. 목록을 사용하면서 나중에 다른 사용 사례를 살펴보겠습니다.

앱의 상태 및 UX 요구사항에 따라 remember를 사용할지 rememberSaveable을 사용할지 고려합니다.

9. 상태 호이스팅

remember를 사용하여 객체를 저장하는 컴포저블에는 내부 상태가 포함되며 이는 컴포저블을 스테이트풀(Stateful)로 만듭니다. 이는 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용합니다. 그러나 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있습니다.

상태를 보유하지 않는 컴포저블을 스테이트리스(Stateless) 컴포저블이라고 합니다. 상태 호이스팅을 사용하면 스테이트리스(Stateless) 컴포저블을 쉽게 만들 수 있습니다.

Compose에서 상태 호이스팅은 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴입니다. Jetpack Compose에서 상태 호이스팅을 위한 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾸는 것입니다.

  • value: T - 표시할 현재 값입니다.
  • onValueChange: (T) -> Unit - 값이 새 값 T로 변경되도록 요청하는 이벤트입니다.

여기서 이 값은 수정할 수 있는 모든 상태를 나타냅니다.

이러한 방식으로 끌어올린 상태에는 중요한 속성이 몇 가지 있습니다.

  • 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 소스 저장소가 하나만 있습니다. 버그 방지에 도움이 됩니다.
  • 공유 가능함: 끌어올린 상태를 여러 컴포저블과 공유할 수 있습니다.
  • 가로채기 가능함: 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다.
  • 분리됨: 구성 가능한 스테이트리스(Stateless) 함수의 상태는 어디에든(예: ViewModel) 저장할 수 있습니다.

위의 모든 이점을 활용하려면 WaterCounter에 이를 구현해 보세요.

스테이트풀(Stateful)과 스테이트리스(Stateless) 비교

구성 가능한 함수에서 모든 상태를 추출할 수 있는 경우 결과로 생성되는 구성 가능한 함수를 스테이트리스(Stateless)라고 합니다.

스테이트풀(Stateful)과 스테이트리스(Stateless) 카운터라는 두 부분으로 분할하여 WaterCounter 컴포저블을 리팩터링합니다.

StatelessCounter의 역할은 count를 표시하고 count를 늘릴 때 함수를 호출하는 것입니다. 이렇게 하려면 위에 설명된 패턴을 따르고 count 상태(구성 가능한 함수에 매개변수로)와 onIncrement 람다(상태가 증가해야 할 때 호출됨)를 전달합니다. StatelessCounter는 다음과 같이 표시됩니다.

@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       if (count > 0) {
           Text("You've had $count glasses.")
       }
       Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
           Text("Add one")
       }
   }
}

StatefulCounter는 상태를 소유합니다. 즉, count 상태를 보유하고 StatelessCounter 함수를 호출할 때 이 상태를 수정합니다.

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
   var count by rememberSaveable { mutableStateOf(0) }
   StatelessCounter(count, { count++ }, modifier)
}

잘하셨습니다. countStatelessCounter에서 StatefulCounter끌어올렸습니다.

이를 앱에 연결하고 StatefulCounterWellnessScreen을 업데이트할 수 있습니다.

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   StatefulCounter(modifier)
}

앞서 설명했듯이 상태 호이스팅에는 몇 가지 이점이 있습니다. 이 코드를 변경하여 이점을 설명해 보겠습니다. 앱에서 다음 스니펫을 복사하지 않아도 됩니다.

  1. 이제 스테이트리스(Stateless) 컴포저블을 재사용할 수 있습니다. 다음 예를 살펴보겠습니다.

물과 주스의 잔 개수를 계산하려면 waterCountjuiceCount를 기억하고 동일한 StatelessCounter 구성 가능한 함수를 사용하여 서로 다른 두 가지 독립 상태를 표시합니다.

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }

    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}

8211bd9e0a4c5db2.png

juiceCount가 수정되면 StatefulCounter가 재구성됩니다. 리컴포지션 중에 Compose는 juiceCount를 읽는 함수를 식별하고 이러한 함수의 리컴포지션만 트리거합니다.

2cb0dcdbe75dcfbf.png

사용자가 탭하여 juiceCount를 늘리면 StatefulCounter가 재구성되고 juiceCount를 읽는 StatelessCounter도 재구성됩니다. 하지만 waterCount를 읽는 StatelessCounter는 재구성되지 않습니다.

7fe6ee3d2886abd0.png

  1. 구성 가능한 스테이트풀(Stateful) 함수는 여러 구성 가능한 함수에 동일한 상태를 제공할 수 있습니다.
@Composable
fun StatefulCounter() {
   var count by remember { mutableStateOf(0) }

   StatelessCounter(count, { count++ })
   AnotherStatelessMethod(count, { count *= 2 })
}

이 경우 개수가 StatelessCounter 또는 AnotherStatelessMethod에 의해 업데이트되면 예상대로 모든 항목이 재구성됩니다.

끌어올린 상태는 공유할 수 있으므로 불필요한 리컴포지션을 방지하고 재사용성을 높이려면 컴포저블에 필요한 상태만 전달해야 합니다.

상태 및 상태 호이스팅에 관한 자세한 내용은 Compose 상태 문서를 참고하세요.

10. 목록 사용

이제 앱의 두 번째 기능인 웰니스 작업 목록을 추가합니다. 목록에 있는 항목으로 다음 두 가지 작업을 할 수 있습니다.

  • 작업을 완료로 표시하려면 목록 항목을 선택합니다.
  • 완료하는 데 관심이 없는 작업을 목록에서 삭제합니다.

설정

  1. 먼저 목록 항목을 수정합니다. 컴포지션의 Remember 섹션의 WellnessTaskItem을 재사용하여 Checkbox를 포함하도록 업데이트하면 됩니다. 함수를 스테이트리스(Stateless)로 만들려면 checked 상태와 onCheckedChange 콜백을 호이스팅해야 합니다.

a0f8724cfd33cb10.png

이 섹션의 WellnessTaskItem 컴포저블은 다음과 같습니다.

import androidx.compose.material3.Checkbox

@Composable
fun WellnessTaskItem(
    taskName: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp),
            text = taskName
        )
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}
  1. 같은 파일에서 상태 변수 checkedState를 정의하고 동일한 이름의 스테이트리스(Stateless) 메서드에 이를 전달하는 구성 가능한 스테이트풀(Stateful) WellnessTaskItem 함수를 추가합니다. 지금은 onClose에 관해 걱정하지 마세요. 빈 람다 함수를 전달해도 됩니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
   var checkedState by remember { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = {}, // we will implement this later!
       modifier = modifier,
   )
}
  1. WellnessTask.kt 파일을 만들어 ID와 라벨이 포함된 작업을 모델링합니다. 이를 데이터 클래스로 정의합니다.
data class WellnessTask(val id: Int, val label: String)
  1. 작업 목록 자체의 경우 WellnessTasksList.kt라는 새 파일을 만들고 가짜 데이터를 생성하는 메서드를 추가합니다.
fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

실제 앱에서는 데이터 영역에서 데이터를 가져옵니다.

  1. WellnessTasksList.kt에서 목록을 만드는 구성 가능한 함수를 추가합니다. 직접 만든 목록 메서드에서 LazyColumn 및 항목을 정의합니다. 도움이 필요한 경우 목록 문서를 참고하세요.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember

@Composable
fun WellnessTasksList(
    modifier: Modifier = Modifier,
    list: List<WellnessTask> = remember { getWellnessTasks() }
) {
    LazyColumn(
        modifier = modifier
    ) {
        items(list) { task ->
            WellnessTaskItem(taskName = task.label)
        }
    }
}
  1. 목록을 WellnessScreen에 추가합니다. Column을 사용하면 이미 있는 카운터와 목록을 세로로 정렬하는 데 도움이 됩니다.
import androidx.compose.foundation.layout.Column

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()
       WellnessTasksList()
   }
}
  1. 앱을 실행하여 사용해 보세요. 이제 작업을 선택할 수 있지만 삭제할 수는 없습니다. 이 작업은 이후 섹션에서 구현합니다.

f9cbc49c960fd24c.gif

LazyList에서 항목 상태 복원

이제 WellnessTaskItem 컴포저블의 몇 가지 요소를 자세히 살펴보겠습니다.

checkedState는 비공개 변수처럼 각 WellnessTaskItem 컴포저블에 독립적으로 속합니다. checkedState가 변경되면 WellnessTaskItem의 그 인스턴스만 재구성되며 LazyColumn의 모든 WellnessTaskItem 인스턴스가 재구성되는 것은 아닙니다.

다음 단계에 따라 시도해 보세요.

  1. 이 목록 상단에 있는 요소(예: 요소 1, 2)를 선택합니다.
  2. 화면 밖으로 나가도록 목록 하단으로 스크롤합니다.
  3. 앞서 선택한 항목까지 상단으로 다시 스크롤합니다.
  4. 선택 해제되어 있습니다.

이전 섹션에서 살펴본 것처럼 항목이 컴포지션을 종료하면 기억된 상태가 삭제된다는 문제가 있습니다. LazyColumn에 있는 항목의 경우 스크롤하면서 항목을 지나치면 항목이 컴포지션을 완전히 종료하므로 더 이상 항목이 표시되지 않습니다.

a68b5473354d92df.gif

이 문제를 해결하려면 어떻게 해야 할까요? rememberSaveable을 다시 사용하세요. 저장된 인스턴스 상태 메커니즘을 사용하여 활동 또는 프로세스 재생성 후에도 상태가 유지됩니다. rememberSaveableLazyList와 함께 작동하는 방식 덕분에 항목은 컴포지션을 종료해도 유지될 수 있습니다.

스테이트풀(Stateful) WellnessTaskItem에서 rememberrememberSaveable로 바꾸기만 하면 됩니다.

import androidx.compose.runtime.saveable.rememberSaveable

var checkedState by rememberSaveable { mutableStateOf(false) }

85796fb49cf5dd16.gif

Compose의 일반적인 패턴

LazyColumn의 구현을 확인합니다.

@Composable
fun LazyColumn(
...
    state: LazyListState = rememberLazyListState(),
...

구성 가능한 함수 rememberLazyListStaterememberSaveable을 사용하여 목록의 초기 상태를 만듭니다. 활동이 다시 생성되면 스크롤 상태는 아무런 코딩을 하지 않아도 유지됩니다.

많은 앱이 스크롤 위치, 항목 레이아웃 변경사항, 목록의 상태와 관련된 기타 이벤트에 반응하고 이를 수신 대기해야 합니다. LazyColumn 또는 LazyRow와 같은 지연 구성요소는 LazyListState를 끌어올려 이 사용 사례를 지원합니다. 이 패턴에 관한 자세한 내용은 목록의 상태 문서를 참고하세요.

공개 rememberX 함수에서 제공하는 기본값이 포함된 상태 매개변수가 있는 것이 구성 가능한 내장 함수에서 일반적인 패턴입니다. rememberBottomSheetScaffoldState를 사용하여 상태를 끌어올리는 BottomSheetScaffold에서 또 다른 예를 확인할 수 있습니다.

11. 관찰 가능한 MutableList

이제 목록에서 작업을 삭제하는 동작을 추가하려면 먼저 목록을 변경 가능한 목록으로 만들어야 합니다.

이를 위해 변경 가능한 객체(예: ArrayList<T> 또는 mutableListOf,)를 사용하면 작동하지 않습니다. 이러한 유형은 목록의 항목이 변경되었고 UI의 리컴포지션을 예약한다고 Compose에 알리지 않습니다. 다른 API가 필요합니다.

Compose에서 관찰할 수 있는 MutableList 인스턴스를 만들어야 합니다. 이 구조를 사용하면 Compose가 항목이 추가되거나 목록에서 삭제될 때 변경사항을 추적하여 UI를 재구성할 수 있습니다.

먼저 관찰 가능한 MutableList를 정의합니다. 확장 함수 toMutableStateList()를 사용하면 변경 가능하거나 변경 불가능한 초기 Collection(예: List)에서 관찰 가능한 MutableList를 만들 수 있습니다.

또는 팩토리 메서드 mutableStateListOf를 사용하여 관찰 가능한 MutableList를 만들고 초기 상태의 요소를 추가할 수도 있습니다.

  1. WellnessScreen.kt 파일을 엽니다. getWellnessTasks 메서드를 이 파일로 이동해야 사용할 수 있습니다. 먼저 getWellnessTasks()를 호출하고 이전에 배운 확장 함수 toMutableStateList를 사용하여 목록을 만듭니다.
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
  1. 목록의 기본값을 삭제하여 구성 가능한 WellnessTasksList 함수를 수정합니다. 목록이 화면 수준으로 끌어올려지기 때문입니다. 새 람다 함수 매개변수 onCloseTask를 추가합니다(삭제할 WellnessTask 수신). onCloseTaskWellnessTaskItem에 전달합니다.

한 가지를 더 변경해야 합니다. items 메서드는 key 매개변수를 수신합니다. 기본적으로 각 항목의 상태는 목록에 있는 항목의 위치를 기준으로 키가 지정됩니다.

변경 가능한 목록에서는 데이터 세트가 변경될 때 문제가 발생합니다. 위치를 변경하는 항목은 기억된 상태를 사실상 잃기 때문입니다.

이 문제는 각 WellnessTaskItemid를 각 항목의 키로 사용하면 쉽게 해결할 수 있습니다.

목록의 항목 키에 관한 자세한 내용은 문서를 참고하세요.

WellnessTasksList는 다음과 같습니다.

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(modifier = modifier) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
       }
   }
}
  1. WellnessTaskItem을 수정합니다. onClose 람다 함수를 스테이트풀(Stateful) WellnessTaskItem에 매개변수로 추가하고 이를 호출합니다.
@Composable
fun WellnessTaskItem(
   taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = onClose,
       modifier = modifier,
   )
}

잘하셨습니다. 기능이 완료되어 목록에서 항목을 삭제할 수 있습니다.

각 행에서 X를 클릭하면 이벤트가 상태를 소유하는 목록까지 위로 이동하므로 목록에서 항목을 삭제할 수 있고 Compose는 화면을 재구성할 수 있습니다.

47f4a64c7e9a5083.png

rememberSaveable()를 사용하여 WellnessScreen에 목록을 저장하려고 하면 런타임 예외가 발생합니다.

이 오류는 맞춤 Saver를 제공해야 함을 나타냅니다. 그러나 긴 직렬화 또는 역직렬화가 필요한 복잡한 데이터 구조나 대량의 데이터를 저장하는 데 rememberSaveable을 사용해서는 안 됩니다.

Activity의 onSaveInstanceState를 사용할 때도 유사한 규칙이 적용됩니다. 자세한 내용은 UI 상태 저장 문서를 참고하세요. 이 작업을 실행하려면 대체 저장 메커니즘이 필요합니다. 다양한 UI 상태 유지 옵션에 관한 자세한 내용은 문서를 참고하세요.

이제 앱 상태 홀더로서 ViewModel의 역할을 살펴보겠습니다.

12. ViewModel의 상태

화면 또는 UI 상태는 화면에 표시할 내용을 나타냅니다(예: 작업 목록). 이 상태는 애플리케이션 데이터를 포함하므로 대개 계층 구조의 다른 레이어에 연결됩니다.

UI 상태는 화면에 표시할 내용을 설명하지만 앱의 로직은 앱의 동작 방식을 설명하고 상태 변경에 반응해야 합니다. 로직 유형에는 두 가지가 있습니다. UI 동작 또는 UI 로직과 비즈니스 로직입니다.

  • UI 로직은 화면에 상태 변경을 표시하는 방법(예: 탐색 로직 또는 스낵바 표시)과 관련이 있습니다.
  • 비즈니스 로직은 상태 변경 시(예: 결제하기 또는 사용자 환경설정 저장) 실행할 작업입니다. 이 로직은 대개 비즈니스 레이어나 데이터 영역에 배치되고 UI 레이어에는 배치되지 않습니다.

ViewModel은 UI 상태와 앱의 다른 레이어에 있는 비즈니스 로직에 대한 액세스 권한을 제공합니다. 또한 ViewModel은 구성 변경 후에도 유지되므로 컴포지션보다 전체 기간이 더 깁니다. Compose 콘텐츠 호스트의 수명 주기(즉, 활동이나 프래그먼트, Compose Navigation을 사용하는 경우 탐색 그래프의 대상)를 따를 수 있습니다.

아키텍처 및 UI 레이어에 관한 자세한 내용은 UI 레이어 문서를 참고하세요.

목록 이전 및 메서드 삭제

이전 단계에서는 구성 가능한 함수에서 상태를 직접 관리하는 방법을 보여주었지만 UI 로직과 비즈니스 로직을 UI 상태와 분리하여 ViewModel로 이전하는 것이 좋습니다.

UI 상태, 목록을 ViewModel로 이전하고 비즈니스 로직도 ViewModel로 추출해 보겠습니다.

  1. WellnessViewModel.kt 파일을 만들어 ViewModel 클래스를 추가합니다.

'데이터 소스' getWellnessTasks()WellnessViewModel로 이동합니다.

이전과 마찬가지로 toMutableStateList를 사용하여 내부 _tasks 변수를 정의하고 tasks를 목록으로 노출하여 ViewModel 외부에서 수정할 수 없도록 합니다.

목록의 내장 remove 함수에 위임하는 간단한 remove 함수를 구현합니다.

import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel

class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
        get() = _tasks

   fun remove(item: WellnessTask) {
       _tasks.remove(item)
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
  1. viewModel() 함수를 호출하여 컴포저블에서 이 ViewModel에 액세스할 수 있습니다.

이 함수를 사용하려면 app/build.gradle.kts 파일을 열고 다음 라이브러리를 추가한 후 Android 스튜디오에서 새 종속 항목을 동기화합니다.

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}")

Android 스튜디오 Giraffe를 사용할 때는 2.6.2 버전을 사용하세요. 또는 여기에서 최신 버전의 라이브러리를 확인하세요.

  1. WellnessScreen을 엽니다. 화면 컴포저블의 매개변수로 viewModel()을 호출하여 wellnessViewModel ViewModel을 인스턴스화합니다. 따라서 이 컴포저블을 테스트할 때 교체하고 필요에 따라 끌어올릴 수 있습니다. WellnessTasksList에 작업 목록을 제공하고 onCloseTask 람다에 remove 함수를 제공합니다.
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier,
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCloseTask = { task -> wellnessViewModel.remove(task) })
   }
}

viewModel()은 기존 ViewModel을 반환하거나 지정된 범위에서 새 ViewModel을 생성합니다. ViewModel 인스턴스는 범위가 활성화되어 있는 동안 유지됩니다. 예를 들어 컴포저블이 활동에서 사용되는 경우 viewModel()은 활동이 완료되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환합니다.

이제 모두 완료되었습니다. 상태 일부와 비즈니스 로직이 포함된 ViewModel을 화면과 통합했습니다. 상태는 컴포지션 외부에 유지되고 ViewModel에 의해 저장되므로 목록의 변형은 구성이 변경되어도 유지됩니다.

ViewModel은 어떤 시나리오에서도 앱의 상태를 자동으로 유지하지 않습니다(예: 시스템에서 시작된 프로세스 중단의 경우). 앱의 UI 상태 유지에 관한 자세한 내용은 문서를 참고하세요.

선택한 상태 이전

마지막 리팩터링은 선택된 상태와 로직을 ViewModel로 이전하는 것입니다. 이렇게 하면 모든 상태가 ViewModel에서 관리되므로 코드가 더 간단해지고 테스트하기 쉬워집니다.

  1. 먼저 선택된 상태를 저장하고 false를 기본값으로 설정할 수 있도록 WellnessTask 모델 클래스를 수정합니다.
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
  1. ViewModel에서 선택된 상태의 새 값으로 수정할 작업을 수신하는 changeTaskChecked 메서드를 구현합니다.
class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       _tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}
  1. WellnessScreen에서 ViewModel의 changeTaskChecked 메서드를 호출하여 목록의 onCheckedTask 동작을 제공합니다. 이제 함수가 다음과 같이 표시됩니다.
@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier, 
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCheckedTask = { task, checked ->
               wellnessViewModel.changeTaskChecked(task, checked)
           },
           onCloseTask = { task ->
               wellnessViewModel.remove(task)
           }
       )
   }
}
  1. WellnessTasksList를 열고 WellnessTaskItem.에 전달할 수 있도록 onCheckedTask 람다 함수 매개변수를 추가합니다.
@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCheckedTask: (WellnessTask, Boolean) -> Unit,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(
       modifier = modifier
   ) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(
               taskName = task.label,
               checked = task.checked,
               onCheckedChange = { checked -> onCheckedTask(task, checked) },
               onClose = { onCloseTask(task) }
           )
       }
   }
}
  1. WellnessTaskItem.kt 파일을 정리합니다. CheckBox 상태가 목록 수준으로 끌어올려지므로 더 이상 스테이트풀(Stateful) 메서드가 필요하지 않습니다. 파일에는 다음과 같은 구성 가능한 함수만 있습니다.
@Composable
fun WellnessTaskItem(
   taskName: String,
   checked: Boolean,
   onCheckedChange: (Boolean) -> Unit,
   onClose: () -> Unit,
   modifier: Modifier = Modifier
) {
   Row(
       modifier = modifier, verticalAlignment = Alignment.CenterVertically
   ) {
       Text(
           modifier = Modifier
               .weight(1f)
               .padding(start = 16.dp),
           text = taskName
       )
       Checkbox(
           checked = checked,
           onCheckedChange = onCheckedChange
       )
       IconButton(onClick = onClose) {
           Icon(Icons.Filled.Close, contentDescription = "Close")
       }
   }
}
  1. 앱을 실행하고 작업을 선택해 봅니다. 작업 선택이 아직 잘 작동하지 않습니다.

1d08ebcade1b9302.gif

이는 Compose에서 MutableList를 위해 추적하는 것이 요소 추가 및 삭제와 관련된 변경사항이기 때문입니다. 삭제가 작동하는 이유가 바로 이것입니다. 하지만 추적하도록 지시하지 않는 한 행 항목 값(여기서는 checkedState)의 변경사항을 인식하지 못합니다.

문제를 해결하는 두 가지 방법은 다음과 같습니다.

  • 데이터 클래스 WellnessTask를 변경하여 checkedStateBoolean 대신 MutableState<Boolean>이 되도록 합니다. 그러면 Compose에서 항목 변경사항을 추적합니다.
  • 변경하려는 항목을 복사하고 목록에서 항목을 삭제한 후 변경된 항목을 다시 목록에 추가합니다. 그러면 Compose에서 이 목록 변경사항을 추적합니다.

두 가지 방법에는 모두 장단점이 있습니다. 예를 들어 사용 중인 목록의 구현에 따라 요소를 삭제하고 읽는 데 비용이 많이 들 수 있습니다.

잠재적으로 비용이 많이 드는 목록 작업을 피하고, 더 효율적이고 Compose 직관적이므로 checkedState를 관찰 가능하도록 만들고자 한다고 가정해 보겠습니다.

WellnessTask는 다음과 같이 표시될 수 있습니다.

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

data class WellnessTask(val id: Int, val label: String, val checked: MutableState<Boolean> = mutableStateOf(false))

이전에 본 것처럼 위임된 속성을 사용하면 이 경우에 checked 변수를 더 간단하게 사용할 수 있습니다.

WellnessTask를 데이터 클래스가 아닌 클래스가 되도록 변경합니다. WellnessTask가 생성자에서 기본값이 falseinitialChecked 변수를 수신하도록 하면 팩토리 메서드 mutableStateOfchecked 변수를 초기화하여 initialChecked를 기본값으로 사용할 수 있습니다.

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

이제 완료됐습니다. 이 솔루션은 효과가 있고 리컴포지션 및 구성 변경 후에도 모든 변경사항이 유지됩니다.

e7cc030cd7e8b66f.gif

테스트

이제 결합된 내부 구성 가능한 함수가 아닌 ViewModel로 비즈니스 로직이 리팩터링되므로 단위 테스트가 훨씬 간단해집니다.

계측 테스트를 사용하여 Compose 코드의 올바른 동작을 확인하고 UI 상태가 올바르게 작동하는지 확인할 수 있습니다. Compose의 테스트 Codelab을 통해 Compose UI를 테스트하는 방법을 알아보세요.

13. 축하합니다

잘하셨습니다. 이 Codelab을 성공적으로 완료하고 Jetpack Compose 앱에서 상태를 사용하는 모든 기본 API를 알아봤습니다.

상태와 이벤트를 고려하여 Compose에서 스테이트리스(Stateless) 컴포저블을 추출하는 방법과 Compose에서 상태 업데이트를 사용하여 UI 변경을 유도하는 방법을 살펴봤습니다.

다음 단계

Compose 개발자 과정의 다른 Codelab을 확인하세요.

샘플 앱

  • JetNews는 이 Codelab에 설명된 권장사항을 보여줍니다.

추가 문서

참조 API