개발자 관계팀 엔지니어
Compose의 스냅샷 런타임, 자동 종속 항목 추적, 원자적 업데이트는 UI 레이어에서 멈출 필요가 없습니다. Jetpack AppState를 사용하면 Compose 상태를 업스트림으로 이동할 수 있습니다. 즉, 상태가 UI에 도달하기 전에 스냅샷 시스템으로 상태를 호이스팅하고 파생하여 화면에 도달하는 항목을 간소화할 수 있습니다.
이 세션에서는 AppState의 두 가지 기본 라이브러리에 대해 알아봅니다. 상태를 호이스팅하는 StateStore와 컴포지션을 사용하여 일반 Kotlin 제어 흐름으로 상태를 파생하는 Transform을 자세히 살펴보겠습니다. 이를 통해 복잡한 Flow 결합 체인을 제거하고 UI를 손쉽게 동기화할 수 있습니다.
combine와 같은 복잡한 Flow 연산자 체인을 대체합니다.스크립트
안녕하세요. 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 상태를 최대한 활용하는 데 중점을 둔 라이브러리 모음입니다.
하지만 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을 살펴보겠습니다.
좋습니다.
감사합니다, 벤님.
벤이 방금 언급했듯이 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는 알파 버전이므로 변경될 수 있다는 점을 다시 한번 알려드립니다.
이제 벤에게 다시 넘기겠습니다.
좋습니다.
좋습니다. 감사합니다, 엘리프.
지금까지 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는 메모리에만 상주하므로 프로세스 종료 시 유지되지 않습니다.
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 상태를 사용하는 방법을 명확하게 파악할 수 있습니다.
실시간 상태를 위한 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" }앱에서 사용해 보세요.
의견을 보내주세요.
알파 단계에 있으므로 이 단계에서 실제로 반복하고 변경하여 사용 사례에 맞게 작동하도록 할 수 있습니다.
/**
* 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
}/**
* 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
}@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++ })
}
}@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")
}
}@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++ })
}
}@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++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)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
)
}
}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
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@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
}
}@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)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>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,
)
}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(...)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(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}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
)
}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(...)
}
}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 } }
}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 } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}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
}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()
}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
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@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()
}
}
}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)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@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)
}
}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)
}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
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}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)
}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)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@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)
}
}@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),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}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
}
}
}
}
}[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" }