Jetpack Compose의 고급 상태 및 부작용

1. 소개

이 Codelab에서는 Jetpack Compose상태부작용 API와 관련된 고급 개념을 알아봅니다. 로직이 자명하지 않은 스테이트풀(Stateful) 컴포저블의 상태 홀더를 만드는 방법, Compose 코드에서 코루틴을 만들고 정지 함수를 호출하는 방법, 다양한 사용 사례를 달성하기 위해 부작용을 트리거하는 방법을 살펴보겠습니다.

이 Codelab을 진행하는 동안 도움이 추가로 필요한 경우 다음 Code-Along 동영상을 시청하세요.

학습할 내용

필요한 항목

빌드할 항목

이 Codelab에서는 미완성 애플리케이션인 Crane 머티리얼 연구 앱에서 시작하여 앱을 개선하는 기능을 추가합니다.

b2c6b8989f4332bb.gif

2. 설정

코드 가져오기

이 Codelab의 코드는 android-compose-codelabs GitHub 저장소에서 찾을 수 있습니다. 클론하려면 다음을 실행합니다.

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

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

샘플 앱 확인

다운로드한 코드에는 사용 가능한 모든 Compose Codelab용 코드가 포함되어 있습니다. 이 Codelab을 완료하려면 Android 스튜디오 내에서 AdvancedStateAndSideEffectsCodelab 프로젝트를 엽니다.

기본 분기로 시작하고 각자의 속도에 맞게 Codelab을 단계별로 따라하는 것이 좋습니다.

Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다. 코드 스니펫의 댓글에 명시된 코드를 삭제해야 하는 경우도 있을 수 있습니다.

코드 숙지 및 샘플 앱 실행

잠시 프로젝트 구조를 살펴보고 앱을 실행하세요.

162c42b19dafa701.png

기본 브랜치에서 앱을 실행하면 창 또는 항공편 목적지 로드와 같은 일부 기능이 작동하지 않는 것을 확인할 수 있습니다. Codelab의 다음 단계에서 이를 작업해 보겠습니다.

b2c6b8989f4332bb.gif

UI 테스트

앱에는 androidTest 폴더에서 사용 가능한 매우 기본적인 UI 테스트가 포함되어 있습니다. 항상 mainend 분기에서 모두 통과해야 합니다.

[선택사항] 세부정보 화면에 지도 표시

세부정보 화면에 도시 지도를 표시하는 것은 전혀 필요하지 않은 작업입니다. 하지만 지도를 보려면 지도 문서의 안내에 따라 개인 API 키를 가져와야 합니다. 다음과 같이 이 키를 local.properties 파일에 포함합니다.

// local.properties file
google.maps.key={insert_your_api_key_here}

Codelab 해결책

git을 사용하여 end 분기를 가져오려면 다음 명령어를 사용합니다.

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

또는 다음 위치에서 솔루션 코드를 다운로드할 수 있습니다.

자주 묻는 질문(FAQ)

3. UI 상태 생성 파이프라인

main 분기에서 앱을 실행할 때 확인할 수 있듯이 항공편 목적지 목록이 비어 있습니다.

다음 두 단계를 완료하여 이 문제를 해결할 수 있습니다.

  • ViewModel에 로직을 추가하여 UI 상태를 생성합니다. 이 경우 UI 상태는 추천 목적지 목록입니다.
  • UI에서 UI 상태를 사용하면 화면에 UI가 표시됩니다.

이 섹션에서 첫 번째 단계를 완료하게 됩니다.

좋은 애플리케이션 아키텍처는 관심사 분리 및 테스트 가능 여부와 같은 기본적이면서도 우수한 시스템 설계 관행을 따르도록 여러 레이어로 구성됩니다.

UI 상태 생성은 앱이 데이터 영역에 액세스하고 필요한 경우 비즈니스 규칙을 적용하며 UI에서 사용하는 UI 상태를 노출하는 프로세스를 의미합니다.

이 애플리케이션에는 데이터 영역이 이미 구현되어 있습니다. 이제 UI가 사용할 수 있도록 상태(추천 목적지 목록)를 생성해 보겠습니다.

