앱 기본사항

Jetpack AppState를 사용하여 UI를 넘어 Compose 상태 활용하기

스피커

  • 벤 사그모

    개발자 관계팀 엔지니어

  • 엘리프 빌긴 모리스

    소프트웨어 엔지니어

정보

강연 소개

Compose의 스냅샷 런타임, 자동 종속 항목 추적, 원자적 업데이트는 UI 레이어에서 멈출 필요가 없습니다. Jetpack AppState를 사용하면 Compose 상태를 업스트림으로 이동할 수 있습니다. 즉, 상태가 UI에 도달하기 전에 스냅샷 시스템으로 상태를 호이스팅하고 파생하여 화면에 도달하는 항목을 간소화할 수 있습니다.

이 세션에서는 AppState의 두 가지 기본 라이브러리에 대해 알아봅니다. 상태를 호이스팅하는 StateStore와 컴포지션을 사용하여 일반 Kotlin 제어 흐름으로 상태를 파생하는 Transform을 자세히 살펴보겠습니다. 이를 통해 복잡한 Flow 결합 체인을 제거하고 UI를 손쉽게 동기화할 수 있습니다.

요약

  • Compose 런타임은 Compose UI 없이 범용 상태 관리 엔진으로 작동합니다. 스냅샷 시스템은 읽기를 동적으로 추적하고 속성 전반에서 동시 업데이트를 일관되게 유지합니다.
  • Jetpack AppState Transform은 헤드리스 컴포저블 블록에서 Compose 상태를 계산하고 노출합니다. 조기 반환 및 중첩된 상태 파생을 지원하면서 combine와 같은 복잡한 Flow 연산자 체인을 대체합니다.
  • Transform은 적극적 헤드리스 프레임 클록을 사용하여 UI 스레드 외부에서 변환을 실행합니다. 감시 상태가 변경되지 않으면 클록이 파킹되어 불필요한 작업을 방지합니다.
  • Jetpack AppState StateStore는 활동 및 백그라운드 서비스와 같은 여러 구성요소 간에 Compose 상태를 공유합니다. DataStore와 통합하면 커스텀 상용구 없이도 개별 키가 프로세스 종료 후에도 유지됩니다.

리소스

Gemini Notebook

주제

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

스크립트

소개

  • 안녕하세요. Jetpack AppState를 사용하여 UI를 넘어 Compose 상태를 활용하는 방법에 관한 세션에 오신 것을 환영합니다.

  • 저는 Jetpack 아키텍처 라이브러리를 담당하는 개발자 관계 엔지니어 벤입니다.

  • 저는 엘리프입니다. 앱 아키텍처 라이브러리를 담당하는 엔지니어 중 한 명입니다.

  • 오늘은 Compose가 UI를 넘어 애플리케이션의 데이터 및 도메인 레이어로 확장되는 미래에 관해 이야기해 보려고 합니다.

  • 이제 모두 잘 아시겠지만 Compose는 Android에서 UI 개발에 권장되는 도구 키트입니다.

  • 휴대전화와 태블릿부터 위젯, 시계, TV, XR 기기까지 모두 Compose를 통해 지원됩니다.

  • 하지만 앱이 이러한 UI 표시 경로와 백그라운드 구성요소 전반에서 성장함에 따라 그 이면의 상태를 관리하기가 더 어려워집니다. 특히 두 가지 일반적인 문제점이 있습니다.

  • 첫 번째는 복잡한 상태를 도출하는 것입니다.

  • 여러 데이터 스트림, 로컬 UI 입력, 네트워크 연결과 같은 주변 상태를 함께 엮으면 combine, flatMapLatest, distinctUntilChanged와 같은 연산자가 뒤엉키는 경우가 많습니다.

  • 두 번째는 구성요소 간에 상태를 공유하는 것입니다. 활동, 서비스, 앱 위젯 등을 동기화 상태로 유지하고 필요할 때 프로세스 종료 후에도 상태가 유지되도록 합니다.

  • 이러한 문제를 한 걸음 물러서서 살펴보면서 Compose 자체가 어떻게 빌드되는지 살펴보게 되었습니다.

  • 대부분의 사람들은 Compose를 UI 툴킷으로 생각하지만 실제로는 상단의 Compose UI와 하단의 Compose 런타임이라는 두 개의 개별 레이어로 구성되어 있습니다.

  • Compose UI는 레이아웃, 그리기, 입력을 처리하는 반면 Compose 런타임은 상태 관리 및 리컴포지션을 위한 범용 엔진입니다.

  • UI에 종속되지 않습니다.

  • 이러한 깨달음을 바탕으로 Jetpack AppState를 빌드하게 되었습니다.

  • UI를 넘어 Compose 상태를 최대한 활용하는 데 중점을 둔 라이브러리 모음입니다.

