UI 외부에서 Compose 상태 사용

Jetpack Compose State<T> 및 MutableState<T>는 Compose 스냅샷 시스템에서 지원하는 범용 값 홀더입니다. 스냅샷 시스템은 UI 리컴포지션을 유도하는 데 가장 흔히 사용되지만 UI 렌더링에 연결되지는 않습니다.

AppState 라이브러리(Transform 및 StateStore)를 사용하면 ViewModel, 관리자, 저장소, 백그라운드 서비스 전반에서 Compose State를 보유, 파생, 관찰, 유지할 수 있습니다.

UI 외에 Compose 상태를 사용하는 이유

UI 컴포저블, transform 또는 derivedStateOf와 같은 상태 관찰 블록 내에서 Compose State를 읽으면 Compose 런타임은 아키텍처의 모든 레이어에서 두 가지 주요 이점을 제공합니다.

  • 자동 및 동적 종속 항목 추적: 상태의 .value를 읽거나(또는 by 속성 위임 사용) 자동으로 해당 상태를 블록의 종속 항목으로 등록합니다. 추적은 동적이므로 종속 항목은 실행되는 코드 경로에 따라 런타임에 결정됩니다. 조건부 분기가 사용되지 않는 경우 (예: if 검사 또는 조기 return 후) 해당 분기 내의 상태를 읽지 않으며 상태 변경으로 인해 불필요한 재계산이 트리거되지 않습니다.
  • 일관성 및 세분성: 별도의 세분화된 MutableState 속성을 선언하고 Snapshot.withMutableSnapshot에서 다중 속성 쓰기를 래핑할 수 있습니다. 중간 업데이트는 변경 가능한 스냅샷으로 격리되고 블록이 완료되면 원자적으로 적용되므로 스냅샷 내에서 읽는 관찰자는 일관되지 않은 중간 상태를 보지 않으며 읽은 특정 속성이 변경될 때만 다시 계산합니다. StateFlow와 달리 단일 모놀리식 StateFlow<UiState> (필드가 변경될 때 모든 수집기를 무효화함)과 별도의 StateFlow 속성 (단일 원자 트랜잭션에서 함께 업데이트할 수 없음) 중에서 선택할 필요가 없습니다.

아키텍처 전반에서 적절한 상태 API 선택

상태가 유지되어야 하는 기간과 상태에 액세스해야 하는 구성요소에 따라 Compose State를 보유하고 유지하는 방법을 선택합니다.

주 카테고리 수명 및 범위 예 권장 Compose State API
라이브 상태 항상 메모리에 저장되며 시작 시 새로 쿼리되고 프로세스 종료 시 유지되지 않습니다. 네트워크 연결 상태, 블루투스 연결 상태, 항목 선택과 같은 임시 UI 상태
임시 UI 상태 구성 변경 및 시스템에서 시작된 프로세스 종료 시에도 유지되며 무기한 저장되지 않습니다. 단일 화면 또는 활동으로 범위가 지정됩니다. 펼치거나 접은 UI 요소, 스크롤 위치, 화면 검색어 또는 필터 전환
임시 세션 상태 백그라운드 프로세스 종료 시에도 유지되며 무기한 저장되지 않고 활동, 서비스 또는 위젯 간에 공유됩니다. 활성 집중 타이머, 다단계 결제 장바구니 또는 진행 중인 양식 초안
영구 상태 디스크에 무기한 보관됩니다. 사용자 환경설정 및 설정 (DataStore), 구조화된 앱 데이터 및 미디어 라이브러리 (Room) collectAsState를 사용하여 transform 내에서 수집된 DataStore 또는 Room 흐름

ViewModel에서 Compose 상태 사용

ViewModel 내에서 독립적인 화면 입력을 세분화된 MutableState 속성으로 선언하고, SavedStateHandle.saveable를 사용하여 프로세스 종료 전반에 걸쳐 유지하고, Snapshot.withMutableSnapshot를 사용하여 여러 속성을 원자적으로 업데이트하고, transform 내에서 저장소 Flow 스트림과 결합할 수 있습니다.

sealed interface TaskListState {
    data object Loading : TaskListState
    data class Success(
        val tasks: List<Task>,
        val selectedTaskIds: Set<String>,
        val isOnline: Boolean = true,
    ) : TaskListState
}