UI 상태를 생성하는 데 사용할 수 있는 API가 몇 가지 있습니다. 이 방법 외에 다른 방법은 상태 프로덕션 파이프라인의 출력 유형 문서에 요약되어 있습니다. 일반적으로 Kotlin의 StateFlow를 사용하여 UI 상태를 생성하는 것이 좋습니다.

UI 상태를 생성하려면 다음 단계를 따르세요.

  1. home/MainViewModel.kt를 엽니다.
  2. 추천 목적지 목록을 나타내는 MutableStateFlow 유형의 비공개 변수 _suggestedDestinations를 정의하고 빈 목록을 시작값으로 설정합니다.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. StateFlow 유형의 변경 불가능한 변수 suggestedDestinations를 다시 정의합니다. 이 변수는 UI에서 사용할 수 있는 읽기 전용 공개 변수입니다. 내부적으로 변경 가능한 변수를 사용하는 동안 읽기 전용 변수를 노출하는 것이 좋습니다. 이렇게 하면 정보 소스가 하나가 되어 ViewModel을 통하지 않는 한 UI 상태를 수정할 수 없습니다. 확장 함수 asStateFlow는 변경 가능한 흐름을 변경 불가능한 흐름으로 변환합니다.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. ViewModel의 init 블록에서 destinationsRepository 호출을 추가하여 데이터 영역에서 목적지를 가져옵니다.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. 마지막으로 이 클래스에서 발견한 내부 변수 _suggestedDestinations의 사용과 관련된 주석을 해제합니다. 그러면 UI에서 발생한 이벤트로 변수를 올바르게 업데이트할 수 있습니다.

이것으로 첫 번째 단계를 완료했습니다. 이제 ViewModel이 UI 상태를 생성할 수 있습니다. 다음 단계에서는 UI에서 이 상태를 사용합니다.

4. ViewModel에서 안전하게 Flow 사용하기

항공편 목적지 목록은 여전히 비어 있습니다. 이전 단계에서는 MainViewModel에서 UI 상태를 생성했습니다. 이제 MainViewModel에서 노출된 UI 상태를 사용하여 UI에 표시합니다.

home/CraneHome.kt 파일을 열고 CraneHomeContent 컴포저블을 확인합니다.

기억된 빈 목록에 할당된 suggestedDestinations의 정의 위에 TODO 주석이 있습니다. 화면에 표시되는 것은 빈 목록입니다. 이 단계에서는 이를 수정하고 MainViewModel이 노출하는 추천 목적지를 표시합니다.

66ae2543faaf2e91.png

home/MainViewModel.kt를 연 다음, destinationsRepository.destinations로 초기화되고 updatePeople 또는 toDestinationChanged 함수가 호출될 때 업데이트되는 suggestedDestinations StateFlow를 확인합니다.

데이터의 suggestedDestinations 스트림에 새 항목을 내보낼 때마다 CraneHomeContent 컴포저블의 UI가 업데이트되도록 하려고 합니다. 그렇게 하려면 collectAsStateWithLifecycle() 함수를 사용하면 됩니다. collectAsStateWithLifecycle()StateFlow에서 값을 수집하고 수명 주기를 인식하는 방식으로 Compose의 State API를 통해 최신 값을 나타냅니다. 이렇게 하면 상태 값을 읽는 Compose 코드가 새로 내보낼 때 재구성됩니다.

collectAsStateWithLifecycle API를 사용하려면 먼저 app/build.gradle에 다음 종속 항목을 추가합니다. lifecycle_version 변수는 이미 적절한 버전으로 프로젝트에 정의되어 있습니다.

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}

CraneHomeContent 컴포저블로 돌아가서 suggestedDestinations를 할당하는 행을 ViewModelsuggestedDestinations 속성에 관한 collectAsStateWithLifecycle 호출로 바꿉니다.

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

앱을 실행하면 목적지 목록이 채워지고, 여행 중인 사람 수를 탭할 때마다 목적지가 변경되는 것을 확인할 수 있습니다.

d656748c7c583eb8.gif

5. LaunchedEffect 및 rememberUpdatedState

프로젝트에 현재 사용되지 않는 home/LandingScreen.kt 파일이 있습니다. 백그라운드에서 필요한 모든 데이터를 로드하는 데 사용될 수 있는 랜딩 화면을 앱에 추가하려고 합니다.