Compose 상태란 무엇인가요?

  • 하지만 AppState를 자세히 살펴보기 전에 Compose 상태가 무엇을 의미하는지 먼저 알아보겠습니다.

  • 정답은 androidx.compose.runtime.State입니다.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • 그리고 아마도 더 익숙한 인터페이스인 MutableState입니다.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • 기본적으로 이러한 컨테이너는 최소한의 기능만 갖춘 컨테이너입니다.

  • 이러한 함수가 실제로 작동하는 이유는 Compose 스냅샷 시스템 때문입니다.

  • 따라서 Compose 상태라고 하면 일반적으로 스냅샷 상태를 의미합니다.

  • 따라서 mutableStateOf를 호출하면 스냅샷 시스템을 통해 읽기 및 쓰기를 라우팅하는 컨테이너가 생성됩니다.

  • 그리고 컴포지션과 같은 스냅샷 옵저버 내에서 코드가 실행될 때마다 Flow와 같은 스트림에서는 제공하지 않는 두 가지 속성을 얻게 됩니다.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • 첫 번째는 자동 및 동적 읽기 추적입니다.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • 즉, 자동이라는 것은 구독이나 수집이 없다는 의미입니다.

  • .value만 읽는 것은 구독입니다.

  • 여기에서 Counter를 보면 컴포저블 내에서 직접 count.value를 읽으면 컴포지션에 읽기를 보고하고 리더 목록에 Counter를 등록합니다.

  • 이제 AddButton를 살펴보겠습니다.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • 클릭을 집계하기 위해서만 작성합니다.

  • 따라서 리더가 아닙니다.

  • 사용자가 AddButton를 클릭하고 개수를 수정하면 스냅샷 시스템은 이 쓰기를 추적합니다. 그러면 컴포지션은 AddButton가 아닌 Counter를 무효화해야 한다는 것을 알게 됩니다.

  • 동적이라는 것은 실제로 실행되는 코드 경로를 기반으로 런타임에 읽기 추적이 발생한다는 의미입니다.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • 여기에서는 isVisible 플래그 뒤에 카운터 텍스트를 보호하고 있습니다.

  • isVisible가 true이면 isVisible와 개수가 모두 읽힙니다.

  • 따라서 Counter는 두 항목의 리더로 등록됩니다.

  • isVisible가 false이면 if 분기가 실행되지 않고 count를 읽지 않으므로 Counter는 count가 아닌 isVisible의 리더로만 등록됩니다.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • 따라서 AddButton가 count에 쓸 때 무효화할 항목이 남아 있지 않습니다.

  • 이제 두 번째 속성은 일관성과 세분화 사이에서 선택할 필요가 없습니다.

  • 일관성이란 스냅샷에서 여러 값을 업데이트할 때 다른 스레드가 동시에 해당 값에 쓰기를 수행하더라도 코드가 실행되는 동안 변경사항이 격리된 상태로 유지된다는 것을 의미합니다.

  • 스냅샷이 완료되면 이러한 업데이트가 한 번에 표시됩니다.

  • 세분화는 별도의 속성을 개별적으로 선언하고 추적할 수 있음을 의미하며, 특히 관련이 없는 경우에 그렇습니다.

  • 따라서 하나의 속성을 업데이트하면 실제로 해당 속성에 종속된 리더만 무효화됩니다.

  • 실제 코드를 통해 살펴보겠습니다.

  • 예를 들어 레벨 이름, 생존 시간, 점수라는 세 가지 상태가 있는 게임을 살펴보겠습니다.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • 먼저, 모든 것을 GameUiState 클래스로 묶어 세분성을 희생하고 일관성을 선택하는 Flow 구현을 살펴보겠습니다.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • 즉, updateScore가 실행될 때마다 전체 GameUiState를 복사하여 timeSurvived와 점수가 함께 동기화되어 업데이트될 수 있습니다.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • UI에서 GameScreen는 UI 상태를 수집하고 이를 읽어 관련 속성을 Header 및 ScoreBadge에 전달합니다.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • GameScreen는 모든 상태를 읽기 때문에 updateScore를 호출할 때마다 헤더의 데이터가 변경되지 않았더라도 GameScreen가 무효화됩니다.

  • 이제 모든 속성을 자체 Flow로 분할하여 세분화할 수 있지만, 다른 업데이트 블록이 실행되는 동안 다른 스레드가 값을 업데이트할 수 있으므로 일관성이 손실됩니다.

  • 이제 대체 상태 기반 구현을 살펴보겠습니다.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • 이 예시에서는 GameUiState를 다시 사용합니다.

  • 하지만 이 예에서는 각 속성이 mutableStateOf에 위임하므로 약간 다릅니다.

  • 여기서 updateScore는 쓰기를 withMutableSnapshot로 래핑하며, 이를 통해 쓰기 전반에 걸쳐 일관성을 유지할 수 있습니다.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • 그런 다음 UI에서 GameScreen는 State를 Header와 ScoreBadge에 바로 전달합니다.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • 속성을 읽지 않으므로 updateScore가 실행될 때 GameScreen는 리더가 아닙니다.

  • 실제 구성요소(이 경우 ScoreBadge)만 무효화됩니다.

  • 마지막으로, 이 중 어느 것도 UI에 연결되어 있지 않습니다.

  • UI는 이러한 속성이 진가를 발휘하는 곳 중 하나입니다.

  • 이것으로 발표를 마치겠습니다. 참석해 주셔서 감사합니다.

  • 농담은 여기까지 하고, 엘리프에게 넘겨 첫 번째 라이브러리인 Transform을 살펴보겠습니다.