@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle,
) : 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<TaskListState>(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading

        TaskListState.Success(
            tasks = tasks,
            selectedTaskIds = selectedTaskIds,
        )
    }
}

이 ViewModel의 주요 패턴:

  • 반응형 저장된 상태에 SavedStateHandle.saveable 사용: mutableStateOf(androidx.lifecycle:lifecycle-viewmodel-compose에서 가져옴)를 사용하여 searchQuery를 SavedStateHandle.saveable에 위임하면 프로세스 종료 시 값이 MutableState로 유지되므로 transform가 읽기를 자동으로 추적합니다. Compose State가 아닌 일반 Kotlin 속성을 반환하고 변경 시 리컴포지션을 트리거하지 않는 단순한 SavedStateHandle.saved는 사용하지 마세요.
  • Snapshot.withMutableSnapshot를 사용한 원자적 다중 속성 쓰기: Snapshot.withMutableSnapshot 내에서 searchQuery 및 selectedTaskIds를 업데이트하면 쓰기가 격리되고 단일 원자적 스냅샷 트랜잭션에서 두 변경사항이 모두 커밋됩니다. 따라서 관찰자(transform 포함)는 오래된 selectedTaskIds와 쌍을 이루는 새 searchQuery를 관찰하는 대신 단일 리컴포지션 패스에서 두 업데이트를 함께 볼 수 있습니다.
  • remember(key)를 사용하여 리컴포지션 전반에서 흐름 재사용: searchQuery를 키로 사용하여 remember에서 tasksRepository.getTasks(searchQuery)를 래핑하면 selectedTaskIds가 변경되고 transform 블록을 리컴포지션할 때 Compose가 쿼리를 다시 시작하는 대신 기존 데이터베이스 Flow를 재사용합니다.

앱 전체에서 실시간 시스템 상태 공유

네트워크 또는 블루투스 연결 상태와 같이 여러 ViewModel, 활동 또는 서비스 간에 공유되는 실시간 시스템 상태의 경우 프로세스 종료 시 오래된 상태를 디스크에 유지하지 않는 것이 좋습니다. 대신 시작 시 현재 시스템 상태를 쿼리하고 애플리케이션 범위 싱글톤 내의 비공개 mutableStateOf 속성에 보관하고 읽기 전용 State<T>를 노출합니다.

interface NetworkMonitor {
    val isOnline: State<Boolean>
}

@Singleton
class LiveNetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) : NetworkMonitor {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)

    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    override val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onCapabilitiesChanged(
            network: Network,
            networkCapabilities: NetworkCapabilities,
        ) {
            _isOnline.value = networkCapabilities.hasCapability(
                NetworkCapabilities.NET_CAPABILITY_INTERNET
            )
        }

        override fun onLost(network: Network) {
            _isOnline.value = false
        }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }

    private fun checkCurrentConnectivity(): Boolean {
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities =
            connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(
            NetworkCapabilities.NET_CAPABILITY_INTERNET
        )
    }
}

MutableState는 모든 스레드에서 일관된 읽기 및 업데이트를 지원하므로 NetworkCallback는 백그라운드 콜백 스레드에서 직접 _isOnline.value를 업데이트할 수 있습니다. 백그라운드 콜백이 여러 MutableState 속성을 함께 업데이트하는 경우 쓰기를 Snapshot.withMutableSnapshot로 래핑하여 원자적으로 적용되도록 합니다.

앱의 모든 구성요소는 일회성 검사로, UI 컴포저블 내에서 또는 listener, derivedStateOf, transform와 같은 상태 관찰 블록 내에서 networkMonitor.isOnline.value를 읽을 수 있습니다. 예를 들어 TaskListViewModel는 transform 블록 내에서 networkMonitor.isOnline.value를 읽을 수 있습니다.

val tasksState: State<TaskListState> = transform<TaskListState>(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(
        tasks = tasks,
        selectedTaskIds = selectedTaskIds,
        isOnline = networkMonitor.isOnline.value,
    )
}

transform는 searchQuery, selectedTaskIds, 저장소 Flow와 함께 networkMonitor.isOnline.value를 추적하므로 연결이 변경되면 데이터베이스를 다시 쿼리하지 않고도 TaskListState.Success가 자동으로 다시 계산됩니다.