랜딩 화면은 화면 전체를 차지하며, 화면 중앙에 앱 로고가 표시됩니다. 이상적으로는 화면을 표시하고 모든 데이터가 로드된 후 호출자에게 onTimeout 콜백을 사용하여 랜딩 화면을 닫을 수 있음을 알립니다.

Kotlin 코루틴은 Android에서 비동기 작업을 실행하는 데 권장되는 방법입니다. 앱은 일반적으로 시작할 때 백그라운드에서 항목을 로드하기 위해 코루틴을 사용합니다. Jetpack Compose는 UI 레이어 내에서 코루틴을 안전하게 사용하도록 하는 API를 제공합니다. 이 앱은 백엔드와 통신하지 않으므로 코루틴의 delay 함수를 사용하여 백그라운드에서 로드를 시뮬레이션합니다.

Compose의 부작용은 구성 가능한 함수의 범위 밖에서 발생하는 앱 상태에 관한 변경사항입니다. 랜딩 화면 표시/숨기기를 위해 상태를 변경하는 것은 onTimeout 콜백에서 발생하고, onTimeout 호출 전에 코루틴을 사용하여 항목을 로드해야 하므로 코루틴의 컨텍스트에서 상태 변경이 발생해야 합니다.

컴포저블 내에서 안전하게 정지 함수를 호출하려면 Compose에서 코루틴 범위의 부작용을 트리거하는 LaunchedEffect API를 사용합니다.

LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됩니다. LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소됩니다.

다음 코드가 정확하지 않더라도 이 API를 사용하는 방법을 알아보고 다음 코드가 잘못된 이유를 알아보겠습니다. 이 단계의 후반부에서 구성 가능한 함수 LandingScreen을 호출합니다.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

LaunchedEffect와 같은 일부 부작용 API는 다양한 수의 키를 매개변수로 사용하며, 이러한 키는 그중 하나가 변경될 때마다 효과를 다시 시작하는 데 사용됩니다. 오류를 발견하셨나요? 이 구성 가능한 함수의 호출자가 다른 onTimeout 람다 값을 전달하는 경우에는 LaunchedEffect를 다시 시작하지 않는 것이 좋습니다. 이렇게 하면 delay가 다시 시작되며 요구사항을 충족하지 못하게 됩니다.

이 문제를 해결해 보겠습니다. 이 구성 가능한 함수의 수명 주기 동안 한 번만 부작용을 트리거하려면 상수를 키로 사용합니다(예: LaunchedEffect(Unit) { ... }). 그러나 이제 다른 문제가 있습니다.

부작용이 진행되는 동안 onTimeout이 변경되면 효과가 끝날 때 마지막 onTimeout이 호출된다는 보장이 없습니다. 마지막 onTimeout이 호출되도록 하려면 rememberUpdatedState API를 사용하여 onTimeout을 저장합니다. 이 API는 다음과 같이 최신 값을 캡처하고 업데이트합니다.

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

수명이 긴 람다 또는 객체 표현식이 컴포지션 중에 계산된 매개변수 또는 값을 참조하는 경우 rememberUpdatedState를 사용해야 합니다. LaunchedEffect로 작업할 때는 이 방식이 일반적일 수 있습니다.

랜딩 화면 표시

이제 앱이 열릴 때 랜딩 화면을 표시해야 합니다. home/MainActivity.kt 파일을 열고 먼저 호출되는 MainScreen 컴포저블을 확인합니다.

MainScreen 컴포저블에서 랜딩 화면이 표시되어야 하는지 여부를 추적하는 내부 상태를 간단하게 추가할 수 있습니다.

// home/MainActivity.kt file

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

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

지금 앱을 실행하면 LandingScreen이 표시되고 2초 후에 사라집니다.

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

이 단계에서는 탐색 창을 작동하도록 만듭니다. 현재 햄버거 메뉴를 탭해도 어떤 일도 일어나지 않습니다.

home/CraneHome.kt 파일을 열고 CraneHome 컴포저블을 확인하여 탐색 창을 열어야 하는 위치를 확인합니다. 바로 openDrawer 콜백입니다.

CraneHome에는 DrawerState가 포함된 scaffoldState가 있습니다. DrawerState에는 프로그래매틱 방식으로 탐색 창을 열고 닫는 메서드가 있습니다. 하지만 openDrawer 콜백에 scaffoldState.drawerState.open()을 쓰려고 하면 오류가 발생합니다. open 함수가 정지 함수이기 때문입니다. 다시 코루틴을 살펴봐야 합니다.

