변환 Android Jetpack의 구성요소
Transform은 Compose 런타임을 기반으로 빌드된 라이브러리로, UI 없이 @Composable 블록을 실행하여 입력을 Compose State로 변환합니다.
블록 내에서 읽은 State가 변경될 때마다 블록은 자동으로 재구성되고 반환된 State를 업데이트합니다. 이를 통해 ViewModel, 저장소 또는 백그라운드 서비스에서 Compose의 종속 항목 추적 및 재구성 모델을 사용할 수 있습니다.
사용 사례
- 반응형 데이터 변환: 원시 데이터 스트림 (예: 네트워크 응답 또는 데이터베이스 쿼리)을 UI 준비 상태로 변환합니다.
- 여러 입력 결합: 복잡한
Flow연산자 없이 여러 독립적인 상태 소스 (예: 사용자 입력, 설정, 네트워크 상태)를 하나의 일관된 상태로 병합합니다. - 디바운싱 및 제한:
LaunchedEffect와 같은 표준 Compose 부작용을 사용하여 시간 기반 동작 (예: 검색어 디바운싱)을 구현합니다. - 조건부 작업: 표준
if-else또는when표현식을 사용하여 현재 조건에 따라 다양한 상태 수집 로직을 실행합니다.
API 개요
androidx.appstate.transform 패키지는 다음과 같은 함수를 제공합니다.
package androidx.appstate.transform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
@Composable
public fun <R> transform(
initialValue: R,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
public suspend fun listener(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable () -> Unit,
)
변환 만들기
제공된 CoroutineScope를 사용하여 자체적으로 변환을 만들거나 기존 컴포지션 내에서 변환을 만들 수 있습니다. 모든 변환의 주요 매개변수는 initialValue 및 onUpdate입니다. onUpdate 람다는 이전 출력 값 R를 매개변수로 수신합니다 (첫 번째 컴포지션에서 initialValue로 초기화됨). 이 매개변수를 반환하여 { previous -> flow.collectAsState(initial = null).value ?: previous }과 같은 로드 상태로 재설정하는 대신 마지막으로 내보낸 상태를 유지할 수 있습니다.
예를 들어 transform를 사용하여 검색 쿼리 문자열의 변경사항을 관찰하고 네트워크 호출과 같은 비용이 많이 드는 계산을 디바운스할 수 있습니다.
var query: String by mutableStateOf("") val searchResults: List<String> by transform<List<String>>( initialValue = emptyList(), scope = coroutineScope, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results }
이 스니펫에서는 다음 사항이 적용됩니다.
query은 검색창 UI 컴포저블이 읽고 업데이트할 수 있는MutableState의 지원을 받습니다.transform는 빈 목록의initialValue,CoroutineScope,onUpdate를 후행 람다로 사용합니다.onUpdate블록 내에서remember는LaunchedEffect가 실행되는 동안 리컴포지션 간에results상태를 유지합니다.LaunchedEffect는query를 키로 사용합니다.query는MutableState에서 지원하므로query가 변경될 때마다 효과가 취소되고 다시 실행됩니다.- 키 입력마다 API를 호출하지 않도록 효과 본문은 300ms 동안 대기합니다. 지연이 완료되기 전에 쿼리가 변경되면 효과가 취소되고 네트워크 호출이 실행되지 않습니다.
@Composable 함수 내에서 @Composable transform 오버로드를 사용할 수도 있습니다. 이 오버로드는 내부적으로 CoroutineScope를 기억하고 기본적으로 Dispatchers.Default에서 onUpdate를 실행합니다. 이는 대규모 목록 필터링 또는 검색과 같은 비용이 많이 드는 상태 파생을 기본 UI 스레드에서 오프로드하려는 경우에 유용합니다.
@Composable fun LogViewerScreen( entriesState: State<List<LogEntry>>, filterQueryState: State<String>, ) { // Calls rememberCoroutineScope() internally and runs onUpdate on // Dispatchers.Default so main-thread UI recomposition isn't blocked val matchingEntries by transform<List<LogEntry>>( initialValue = emptyList(), dispatcher = Dispatchers.Default, ) { val entries by entriesState val filterQuery by filterQueryState if (filterQuery.isBlank()) { entries } else { entries.filter { entry -> entry.matchesQuery(filterQuery) } } } // Render matchingEntries... }
listener로 상태 관찰
파생 State<R>를 생성하지 않고 UI가 아닌 구성요소 (예: 상태 관리자, 저장소, 백그라운드 서비스)에서 Compose State 변경사항에 반응하려면 listener를 사용하세요.
listener는 호출 코루틴이 취소될 때까지 @Composable () -> Unit 블록을 실행하는 suspend 함수로, onUpdate 내에서 읽은 상태가 변경될 때마다 자동으로 재구성됩니다.
class FocusSessionManager( private val stateStore: StateStore, private val notificationManager: NotificationManager, scope: CoroutineScope, ) { val session: State<FocusSession> = stateStore.getState(FocusSessionKey) init { scope.launch { listener { when (val current = session.value) { is FocusSession.Running -> { LaunchedEffect(current.endTimeMillis) { val remainingMillis = current.endTimeMillis - System.currentTimeMillis() if (remainingMillis > 0) { delay(remainingMillis) } stateStore.setState(FocusSessionKey, FocusSession.Idle) notificationManager.notify( NOTIFICATION_ID, buildSessionCompleteNotification(current.taskId), ) } } FocusSession.Idle, is FocusSession.Paused -> Unit } } } } // startFocus, pauseFocus, resumeFocus, endFocus... }
이 예시에서 listener는 session.value를 관찰합니다. 세션이 FocusSession.Running이면 LaunchedEffect는 endTimeMillis까지 기다린 후 세션을 FocusSession.Idle로 재설정하고 완료 알림을 게시합니다. 사용자가 세션을 일시중지하거나 일찍 종료하면 Running 분기가 컴포지션을 종료하고 타이머를 자동으로 취소합니다. scope가 취소되면 listener가 관찰을 중지합니다.
일반적인 패턴
다음 패턴은 조건부 로직에 transform를 사용하고, 여러 스트림을 결합하고, ViewModel에서 UI 상태를 생성하는 방법을 보여줍니다.
조건부 상태 생성
transform 내에서 표준 Kotlin if-else 또는 when 표현식을 사용하여 현재 조건에 따라 업스트림 상태 소스 간에 전환합니다. 읽기 추적은 동적이므로 transform는 현재 리컴포지션에서 실행되는 브랜치만 관찰합니다.
var searchQuery by mutableStateOf("") val displayedTasks: State<List<Task>> = transform( initialValue = emptyList(), scope = coroutineScope, ) { if (searchQuery.isBlank()) { val recentTasks by tasksRepository.recentTasks.collectAsState( initial = emptyList(), ) recentTasks } else { val matchingTasks by remember(searchQuery) { tasksRepository.searchTasks(searchQuery) }.collectAsState(initial = emptyList()) matchingTasks } }
여러 상태 소스 결합
Compose는 상태 읽기를 자동으로 추적하므로 단일 transform 블록에서 원하는 수의 State<T> 속성과 Flow 스트림을 결합할 수 있습니다. Flow.combine는 최대 5개의 흐름에 대해 유형이 지정된 오버로드를 제공한 후 유형이 지정되지 않은 Array<Any?>로 대체되는 반면, transform에 다른 입력을 추가하는 것은 항상 단일 유형 안전 읽기입니다.
var showCompleted by mutableStateOf(false) var selectedCategory by mutableStateOf<Category?>(null) val dashboardState: State<DashboardUiState> = transform( initialValue = DashboardUiState(), scope = coroutineScope, ) { val tasks by tasksRepository.tasksFlow.collectAsState(initial = emptyList()) val sortOrder by userPrefsRepository.sortOrderFlow.collectAsState( initial = SortOrder.BY_DEADLINE, ) val focusSession by focusSessionManager.session val isOnline by networkMonitor.isOnline val visibleTasks = tasks .filter { task -> (showCompleted || !task.isCompleted) && (selectedCategory == null || task.category == selectedCategory) } .sortedWith(sortOrder.comparator) DashboardUiState( tasks = visibleTasks, selectedCategory = selectedCategory, focusSession = focusSession, isOnline = isOnline, ) }
ViewModel에서 transform 사용
컴포저블이 아닌 transform 오버로드는 Compose의 상태 모델을 사용하는 동안 UI가 아닌 레이어(예: ViewModel 또는 저장소)에서 반응형 로직을 실행합니다. 제공된 CoroutineScope가 활성 상태인 동안 실행되므로 ViewModel 내에 viewModelScope를 전달하여 구성요소의 수명 주기에 연결할 수 있습니다.
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, ) : ViewModel() { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } }
이 ViewModel에서 uiState는 networkMonitor.isOnline를 읽고 오프라인일 때 mediaRepository.downloadedTracksFlow 수집과 온라인일 때 mediaRepository.allTracksFlow 수집 간에 동적으로 전환하여 UI 컴포저블이 직접 읽을 수 있는 단일 State<HomeUiState>를 노출합니다. transform를 MutableState 및 SavedStateHandle.saveable와 결합하는 방법에 관한 자세한 내용은 ViewModel에서 Compose 상태 사용을 참고하세요.
transform, derivedStateOf, Flow 중에서 선택하세요.
다음 가이드라인에 따라 transform, derivedStateOf, Kotlin Flow 중에서 선택하세요.
derivedStateOf 및 transform 비교
derivedStateOf와 transform는 모두 블록 내에서 읽은 Compose 상태를 자동으로 추적하는 파생 State<T>를 생성합니다.
기존 State<T> 객체에서 직접 값을 계산하는지 또는 collectAsState, produceState, LaunchedEffect와 같은 @Composable 기능이 필요한지에 따라 선택합니다.
| 기능 | derivedStateOf |
transform |
|---|---|---|
| 람다 유형 | 표준 Kotlin 람다 (() -> T) |
@Composable 람다 (@Composable (R) -> R) |
| 지원되는 입력 | 동기식 State<T> 읽기 전용 |
State<T>, Kotlin Flow(collectAsState 사용), suspend 함수, Callback API(produceState 사용) |
| 실행 및 일관성 | .value를 읽을 때 지연 및 동기화, initialValue 또는 CoroutineScope 없이 동일한 스냅샷 내에서 원자적으로 업데이트 |
CoroutineScope에서 활성 컴포지션을 실행하고 스냅샷 커밋 후 비동기식으로 재구성합니다. initialValue가 필요합니다. |
| 상태 및 부수 효과 | remember 또는 부작용 API를 호출할 수 없음 |
remember, LaunchedEffect, produceState, 재사용 가능한 @Composable 함수 지원 |
모든 입력이 이미 State<T> 객체이고 @Composable API가 필요하지 않은 경우 derivedStateOf를 사용합니다.
- 동기식, 동일 스냅샷 파생:
CoroutineScope를 관리하거나 자리표시자initialValue를 제공하지 않고 기존State<T>속성에서 값 또는 필터링된 목록을 계산합니다..value를 읽을 때derivedStateOf가 동기식으로 평가되므로 입력 상태를 업데이트한 직후에.value를 읽는 호출자는 항상 동일한 스냅샷에서 업데이트된 결과를 관찰합니다. - 관찰자 업데이트 빈도 줄이기: 소스
State<T>보다 덜 자주 변경되는 값 계산(예: 스크롤 오프셋이 기준점을 초과하는지 확인) 자세한 내용은derivedStateOf문서를 참조하세요.
파생에 Compose 런타임이 필요한 경우 transform를 사용합니다.
State<T>와 비동기 스트림 결합:State<T>입력과 함께collectAsState를 사용하여 저장소 또는 데이터베이스에서 KotlinFlow를 수집합니다.- 입력이 변경될 때 업스트림 작업 다시 시작: 특정
State<T>입력이 업데이트될 때만 블록 내에서remember(key)를 사용하여 매개변수화된Flow를 다시 만듭니다. - 부수 효과 실행 또는 중간 상태 기억:
LaunchedEffect(예: 입력 디바운스 또는 타이머 실행),produceState(suspend함수에서 데이터 로드 또는awaitDispose로 콜백 래핑) 또는remember를 사용하여 리컴포지션 전반에서 상태를 유지합니다.
다음 예시에서는 derivedStateOf를 State<T>와 저장소 Flow를 결합하는 transform 블록과 비교합니다.
class TaskListViewModel( private val tasksRepository: TasksRepository, private val preferencesManager: TaskPreferencesManager, ) : ViewModel() { var searchQuery by mutableStateOf("") private set // Computing a value from existing State<T>: use derivedStateOf val hasSearchQuery: State<Boolean> = derivedStateOf { searchQuery.isNotBlank() } // Combining State<T> with a Flow and remember(key): use transform val uiState: State<TaskListUiState> = transform<TaskListUiState>( initialValue = TaskListUiState.Loading, scope = viewModelScope, ) { val showCompleted by preferencesManager.showCompleted val tasks by remember(showCompleted) { tasksRepository.observeTasks(showCompleted) }.collectAsState(initial = null) val currentTasks = tasks ?: return@transform TaskListUiState.Loading val filteredTasks = currentTasks.filter { task -> task.title.contains(searchQuery, ignoreCase = true) } TaskListUiState.Success(filteredTasks) } }
이 예에서 hasSearchQuery는 searchQuery 상태만 검사하므로 derivedStateOf는 CoroutineScope 없이 지연 계산합니다. 반면 uiState는 preferencesManager.showCompleted 및 searchQuery를 tasksRepository의 비동기 Flow와 결합하고 remember(showCompleted)를 사용하여 searchQuery만 변경될 때 Flow를 다시 만들지 않습니다.
transform 대신 Flow를 사용해야 하는 경우
transform는 이벤트 스트림이 아닌 상태의 현재 값을 나타내도록 설계되었습니다. 다음 리컴포지션 전에 상태가 여러 번 변경되면 Compose는 최신 값만 사용하고 새 값이 이전 값과 같으면 (==) 업데이트를 건너뜁니다. 로직이 모든 값을 순서대로 처리해야 하는 경우 Kotlin Flow를 대신 사용합니다.
- 누적합 및 평균: 이동 평균, 누적합 또는 실행취소 기록 (
runningFold또는scan)과 같은 계산에서는 모든 값을 한 번 계산해야 합니다.transform블록에서 리컴포지션 전의 빠른 업데이트는 건너뛰고, 연속으로 두 번 동일한 읽기 (예: 센서가10.0를 두 번 내보냄)는 리컴포지션을 트리거하지 않으며, 동일한 블록의 다른 상태에 의해 트리거된 리컴포지션은 현재 값을 두 번 계산할 수 있습니다. 먼저Flow에서 실행 값을 계산한 다음 다른 상태와 결합해야 하는 경우transform에서 결과를 수집합니다. - 이벤트 로깅 및 큐: 사용자 상호작용을 로깅하거나, 큐에 추가된 네트워크 요청을 처리하거나, 모든 중간 변경사항 (
A -> B -> C또는A -> B -> A)을 기록해야 하는 트랜잭션을 기록하는 파이프라인입니다. - 일회성 이벤트: 소리 재생 또는 하드웨어 명령어 전송과 같은 작업으로, 동일한 명령어를 연속으로 두 번 전송하면 두 번 실행되어야 합니다.
transform에는initialValue가 필요하며 연속된 동일한 출력을 건너뜁니다. - 백프레셔에 민감한 스트림: 소비자가 뒤처질 때 빠른 생산자가 정지해야 하는 스트림 (예: 파일 또는 소켓 읽기)입니다. Compose 상태 쓰기는 정지되지 않고 이전 값을 덮어씁니다.
변환 테스트
변환은 Compose 런타임과 Kotlin 코루틴을 사용하므로 변환을 테스트하려면 비동기 실행과 상태 재구성을 조정해야 합니다. androidx.appstate.transform:transform-testing 아티팩트를 사용하여 코루틴 스케줄러를 제어하고 단위 테스트에서 리컴포지션을 트리거합니다.
androidx.appstate.transform.testing 패키지는 다음 API를 제공합니다.
package androidx.appstate.transform.testing
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult
public fun transformTest(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend TransformTestScope.() -> Unit,
): TestResult
public sealed interface TransformTestScope : CoroutineScope {
public val testCoroutineScheduler: TestCoroutineScheduler
public val testDispatcher: TestDispatcher
public val backgroundScope: CoroutineScope
public fun advance()
public fun advanceTimeBy(duration: Duration)
public fun advanceUntilIdle()
}
transformTest는 runTest를 래핑하고 TransformTestScope 수신기로 테스트 블록을 실행합니다.
backgroundScope: 테스트 완료를 차단하지 않아야 하는 변환을 실행하기 위한CoroutineScope입니다. 테스트가 완료되면 자동으로 취소되어 변환이 중지됩니다.testDispatcher:testCoroutineScheduler에 연결된TestDispatcher입니다. 이를transform의dispatcher매개변수에 전달하여 재구성이 기본값인Dispatchers.Default대신 테스트 스케줄러에서 실행되도록 합니다.advance: 테스트 스케줄러에서 대기 중인 코루틴을 실행하고 Compose 스냅샷 적용 알림 (Snapshot.sendApplyNotifications)을 전송하며transform를 재구성하여 출력State를 업데이트합니다.advanceTimeBy(duration)및advanceUntilIdle: 시간에 따라 달라지는 작업의 경우testCoroutineScheduler에서 가상 시계를 진행합니다.
상태 업데이트 테스트
transform를 테스트하려면 TransformTestScope에서 backgroundScope 및 testDispatcher를 전달한 다음 입력 상태 또는 Flow를 업데이트한 후 advance를 호출합니다.
@Test fun testStateUpdate() = transformTest { var sourceState by mutableStateOf(0) val state = transform( initialValue = 0, scope = backgroundScope, dispatcher = testDispatcher, ) { sourceState } assertThat(state.value).isEqualTo(0) // Modify the source state and advance to trigger recomposition. sourceState = 1 advance() assertThat(state.value).isEqualTo(1) }
시간 기반 작업 테스트
transform가 LaunchedEffect 및 delay와 같은 시간 기반 부작용(예: 검색어 디바운싱)을 사용하는 경우 advanceTimeBy(duration)를 advance와 결합합니다.
- 입력 상태를 수정한 후
advance를 호출하여transform블록이 재구성되고LaunchedEffect를 시작하도록 합니다. advanceTimeBy(duration)를 호출하여 가상 시계를 앞으로 이동한 다음advance를 호출하여 해당 타임스탬프에 예약된 작업을 실행하고transform를 재구성합니다.
@Test fun testDebouncedSearch() = transformTest { var query by mutableStateOf("") val searchResults = transform<List<String>>( initialValue = emptyList(), scope = backgroundScope, dispatcher = testDispatcher, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results } query = "compose" // Recompose to start the LaunchedEffect for the new query. advance() // Results don't update before the 300ms debounce delay completes. advanceTimeBy(200.milliseconds) advance() assertThat(searchResults.value).isEmpty() // Advance past the remaining 100ms and trigger recomposition. advanceTimeBy(100.milliseconds) advance() assertThat(searchResults.value).containsExactly("Compose State", "Compose UI") }
ViewModel 또는 상태 홀더 테스트
transform는 제공된 scope에서 컴포지션을 실행하고 dispatcher 매개변수를 기본적으로 Dispatchers.Default로 설정하므로 transform를 만드는 클래스는 CoroutineScope 및 CoroutineDispatcher를 생성자 매개변수로 허용해야 합니다. ViewModel에서 삽입된 CoroutineScope를 ViewModel(coroutineScope) 생성자에 전달하고 CoroutineDispatcher를 transform에 전달합니다.
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel(coroutineScope) { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, dispatcher = dispatcher, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } } @Test fun testHomeViewModelOfflineState() = transformTest { val mediaRepository = FakeMediaRepository() val networkMonitor = FakeNetworkMonitor() val viewModel = HomeViewModel( mediaRepository = mediaRepository, networkMonitor = networkMonitor, coroutineScope = backgroundScope, dispatcher = testDispatcher, ) val downloadedTrack = Track(id = "1", isDownloaded = true) val streamingTrack = Track(id = "2", isDownloaded = false) mediaRepository.emitAllTracks(listOf(downloadedTrack, streamingTrack)) mediaRepository.emitDownloadedTracks(listOf(downloadedTrack)) advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack, streamingTrack), isOnline = true, ) ) // Switch offline and verify it switches to downloadedTracksFlow. networkMonitor.setOnline(false) // First advance() recomposes into the offline branch (where collectAsState // initially returns null -> Loading) and starts collecting downloadedTracksFlow. advance() // Second advance() applies the collected downloadedTracksFlow value and // recomposes uiState with the offline Success state. advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack), isOnline = false, ) ) }
이 테스트에서 backgroundScope 및 testDispatcher를 HomeViewModel에 전달하면 viewModelScope 및 transform 컴포지션이 테스트 스케줄러에 바인딩되므로 각 advance 호출은 FakeMediaRepository 및 FakeNetworkMonitor의 방출을 결정론적으로 처리합니다. ViewModel에서 SavedStateHandle.saveable 및 Snapshot.withMutableSnapshot를 사용하는 방법에 관한 자세한 내용은 ViewModel에서 Compose 상태 사용을 참고하세요.