Transform 소개

  • 좋습니다.

  • 감사합니다, 벤님.

  • 벤이 방금 언급했듯이 Compose 스냅샷 상태는 자동 및 동적 읽기 추적, 일관성, 세분성을 제공합니다. 그렇다면 데이터 흐름도 Compose 상태 스냅샷(스냅샷 상태)에 의해 구동될 수 있다면 어떨까요?

  • 상태가 데이터 모델이라면 모든 소비자의 상태를 실제로 계산하고 변환하려면 어떻게 해야 할까요?

  • 이때 새로운 Jetpack AppState Transform 라이브러리가 유용합니다.

  • 라이브러리 자체를 살펴보기 전에 라이브러리가 어떻게 만들어졌는지 알아보겠습니다.

  • 따라서 여기에서 런타임과 컴파일러인 Compose 엔진을 활용하여 헤드리스 컴포지션에서 상태 값을 생성한다는 아이디어는 새로운 것이 아닙니다.

  • 이미 Molecule 라이브러리를 사용 중이거나 익숙한 분들도 많을 것입니다.

  • 여기서 헤드리스란 UI가 없다는 의미입니다.

  • Molecule은 Compose 런타임 사용의 이점을 보여주는 선구적인 역할을 했습니다. 구성 가능한 함수가 UI 노드를 내보낼 필요가 없으며, 순수 데이터 모델을 반환하는 구성 가능한 함수를 작성하는 데 실제로 사용될 수 있음을 보여주었습니다.

  • Molecule의 경우 Kotlin Flow 또는 StateFlow였습니다.

  • 즉, combine과 같은 반응형 연산자를 연결하는 대신 일반 Kotlin을 작성할 수 있었습니다.

  • 하지만 UI가 이미 Compose에 있으므로 흐름을 처리하는 대신 데이터 모델이 State라면 어떨까요?

  • Transform을 사용하면 데이터 모델이 상태가 됩니다.

  • 실제 API를 살펴보겠습니다.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • 이것이 API의 전부입니다.

  • 매우 간단한 하나의 함수입니다.

  • Flow나 StateFlow가 아닌 일반적인 관찰 가능한 State를 반환합니다.

  • 그리고 이를 사용하는 사람은 Transform에서 생성했다는 사실을 알거나 신경 쓰지 않습니다.

  • 여기서 정말 흥미로운 부분은 이 블록입니다.

  • 구성 가능합니다.

  • 즉, 이미 알고 있는 모든 것이 그 안에서 작동합니다.

  • LaunchedEffect, collectAsState, 조기 반환, 일반 if, 컴포저블 내부에서 읽을 수 있는 모든 항목을 변환에서 파생할 수 있습니다.

  • 오늘 이 프레젠테이션에서 살펴보는 모든 API는 알파 버전이며 변경될 수 있지만, 어쨌든 예를 살펴보겠습니다.

  • 간단하고 작은 집중 타이머 앱을 예로 들어보겠습니다.

  • 작업을 선택하고 25분 타이머를 설정하면 시계가 카운트다운을 시작합니다.

  • 단일 화면을 만들 때마다, 이와 같이 간단한 단일 화면을 만들 때도 여러 독립적인 데이터 스트림을 결합하고 관리하여 내부적으로 상태를 조합해야 할 수 있습니다.

  • 집중 화면의 경우 두 가지가 있다고 가정해 보겠습니다. 집중 세션 자체는 현재 단계(일시중지 여부, 남은 시간(초))를 나타내며, 이 모든 것은 타이머 엔진에서 1초마다 틱하는 StateFlow로 제공됩니다.

  • 다른 데이터 스트림은 사용자가 작업 중인 현재 작업으로, Room 데이터베이스에서 Flow로 들어옵니다.

  • 따라서 두 개의 데이터 스트림과 하나의 화면 상태가 마지막에 있습니다.

  • ViewModel는 이 두 가지가 결합되는 위치이며, 그 출력(요약이라고 함)은 다음에 작성할 코드 조각입니다.

  • 기존에는 ViewModel에서 체이닝 연산자를 사용하여 이러한 흐름을 결합해야 했습니다.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • 따라서 두 흐름을 스트림으로 결합하고 람다 내에서 분기한 다음 마지막에 상태를 추가합니다.

  • Transform을 사용하면 이 상용구 코드를 모두 삭제할 수 있습니다.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • 이 호출은 범위에 초깃값이 전달되므로 익숙해 보이지만 업스트림에 연결할 항목이 없으므로 파생은 블록 자체입니다.

  • 업스트림 스트림을 일시중지하고 재개하기 위해 WhileSubscribed 제한 시간을 구성해야 할 수 있는 stateIn와 달리 변환은 해당 범위의 전체 기간 동안 지속적으로 실행됩니다.

  • 다시 말하지만, 소스는 흐름이고 흐름은 환영합니다.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • 각각을 collectAsState로 연결하고 세션은 StateFlow이므로 초깃값이 필요하지 않지만 Room Flow는 콜드이므로 초깃값을 제공합니다.

  • 이것이 구독에 관한 모든 내용입니다.

  • 읽기는 구독을 의미합니다.

  • 앞서 벤이 예시에서 언급한 자동 읽기 추적이지만 ViewModel에 있습니다.

  • 유휴 상태일 때 다음과 같이 조기 반환을 도입할 수도 있습니다.

  • 새로운 작업이 없다고 가정하면 나머지 작업은 처리하지 않습니다.

  • 이것은 동적 읽기 추적이 다시 한번 효과를 발휘하는 것입니다.

  • 유휴 상태에서는 Room Flow가 전혀 수집되지 않습니다.

  • 그런 다음 최종적으로 요약을 내보냅니다.

  • 따라서 사용자가 작업을 일시중지하거나 타이머가 작동하면 모든 것이 자동으로 업데이트됩니다.