UI 레이어에서 코루틴 호출을 안전하게 만드는 API 외에도 일부 Compose API는 정지 함수입니다. 한 가지 예로 탐색 창을 여는 API가 있습니다. 정지 함수는 비동기 코드를 실행하는 것 외에도 시간이 지남에 따라 발생하는 개념을 나타내는 데 도움이 됩니다. 창을 열려면 시간, 움직임 및 잠재적인 애니메이션이 필요하므로 정지 함수에 완벽하게 반영되며, 완료되고 실행을 재개할 때까지 호출되는 코루틴의 실행을 정지합니다.

scaffoldState.drawerState.open()은 코루틴 내에서 호출되어야 합니다. 이제 무엇을 해야 할까요? openDrawer는 간단한 콜백 함수입니다. 따라서,

  • openDrawer가 코루틴의 컨텍스트에서 실행되지 않으므로 여기서 정지 함수를 호출할 수 없습니다.
  • openDrawer에서 컴포저블을 호출할 수 없으므로 이전과 같이 LaunchedEffect를 사용할 수 없습니다. 컴포지션 내에 있지 않기 때문입니다.

코루틴을 실행하려고 합니다. 어떤 범위를 사용해야 할까요? 호출 사이트의 수명 주기를 따르는 CoroutineScope을 사용하는 것이 좋습니다. rememberCoroutineScope API를 사용하면 컴포지션의 호출 지점에 바인딩된 CoroutineScope이 반환됩니다. 컴포지션을 종료하면 범위가 자동으로 취소됩니다. 이 범위를 사용하면 컴포지션에 있지 않을 때 코루틴을 시작할 수 있습니다(예: openDrawer 콜백).

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

앱을 실행하는 경우 햄버거 메뉴 아이콘을 탭하면 탐색 창이 열립니다.

92957c04a35e91e3.gif

LaunchedEffect vs rememberCoroutineScope

컴포지션 외부에 있는 일반 콜백에서 코루틴을 만들기 위한 호출을 트리거했기 때문에 LaunchedEffect를 사용할 수 없었습니다.

LaunchedEffect를 사용한 랜딩 화면 단계를 떠올려 보세요. LaunchedEffect를 사용하는 대신 rememberCoroutineScope를 사용하고 scope.launch { delay(); onTimeout(); }을 호출할 수 있을까요?

그렇게 했을 수도 있고 작동하는 것으로 보일 수 있지만 정답은 아닙니다. Compose 이해 문서에 설명된 대로 컴포저블은 언제든지 Compose에서 호출할 수 있습니다. LaunchedEffect는 컴포저블에 대한 호출이 컴포지션으로 향할 때 부작용이 실행되도록 합니다. LandingScreen의 본문에 rememberCoroutineScopescope.launch를 사용하는 경우 코루틴은 호출이 컴포지션으로 향하는지 여부와 무관하게 Compose에서 LandingScreen을 호출할 때마다 실행됩니다. 따라서 리소스를 낭비하게 되며 제어된 환경에서 이 부작용을 실행하지 않게 됩니다.

7. 상태 홀더 만들기

목적지 선택을 탭하면 입력란을 수정하고 입력한 검색어를 바탕으로 도시를 필터링할 수 있다는 사실을 알고 계셨나요? 또한 목적지 선택을 수정할 때마다 텍스트 스타일이 변경되는 것을 확인하셨을 겁니다.

dde9ef06ca4e5191.gif

base/EditableUserInput.kt 파일을 엽니다. CraneEditableUserInput 스테이트풀(Stateful) 컴포저블은 hintcaption과 같은 일부 매개변수를 가져오며, 이는 아이콘 옆의 선택적 텍스트에 해당합니다. 예를 들어 목적지를 검색하면 caption To가 표시됩니다.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

이유가 무엇인가요?

textState를 업데이트하고 표시된 항목이 힌트에 해당하는지 확인하는 로직은 모두 CraneEditableUserInput 컴포저블의 본문에 있습니다. 이 경우 몇 가지 단점이 있습니다.

  • TextField 값은 끌어올려지지 않아 외부에서 제어할 수 없으므로 테스트가 더 어렵습니다.
  • 이 컴포저블의 논리가 더 복잡해지고 내부 상태가 더 쉽게 동기화되지 않을 수 있습니다.

이 컴포저블의 내부 상태를 담당하는 상태 홀더를 만들어 모든 상태 변경사항을 한 곳으로 중앙화할 수 있습니다. 이렇게 하면 상태가 쉽게 동기화되고 관련 로직도 모두 단일 클래스로 그룹화됩니다. 또한 이 상태는 쉽게 끌어올릴 수 있으며 이 컴포저블의 호출자로부터 사용될 수 있습니다.

이 경우 앱의 다른 부분에서 재사용할 수 있는 하위 수준의 UI 구성요소이므로 상태를 끌어올리는 것이 좋습니다. 따라서 유연성과 제어 가능성이 높을수록 좋습니다.

상태 홀더 만들기

CraneEditableUserInput은 재사용 가능한 구성요소이므로 다음과 같은 동일한 파일에서 EditableUserInputState라는 상태 홀더로 일반 클래스를 만듭니다.

// base/EditableUserInput.kt file

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

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

클래스에는 다음과 같은 특성이 있어야 합니다.

  • textCraneEditableUserInput에서와 마찬가지로 String 유형의 변경 가능한 상태입니다. Compose가 값의 변경을 추적하고 변경될 때 다시 작성하도록 mutableStateOf를 사용하는 것이 중요합니다.
  • text는 비공개 set이 있는 var이므로 클래스 외부에서 직접 변경할 수 없습니다. 이 변수를 공개하는 대신 updateText 이벤트를 노출하여 변수를 수정할 수 있습니다. 그러면 클래스의 정보 소스가 단일화됩니다.
  • 클래스는 text를 초기화하는 데 사용되는 종속 항목으로 initialText를 사용합니다.
  • text가 힌트인지 여부를 확인하는 로직은 주문형 검사를 실행하는 isHint 속성에 있습니다.

향후 로직이 더 복잡해지면 EditableUserInputState 클래스만 변경하면 됩니다.

상태 홀더 기억하기

상태 홀더가 항상 기억되어야 컴포지션에서 유지되고 매번 새로 만들 필요가 없습니다. 상용구를 삭제하고 발생할 수 있는 실수를 피하도록 이 작업을 수행하는 메서드를 동일한 파일에 만드는 것이 좋습니다. base/EditableUserInput.kt 파일에 다음 코드를 추가합니다.

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

이 상태를 오직 remember 처리하기만 하면 활동을 다시 만들 때 유지되지 않습니다. 이를 달성하기 위해 remember와 유사하게 동작하는 rememberSaveable API를 대신 사용할 수 있지만 저장된 값은 활동 및 프로세스 재생성에서도 유지됩니다. 내부적으로 저장된 인스턴스 상태 메커니즘을 사용합니다.

rememberSaveableBundle 내에 저장할 수 있는 객체에 관한 추가 작업 없이 이 작업을 모두 실행합니다. 이는 프로젝트에서 만든 EditableUserInputState 클래스에는 적용되지 않습니다. 따라서 Saver를 사용하여 이 클래스의 인스턴스를 저장 및 복원하는 방법을 rememberSaveable에 알려야 합니다.

맞춤 Saver 만들기

Saver는 객체를 Saveable 상태인 것으로 변환하는 방법을 설명합니다. Saver를 구현하려면 두 가지 함수를 재정의해야 합니다.

  • save는 원래 값을 저장 가능한 값으로 변환합니다.
  • restore는 복원된 값을 원본 클래스의 인스턴스로 변환합니다.

이 사례에서는 EditableUserInputState 클래스의 Saver 맞춤 구현을 만드는 대신 listSaver 또는 mapSaver(List 또는 Map에 값을 저장)와 같은 기존 Compose API를 사용하여 작성해야 하는 코드의 양을 줄일 수 있습니다.

Saver 정의를 함께 작동하는 클래스와 가깝게 배치하는 것이 좋습니다. 정적으로 액세스해야 하므로 companion object에서 EditableUserInputStateSaver를 추가합니다. base/EditableUserInput.kt 파일에서 Saver의 구현을 추가합니다.

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