중첩 변환

  • 단일 ViewModel 예시를 벗어나면 상황이 조금 더 복잡해집니다.

  • 이 동일한 세션을 두 개의 추가 표시 경로, 즉 모든 화면에서 앱 화면 하단에 표시되는 미니 타이머 바와 홈 화면 위젯에 표시해야 한다고 가정해 보겠습니다.

  • 두 가지 모두 세션 요약, 단계, 일시중지 여부, 타이머에 남은 시간 등 세션에 관한 동일한 파생 정보가 필요합니다.

  • 즉, 모든 표시 경로가 자체 사본을 조합하는 경우 동일한 파생이 세 번, 세 곳에서 이루어지며 이 세 곳은 모두 서로 일치해야 합니다.

  • 따라서 대신 애플리케이션 범위에서 요약을 한 번 도출한 다음 다운스트림의 모든 항목에서 이를 사용해야 합니다.

  • 그렇다면 실제로 어떻게 구현할 수 있을까요?

  • 구현에는 세 가지 요소가 필요합니다.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • 따라서 애플리케이션 범위의 세션 관리자는 요약이 도출되는 위치가 되고, 미니 타이머의 ViewModel와 Glance 위젯은 모두 이 집중 세션 관리자에서 도출된 요약을 사용합니다.

  • 각각을 기존 방식과 Transform을 사용하는 방식으로 빌드해 보겠습니다.

  • 따라서 기존에는 애플리케이션 범위에서 요약을 StateFlow로 한 번 도출하는 것으로 시작했습니다.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • 앞서 ViewModel 예시에서 본 것과 동일한 combine 및 stateIn 호출이지만 한 수준 위로 이동했습니다.

  • Transform을 사용하면 앞서 살펴본 ViewModel의 동일한 선언을 재사용할 수도 있습니다.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • 변경된 유일한 점은 이제 애플리케이션 범위에 있다는 것입니다.

  • 다음은 미니 타이머의 ViewModel입니다.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • 여기서도 약간의 도전 과제를 설정해 보겠습니다.

  • 미니 타이머에서 이미 보유한 두 데이터 스트림(StateFlow 및 Room Flow) 외에도 타이머 막대가 펼쳐져 있는지 여부도 추적하려고 한다고 가정해 보겠습니다.

  • 이를 위해 MutableStateFlow를 사용합니다.

  • 이렇게 하려면 다른 combine을 사용하여 요약 흐름과 isExpanded StateFlow를 추적해야 합니다.

  • 두 번째 상태가 들어옵니다.

  • Transform을 사용하면 데이터 스트림 간의 동기화를 위해 이러한 체이닝된 연산자를 다시 사용할 필요가 없습니다.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • 여기서 할 수 있는 정말 멋진 작업은 중첩된 변환을 수행하는 것입니다.

  • 따라서 여기에서 ViewModel는 다른 상태와 마찬가지로 업스트림 변환의 출력인 요약을 읽은 다음 collectAsState를 사용하여 isExpanded를 수집합니다.

  • 따라서 동일한 블록에서 StateFlow의 업스트림 상태와 다운스트림 흐름을 혼합할 수 있습니다.

  • 세부사항을 기억하세요.

  • 따라서 isExpanded를 전환하면 이 블록만 다시 실행됩니다.

  • 앱 수준 차단과 위젯은 이를 인식할 필요도 없습니다.

  • 마지막으로 위젯이 있습니다.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Glance는 Compose이므로 다른 컴포저블과 마찬가지로 provideContent 내에서 collectAsState를 사용하여 StateFlow를 수집할 수 있습니다.

  • 변환을 사용하면 이 작업이 훨씬 더 간단해집니다.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • 포커스 관리자가 Compose 상태를 제공하므로 여기에서 변환할 필요가 없습니다.

  • 결국 미니 타이머 바와 홈 화면 위젯은 동일한 상태를 읽기 때문에 일치하지 않을 수 없습니다.

  • 뿐만 아니라 해당 상태에 대한 모든 변경사항은 해당 상태의 모든 소비자에게 반영됩니다.

  • 이게 무슨 의미일까요?

  • 따라서 여기에서 위젯의 일시중지를 누르면 관리자를 통해 세션 스트림으로 이동하고 요약을 통해 다시 나옵니다. 이 세 화면이 모두 동기화되어 업데이트됩니다.

  • 지금까지 Transform을 사용하는 방법을 간단히 살펴보았으니 이제 내부 구조를 심층 분석해 보겠습니다.

  • 앞서 살펴본 Transform API는 실제로 기본 관리형 변환을 만드는 데 사용되는 도우미 함수입니다.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • 실제로는 TransformScope 및 launchTransform 확장 함수를 사용하여 이 TransformScope에서 구성 가능한 변환을 실행하고 일회용 상태를 반환합니다.

  • 위에서 아래로 읽어보면 모든 작업을 자동으로 처리한다는 것을 알 수 있습니다.

  • TransformScope를 만들고 여기에 변환을 실행합니다. 이 변환은 다시 가져올 상태가 됩니다.

  • 그런 다음 제공된 디스패처의 TransformScope에서 실행을 시작하므로 범위의 전체 기간과 스레드가 개발자에게 속합니다.

  • 그런 다음 범위가 종료되면 변환을 최종적으로 삭제합니다.

  • 이것이 Transform의 전체 수명 주기 이야기입니다.

  • 여기에서 TransformScope 인터페이스를 간단히 살펴보겠습니다.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • createTransform 함수가 있으며, 이 함수는 이 TransformScope에 연결된 변환을 만듭니다.

  • 또한 TransformScope를 시작하고 현재 상태 변환을 업데이트하는 프로세스를 시작하는 정지 실행 함수도 있습니다.

  • 이게 무슨 의미일까요?

  • 따라서 run은 실제로 Recomposer와 컴포지션을 설정합니다.

  • 범위 내의 모든 변환은 해당 컴포지션 내에 존재하며 자체 리컴포지션 범위를 갖습니다.

  • 이것이 범위를 공유하는 이유이며 TransformScope가 있는 이유입니다.

  • 따라서 상태가 변경될 때마다 이를 읽는 변환만 다시 실행됩니다.

  • 앞서 언급했듯이 run은 정지 함수입니다.

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • 따라서 코루틴이 취소될 때까지 호출하는 코루틴에서 실행됩니다.

  • 이제 Recomposer가 프레임을 생성해야 하는 시점을 알려주는 프레임 클럭이 필요하다는 점이 궁금할 수 있습니다.

  • Compose UI에서는 디스플레이가 이에 해당하지만, 앞서 언급했듯이 헤드리스 환경이므로 UI가 없습니다.

  • 따라서 시계를 제공하지 않으면 자체 시계를 만듭니다.

  • Transform은 디스플레이 프레임 클록에서 리컴포지션을 분리하여 UI 스레드 외부에서 모든 변환과 상태 파생을 실행합니다.

  • 이를 위해 전용 HeadlessTransformClock를 구축했습니다.

  • 변환 프레임 클록입니다.

  • 위젯, 서비스 또는 다른 다운스트림 변환이 변환의 값을 기다리고 있을 수 있으므로 시계는 적극적입니다.

  • 즉, 변환에서 사용하는 상태가 변경되는 순간 새 프레임이 즉시 생성됩니다.

  • 작은 잠금 없는 상태 머신이므로 프레임을 요청하는 것은 간단한 비교 및 설정 작업입니다.

  • 상태에 변경사항이 없으면 클록이 파킹됩니다.

  • 따라서 전혀 변경되지 않은 상태를 사용하는 변환은 비용이 들지 않습니다.

  • 벤에게 다시 넘기기 전에, 이제 Transform이 rememberSaveable을 지원하여 구성 변경 시에도 데이터가 저장된다는 점을 알려드립니다.

  • 이는 새로운 SavedStateContainer API를 통해 이루어지며, 이 API는 이제 Compose의 SaveableStateRegistry, SavedStateHandle, SavedStateRegistry가 모두 빌드되는 구성 요소입니다.

  • 이 중 하나를 SavedStateContainer로 변환하거나 하위 컨테이너를 만들어 Transform에 전달하여 데이터가 저장되도록 할 수 있습니다.

  • 마지막으로, 오늘 설명해 드리는 모든 API는 알파 버전이므로 변경될 수 있다는 점을 다시 한번 알려드립니다.

  • 이제 벤에게 다시 넘기겠습니다.