이 경우, Saver에서 EditableUserInputState의 인스턴스를 저장하고 복원하는 구현 세부정보로 listSaver를 사용합니다.

이제 전에 만든 rememberEditableUserInputState 메서드에서 remember 대신 rememberSaveable에서 이 Saver를 사용할 수 있습니다.

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

이 메서드를 사용하면 EditableUserInput에서 기억된 상태가 프로세스 및 활동 재생성 시 유지됩니다.

상태 홀더 사용하기

textisHint 대신 EditableUserInputState를 사용할 예정이지만 호출자 컴포저블에서 상태를 제어할 수 있는 방법이 없으므로 CraneEditableUserInput의 내부 상태로 사용하지 않을 것입니다. 대신 호출자가 CraneEditableUserInput의 상태를 제어할 수 있도록 EditableUserInputState호이스팅하려고 합니다. 상태를 호이스팅하면 컴포저블을 미리보기에 사용할 수 있고 호출자로부터 상태를 수정할 수 있으므로 더 쉽게 테스트할 수 있습니다.

이렇게 하려면 구성 가능한 함수의 매개변수를 변경하고 필요한 경우 기본값을 제공해야 합니다. 빈 힌트가 있는 CraneEditableUserInput을 허용할 수도 있으므로 기본 인수를 추가합니다.

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

onInputChanged 매개변수가 더 이상 없다는 것을 알고 있을 것입니다. 상태를 호이스팅할 수 있기 때문에 호출자가 입력이 변경되었는지 알고자 하는 경우 상태를 제어하고 이 함수에 상태를 전달할 수 있습니다.

다음으로 이전에 사용되었던 내부 상태 대신 호이스팅된 상태를 사용하도록 함수 본문을 조정해야 합니다. 리팩터링 후에는 함수가 다음과 같아야 합니다.

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.updateText(it) },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

상태 홀더 호출자

CraneEditableUserInput API를 변경했으므로 적절한 매개변수를 전달하는지 확인하기 위해 호출되는 모든 위치를 확인해야 합니다.

프로젝트에서 이 API를 호출하는 유일한 위치는 home/SearchUserInput.kt 파일에 있습니다. 이를 열고 ToDestinationUserInput 구성 가능한 함수로 이동하면 빌드 오류가 표시됩니다. 이제 힌트가 상태 홀더의 일부이며 컴포지션의 CraneEditableUserInput 인스턴스에 관한 맞춤 힌트를 원하므로 ToDestinationUserInput 수준에서 상태를 기억하고 이를 CraneEditableUserInput에 전달해야 합니다.

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

위 코드에는 입력이 변경되면 ToDestinationUserInput의 호출자에게 이를 알려주는 기능이 없습니다. 앱이 구조화되어 있는 방식 때문에 계층 구조에서 EditableUserInputState를 더 높은 수준으로 호이스팅하지 않으려고 합니다. 다른 구성 가능한 함수(예: FlySearchContent)는 이 상태와 결합하지 않는 것이 좋습니다. ToDestinationUserInput에서 onToDestinationChanged 람다를 호출하고 이 구성 가능한 함수를 계속 재사용하려면 어떻게 해야 할까요?

입력이 변경될 때마다 LaunchedEffect를 사용하여 부작용을 트리거하고 onToDestinationChanged 람다를 호출할 수 있습니다.

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

이미 LaunchedEffectrememberUpdatedState를 사용한 적이 있지만 위의 코드에서는 새 API도 사용합니다. 이 snapshotFlow API는 Compose State<T> 객체를 Flow로 변환합니다. snapshotFlow 내에서 읽은 상태가 변형되면 Flow는 수집기에 새 값을 내보냅니다. 이 경우 Flow 연산자의 기능을 사용하도록 상태를 Flow로 변환합니다. 이를 통해 texthint가 아닌 경우 filter 작업을 실행하고 내보낸 항목을 collect 처리하여 현재 목적지가 변경되었음을 상위 요소에 알립니다.

Codelab의 이 단계에서 시각적으로 변경된 사항은 없지만 코드의 이 부분에 관한 품질은 개선되었습니다. 지금 앱을 실행하면 모든 것이 이전처럼 작동하는 것을 확인할 수 있습니다.

8. DisposableEffect

목적지를 탭하면 세부정보 화면이 열리고 지도에서 도시 위치를 확인할 수 있습니다. 이 코드는 details/DetailsActivity.kt 파일에 있습니다. CityMapView 컴포저블에서 rememberMapViewWithLifecycle 함수를 호출합니다. details/MapViewUtils.kt 파일에 있는 이 함수를 열면 함수가 수명 주기에 연결되지 않은 것을 확인할 수 있습니다. 단순히 MapView를 기억하고 onCreate를 호출합니다.

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

앱이 잘 실행되지만 MapView가 올바른 수명 주기를 따르지 않으므로 문제가 됩니다. 따라서 앱이 언제 백그라운드로 이동하는지, 뷰가 언제 일시중지되어야 하는지 등을 알 수 없습니다. 이 문제를 해결해 보겠습니다.

MapView는 구성 가능한 함수가 아닌 뷰이므로, 컴포지션의 수명 주기와 마찬가지로 사용되는 활동의 수명 주기를 따르는 것이 좋습니다. 즉, 수명 주기 이벤트를 수신 대기하고 MapView에서 올바른 메서드를 호출하기 위해 LifecycleEventObserver를 만들어야 합니다. 그런 다음 이 관찰자를 현재 활동의 수명 주기에 추가해야 합니다.

먼저 특정 이벤트의 경우 MapView에서 상응하는 메서드를 호출하는 LifecycleEventObserver를 반환하는 함수를 다음과 같이 만듭니다.

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

이제 이 관찰자를 현재 수명 주기에 추가해야 합니다. 현재 LifecycleOwnerLocalLifecycleOwner 컴포지션 로컬과 함께 사용해 이 관찰자를 가져올 수 있습니다. 하지만 관찰자를 추가하는 것만으로는 충분하지 않습니다. 삭제할 수 있어야 합니다. 효과가 컴포지션을 종료하는 시점을 알려주는 부작용이 있어야 정리 코드를 실행할 수 있습니다. 필요한 부작용 API는 DisposableEffect입니다.

DisposableEffect는 키가 변경되거나 컴포저블이 컴포지션을 종료하면 정리되어야 하는 부작용을 위한 것입니다. 최종 rememberMapViewWithLifecycle 코드가 정확하게 이 작업을 수행합니다. 프로젝트에 다음 줄을 구현합니다.

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

관찰자는 현재 lifecycle에 추가되고, 현재 수명 주기가 변경되거나 이 컴포저블이 컴포지션을 종료할 때마다 삭제됩니다. lifecycle 또는 mapView가 변경되면 DisposableEffectkey를 사용하여 관찰자를 삭제하고 올바른 lifecycle에 다시 추가합니다.

조금 전의 변경사항에 따라 MapView는 항상 현재 LifecycleOwnerlifecycle을 따르며, 그 동작은 View 환경에서 사용된 것과 똑같습니다.

자유롭게 앱을 실행하고 세부정보 화면을 열어 MapView가 여전히 제대로 렌더링되는지 확인합니다. 이 단계에서는 시각적으로 달라지는 것이 없습니다.

9. produceState

이 섹션에서는 세부정보 화면이 시작되는 방식을 개선합니다. details/DetailsActivity.kt 파일의 DetailsScreen 컴포저블이 cityDetails를 ViewModel에서 동기식으로 가져오고 결과가 성공적인 경우 DetailsContent를 호출합니다.

하지만 cityDetails는 UI 스레드를 로드하는 데 더 많은 비용이 들 수 있고 코루틴을 사용하여 데이터 로드를 다른 스레드로 옮길 수 있습니다. 이 코드를 개선하여 로드 화면을 추가하고 데이터가 준비되면 DetailsContent를 표시합니다.

화면의 상태를 모델링하는 한 가지 방법은 화면에 표시할 데이터, 로드 및 오류 신호와 같은 모든 가능성을 다루는 클래스를 사용하는 것입니다. DetailsActivity.kt 파일에 DetailsUiState 클래스를 추가합니다.

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

정보가 준비되면 ViewModel에서 업데이트하고 Compose에서 이미 알고 있는 collectAsStateWithLifecycle() API를 사용해 수집하는 데이터 스트림인 DetailsUiState 유형의 StateFlow를 사용하여, 화면에 표시해야 하는 항목과 ViewModel 레이어의 UiState를 매핑할 수 있습니다.