UI 외부의 Compose 상태

  • 좋습니다.

  • 좋습니다. 감사합니다, 엘리프.

  • 지금까지 Compose 상태가 얼마나 강력한지, 그리고 Transform을 통해 UI 외부에서 상태를 파생하는 방법을 살펴보았습니다. 지금까지의 예시에서 눈치채셨을 수도 있는 한 가지는 입력이 collectAsState를 사용하여 연결한 Flow였다는 점입니다.

  • 하지만 항상 그럴 필요가 있을까요?

  • 상태 홀더에서 Compose 상태를 직접 사용하지 않는 이유는 무엇인가요?

  • 이 질문에 답하기 위해 상태를 세 가지 광범위한 카테고리로 나누어 생각하면 도움이 됩니다.

  • 첫 번째는 메모리에만 저장되는 실시간 상태입니다.

  • 네트워크 또는 블루투스 연결 상태 등을 생각해 보세요.

  • 지속되는 값을 사용하고 싶지 않을 것입니다.

  • 두 번째는 프로세스 종료 후에도 유지되어야 하지만 무기한 저장할 필요는 없는 일시적인 상태입니다.

  • 카드가 펼쳐져 있는지 여부와 같은 UI 상태, 탐색 백 스택 또는 양식 초안과 같은 세션 상태를 생각해 보세요.

  • 세 번째는 영구 상태로, 무기한 저장됩니다.

  • 데이터베이스에 있는 사용자 환경설정이나 미디어 라이브러리 같은 것을 생각해 보세요.

  • 그럼 먼저 라이브 상태와 임시 상태 모두에 Compose 상태를 사용하는 방법을 살펴보겠습니다.

  • 지금까지 Google의 아키텍처 가이드에서는 Compose 상태를 UI 레이어에 유지하거나 경우에 따라 ViewModel에 유지했으며 그 외의 모든 곳에서는 Flow를 사용하라고 안내했습니다.

  • 이유가 무엇일까요?

  • 상태 속성을 Flow와 결합해야 하는 즉시 snapshotFlow를 사용하여 다시 변환해야 했기 때문입니다.

  • 하지만 Transform을 사용하면 이러한 변환이 필요하지 않습니다.

  • 그럼 ViewModel에서 시작하여 이것이 어떤 의미인지 살펴보겠습니다.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • 여기에서 selectedTaskIds와 같은 로컬 화면 입력의 경우 MutableStateFlow 대신 일반 mutableStateOf 속성을 선언할 수 있습니다.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • 프로세스 종료 후에도 유지할 필요가 없으므로 mutableStateOf만 사용해도 괜찮습니다.

  • 검색어와 같이 프로세스 종료 후에도 유지하려는 항목의 경우 변경 가능한 상태를 savedStateHandle.saveable로 직접 백업할 수 있습니다.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • 그런 다음 앞서 살펴본 게임 예시의 updateScore와 마찬가지로, 메서드가 한 번에 여러 상태 값을 업데이트할 때 쓰기를 withMutableSnapshot로 래핑하면 일관성이 보장됩니다.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    
        fun updateSearchQuery(query: String) {
            Snapshot.withMutableSnapshot {
                searchQuery = query
                selectedTaskIds = emptySet()
            }
        }
    }
  • 그런 다음 searchQuery를 Room Flow와 결합하려면 Flow를 수집하는 동안 변환 내에서 직접 읽으면 됩니다.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • 그렇다면 네트워크 연결과 같은 실시간 상태는 어떨까요?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • 이는 일반적으로 싱글톤에 있습니다. 여러 화면에서 읽거나 서비스와 같은 여러 앱 구성요소에서 읽기 때문입니다.

  • 그렇다고 해서 Compose 상태를 사용하여 저장할 수 없다는 의미는 아닙니다.

  • 실제로 흐름 기반 버전보다 더 간단한 경우가 많습니다.

  • 조정을 위해 콜백 흐름이나 다른 작업이 필요하지 않으며 콜백에서 직접 점 값을 업데이트하면 됩니다.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • 소비자에게 여전히 흐름이 필요한 경우 snapshotFlow를 사용하여 다시 연결할 수 있습니다.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • 그럼 ViewModel에서 어떻게 보이는지 살펴보겠습니다.

  • Transform에서 직접 isOnline를 읽을 수 있으며 값이 변경되면 리컴포지션됩니다.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • 이 모든 작업은 직접 이루어집니다. 상태를 업데이트하기 위해 제공해야 하는 애플리케이션 범위나 코루틴 범위가 없으며, 직접적이고 동기적인 업데이트입니다.

  • 따라서 Transform을 사용하면 ViewModel에서 싱글톤 관리자의 상태, ViewModel의 상태, 저장소의 흐름을 모두 단일 Transform 블록에서 결합할 수 있습니다.

  • 이제 일시적인 상태로 돌아가 보겠습니다.

  • 앞서 UI 상태와 세션 상태라는 두 가지 유형을 언급했습니다.

  • UI 상태와 단일 활동 내에 있는 세션 상태의 경우 ViewModel 및 SavedState가 잘 작동합니다.

  • 바로 그럴 때 사용하도록 설계되었습니다.

  • 하지만 세션 상태를 여러 UI 및 앱 구성요소 간에 공유해야 하는 경우 문제가 발생하기 시작합니다.

  • 상태가 ViewModel 외부로 이동하면 기존 API와 가이드에 약간의 공백이 생깁니다.

  • 따라서 먼저 SavedState API가 활동의 Bundle에 저장하지만 서비스와 같은 다른 앱 구성요소에서는 사용할 수 없습니다.

  • 둘째, 싱글톤의 mutableStateOf는 메모리에만 상주하므로 프로세스 종료 시 유지되지 않습니다.

StateStore 소개

  • Jetpack AppState(StateStore)는 바로 이 간극을 메웁니다.

  • 이는 앱 구성요소 간에 공유할 수 있고 DataStore에서 지원하여 프로세스 종료 시에도 유지되는 Compose 상태의 유형 안전 맵입니다.

  • DataStore를 모든 싱글톤에 직접 연결할 수 있지만, 이렇게 하면 모든 속성에 대해 저장 및 복원 상용구를 작성해야 합니다.

  • StateStore DataStore 통합을 사용하면 DataStore를 한 번 연결한 다음 유지하려는 각 상태를 선택합니다.

  • 이제 집중 타이머 앱에서 이 기능을 빌드해 보겠습니다.

  • 먼저 인메모리로 시작한 다음 DataStore 지속성을 추가합니다.

  • 먼저, idle, running, paused 상태를 가진 sealed 인터페이스로 FocusSession를 모델링합니다.

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • 그런 다음 이 상태를 StateStore에 저장하기 위해 유형이 FocusSession이고 기본값이 idle인 StateStoreKey를 정의했습니다.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • 그런 다음 Hilt에 싱글톤 StateStore를 제공하여 포커스 세션 관리자와 같이 필요한 다양한 구성요소에 삽입할 수 있습니다.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • ViewModel가 SavedStateHandle를 삽입하는 방식과 유사합니다.

  • 좋습니다.

  • 이제 StateStore가 생겼습니다. 어떻게 사용해야 할까요?

  • 다행히도 작은 API입니다.

  • 메서드가 세 개뿐이므로 하나씩 살펴보겠습니다.

  • 첫 번째는 키의 새 값을 쓰는 setState입니다.

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • 세션이 시작되면 실행 중으로 설정하고 세션이 종료되면 유휴 상태로 설정합니다.

  • 이상입니다.

  • 두 번째는 updateState입니다. 이 함수를 사용하면 현재 값을 기반으로 새 값을 계산할 수 있습니다(예: 실행 중과 일시중지 간에 전환할 때 남은 시간 계산).

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • 마지막으로 getState는 키의 읽기 전용 State를 반환하며, 특정 호출 사이트에서 기본값을 변경하려는 경우 기본값을 재정의하는 옵션이 있습니다.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • 이 관리자에서 FocusSession 상태를 노출하면 소비자가 StateStore 또는 키에 직접 액세스할 필요가 없습니다.

  • 쓰기 및 읽기 캡슐화를 유지할 수 있습니다.

  • 그런 다음 소비자 대신 Focus Session Manager를 삽입합니다. 세션을 제어해야 하므로 이미 가지고 있을 가능성이 높습니다. 그런 다음 State를 읽을 수 있습니다.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • 그러면 이러한 리더는 상태를 직접 다시 노출하거나 변환을 사용하여 앞서 미니 타이머 ViewModel 예시에서 본 것처럼 업데이트된 새 상태를 만들 수 있습니다.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • 그러면 UI 컴포저블은 다른 Compose 상태와 마찬가지로 해당 상태를 읽고 필요에 따라 재구성할 수 있습니다.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • 하지만 지금까지 FocusSession은 메모리에만 존재합니다.

  • 프로세스 종료에서 살아남기 위해 필요한 사항을 살펴보겠습니다.

  • 따라서 먼저 스토어를 만들 때 syncToDataStore를 실행하여 일회성 설정을 진행해야 합니다.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • 그런 다음 유지하려는 각 키를 선택합니다.

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • 유형이 직렬화 가능하고 키가 PersistToDataStore 인터페이스를 구현하는지 확인하기만 하면 됩니다.

  • 그렇다면, 간단해 보인다고 생각할 수 있습니다.

  • 그렇다면 키를 저장하기 위해 실제로 어떤 작업을 할까요?

  • 따라서 내부적으로 syncToDataStore는 Transform의 리스너 API를 사용합니다. 이 API는 기본적으로 Transform과 정확히 동일하지만 아무것도 반환하지 않고 관찰 및 부작용에만 사용됩니다.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • 그런 다음 리스너 내에서 저장소의 모든 키를 읽고 PersistToDataStore 키가 있는지 확인합니다.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • 이 경우 LaunchedEffect를 사용하여 값이 변경될 때마다 해당 값을 DataStore에 다시 동기화합니다.

  • 여기서 주목할 점은 StateStore와 같은 표준 컨테이너가 있기 때문에 이러한 드롭인 통합을 제공할 수 있다는 것입니다.

  • DataStore은 우리가 살펴보는 첫 번째 항목일 뿐입니다.