하지만 이 연습을 위해 대안을 구현해 보겠습니다. uiState 매핑 로직을 Compose 환경으로 이동하려면 produceState API를 사용하면 됩니다.

produceState를 사용하면 Compose가 아닌 상태를 Compose 상태로 변환할 수 있습니다. value 속성을 사용하여 반환된 State에 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 실행합니다. LaunchedEffect와 마찬가지로 produceState 역시 키를 가져와 계산을 취소하고 다시 시작합니다.

사용 사례에서는 다음과 같이 produceState를 사용하여 초깃값이 DetailsUiState(isLoading = true)uiState 업데이트를 내보낼 수 있습니다.

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

그런 다음 uiState에 따라 데이터를 표시하거나, 로드 화면을 표시하거나, 오류를 보고합니다. 다음은 DetailsScreen 컴포저블의 전체 코드입니다.

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

앱을 실행하면 로딩 스피너가 도시 세부정보가 표시되기 전에 어떻게 표시되는지 볼 수 있습니다.

aa8fd1ac660266e9.gif

10. derivedStateOf

Crane의 마지막 개선사항은 화면의 첫 번째 요소를 넘긴 후 항공편 목적지 목록을 스크롤할 때마다 맨 위로 스크롤 버튼을 표시하는 것입니다. 버튼을 탭하면 목록의 첫 번째 요소로 이동합니다.

2c112d73f48335e0.gif

이 코드가 포함된 base/ExploreSection.kt 파일을 엽니다. ExploreSection 컴포저블은 Scaffold의 배경화면에 표시되는 컴포저블에 해당합니다.

사용자가 첫 번째 항목을 넘겼는지 계산하려면 LazyColumnLazyListState를 사용하고 listState.firstVisibleItemIndex > 0인지 확인합니다.

기본 구현은 다음과 같습니다.

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

이 솔루션은 효율적이지 않습니다. showButton을 읽는 구성 가능한 함수는 firstVisibleItemIndex가 변경되는 만큼 자주 재구성되기 때문입니다. 이러한 상황은 스크롤할 때 자주 발생합니다. 대신 truefalse 간에 조건이 변경될 때만 함수를 재구성하려고 합니다.

이를 가능하게 하는 API로 derivedStateOf API가 있습니다.

listState는 관찰 가능한 Compose State입니다. 계산값 showButton도 Compose State이어야 합니다. 값이 변경될 때 UI를 재구성하고 버튼을 표시하거나 숨기려고 하기 때문입니다.

다른 State에서 파생된 Compose State를 원한다면 derivedStateOf를 사용합니다. derivedStateOf 계산 블록은 내부 상태가 변경될 때마다 실행되지만, 구성 가능한 함수는 계산 결과가 마지막 결과와 다를 때만 재구성됩니다. 따라서 showButton을 읽는 함수가 재구성되는 횟수가 최소화됩니다.

이 경우에는 derivedStateOf API를 사용하는 것이 더 좋은 효율적인 대안입니다. 또한 remember API로 호출을 래핑하므로 계산된 값은 재구성 후에도 유지됩니다.

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

ExploreSection 컴포저블의 새 코드는 이미 익숙할 것입니다. Box를 사용하여 ExploreList 상단에 조건부로 표시되는 Button을 배치합니다. rememberCoroutineScope을 사용하여 ButtononClick 콜백 내에서 listState.scrollToItem 정지 함수를 호출합니다.

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

앱을 실행하는 경우 스크롤하여 화면의 첫 번째 요소를 전달하면 하단에 버튼이 표시됩니다.

11. 마무리

축하합니다. 이 Codelab을 완료하고 Jetpack Compose 앱의 상태 및 부작용 API에 관한 고급 개념을 배웠습니다.

상태 홀더, LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState, derivedStateOf와 같은 부작용 API를 생성하는 방법, 그리고 Jetpack Compose에서 코루틴을 사용하는 방법을 알아보았습니다.

다음 단계

Compose 과정에 대한 다른 Codelab과 Crane을 포함한 다른 코드 샘플을 확인하세요.

문서

이러한 주제에 대한 자세한 내용 및 안내는 다음 문서를 참조하세요.