Compose 상태 카테고리 요약

  • 상태 카테고리로 돌아가면 이제 전체 애플리케이션에서 Compose 상태를 사용하는 방법을 명확하게 파악할 수 있습니다.

  • 실시간 상태를 위한 mutableStateOf, 화면 로컬 프레젠테이션 상태를 위한 SavedState API, 교차 구성요소 세션 상태를 위한 StateStore, 마지막으로 Flow와의 상호 운용을 위한 collectAsState가 있습니다.

  • 하지만 실제로 상호 운용성은 단일 카테고리에 국한되지 않으며, 관찰 가능한 유형을 Compose 상태에 연결해야 할 때마다 collectAsState와 같은 도우미를 사용할 수 있습니다.

  • 하지만 영구 상태의 경우 Compose와 함께 작업하거나 DataStore 및 Room에서 Compose State를 직접 노출하는 방법을 살펴보고 있습니다. 따라서 이를 위해 더 나은 문법과 같은 자체 코드를 작성할 필요가 없습니다.

  • 이와 더불어 Transform, StateStore, DataStore 통합이 모두 오늘 알파 버전으로 제공된다는 소식을 전해드립니다. 또한 developer.android.com에 모든 문서를 게시했으며 GitHub에는 StateStore와 Transform을 모두 사용하는 예시를 보여주는 AppState Recipes 저장소가 있습니다.

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • 앱에서 사용해 보세요.

  • 의견을 보내주세요.

  • 알파 단계에 있으므로 이 단계에서 실제로 반복하고 변경하여 사용 사례에 맞게 작동하도록 할 수 있습니다.

코드

코드 스니펫

'Compose 상태란 무엇인가요?'의 1분 56초 지점에 있는 Kotlin 스니펫
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
'Compose 상태란 무엇인가요?'의 2분 지점에 있는 Kotlin 스니펫
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
'Compose 상태란 무엇인가요?'의 2분 23초 지점에 있는 Kotlin 스니펫
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
'Compose 상태란 무엇인가요?'의 2분 32초 지점에 있는 Kotlin 스니펫
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
'Compose 상태란 무엇인가요?'의 2분 52초 지점에 있는 Kotlin 스니펫
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
'Compose 상태란 무엇인가요?'의 3분 8초 지점에 있는 Kotlin 스니펫
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
'Compose 상태란 무엇인가요?'의 03:26에 나오는 Kotlin 스니펫
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
'Compose 상태란 무엇인가요?'의 4분 15초 지점에 있는 Kotlin 스니펫
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
'Compose 상태란 무엇인가요?'의 4분 23초 지점에 있는 Kotlin 스니펫
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
'Compose 상태란 무엇인가요?'의 04:33에 나오는 Kotlin 스니펫
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
'Compose 상태란 무엇인가요?'의 4분 42초에 나오는 Kotlin 스니펫
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
'Compose 상태란 무엇인가요?'의 5분 9초 지점에 있는 Kotlin 스니펫
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
'Compose 상태란 무엇인가요?'의 5분 21초 지점에 있는 Kotlin 스니펫
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
'Compose 상태란 무엇인가요?'의 5분 30초 지점에 있는 Kotlin 스니펫
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
'Introducing Transform'의 07:35에 나오는 Kotlin 스니펫
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
'변환 소개'의 09:20에 나오는 Kotlin 스니펫
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
'Transform 소개'의 09:37에 나오는 Kotlin 스니펫
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
'변환 소개'의 10:01에 나오는 Kotlin 스니펫
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
'중첩된 변환'의 11:39에 있는 Kotlin 스니펫
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
'중첩된 변환'의 12:03에 있는 Kotlin 스니펫
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
'중첩된 변환'의 12:17에 있는 Kotlin 스니펫
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
'중첩된 변환'의 12:28에 있는 Kotlin 스니펫
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
'중첩된 변환'의 12:56에 있는 Kotlin 스니펫
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
'중첩된 변환'의 13:37에 있는 Kotlin 스니펫
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
'중첩된 변환'의 13:47에 있는 Kotlin 스니펫
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
'중첩된 변환'의 14:33에 있는 Kotlin 스니펫
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
'중첩된 변환'의 15:18에 있는 Kotlin 스니펫
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
'중첩된 변환'의 16:02에 있는 Kotlin 스니펫
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
'UI 외부의 Compose 상태'에서 19:12의 Kotlin 스니펫
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
'UI 외부의 Compose 상태'에서 19:17의 Kotlin 스니펫
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
'Compose State outside of the UI'의 19:33에 있는 Kotlin 스니펫
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
'UI 외부의 Compose 상태'에서 19:41의 Kotlin 스니펫
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }
}
'UI 외부의 Compose 상태'에서 19:52의 Kotlin 스니펫
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
'UI 외부의 Compose 상태'에서 20:00의 Kotlin 스니펫
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
'UI 외부의 Compose 상태'에서 20:17의 Kotlin 스니펫
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
'Compose State outside of the UI'의 20:23에 있는 Kotlin 스니펫
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
'UI 외부의 Compose 상태'에서 20:32의 Kotlin 스니펫
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
'StateStore 소개'의 22:07에 나오는 Kotlin 스니펫
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
'StateStore 소개'의 22:14에 나오는 Kotlin 스니펫
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
'StateStore 소개'의 22:23에 나오는 Kotlin 스니펫
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
'StateStore 소개'의 22:42에 나오는 Kotlin 스니펫
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
'StateStore 소개'의 22:52에 나오는 Kotlin 스니펫
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
'StateStore 소개'의 23:03에 나오는 Kotlin 스니펫
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
'StateStore 소개'의 23:22에 나오는 Kotlin 스니펫
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
'StateStore 소개'의 23:33에 나오는 Kotlin 스니펫
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
'StateStore 소개'의 23:45에 나오는 Kotlin 스니펫
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
'StateStore 소개'의 23:58에 나오는 Kotlin 스니펫
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
'StateStore 소개'의 24:04에 나오는 Kotlin 스니펫
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
'StateStore 소개'의 24:22에 나오는 Kotlin 스니펫
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
'StateStore 소개'의 24:33에 나오는 Kotlin 스니펫
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
'Compose 상태 카테고리 요약'의 25:39에 있는 TOML 스니펫
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }