UI 상태 생성

최신 UI는 대부분 정적이지 않습니다. 사용자가 UI와 상호작용하거나 앱에서 새 데이터를 표시해야 하는 경우 UI의 상태가 변경됩니다.

이 문서에서는 UI 상태의 생성 및 관리를 위한 가이드라인을 설명합니다. 문서를 다 읽고 나면 다음 사항을 알 수 있습니다.

  • UI 상태를 생성하는 데 사용해야 하는 API. 이는 단방향 데이터 흐름 원칙에 따라 상태 홀더에서 사용할 수 있는 상태 변경 소스의 특성에 따라 다릅니다.
  • 시스템 리소스를 고려하도록 UI 상태 생성 범위를 지정하는 방법
  • UI에서 사용할 UI 상태를 노출하는 방법

기본적으로 상태 생성은 이러한 UI 상태 변경사항을 점진적으로 적용하는 것입니다. 상태는 항상 존재하며 이벤트로 인해 변경됩니다. 이벤트와 상태의 차이점은 아래 표에 요약되어 있습니다.

이벤트
일시적이고 예측할 수 없으며 일정 기간 존재합니다. 항상 존재합니다.
상태 생성의 입력입니다. 상태 생성의 출력입니다.
UI 또는 기타 소스의 제품입니다. UI에서 사용합니다.

위 내용을 한마디로 요약하면 상태는 존재하고 이벤트는 발생한다는 것입니다. 아래의 다이어그램은 타임라인에서 이벤트가 발생할 때 상태 변경을 시각화하여 보여줍니다. 각 이벤트는 적절한 상태 홀더에 의해 처리되고 따라서 상태가 변경됩니다.

이벤트와 상태 비교
그림 1: 이벤트로 인해 상태가 변경됨

이벤트는 다음에서 발생할 수 있습니다.

  • 사용자: 사용자가 앱의 UI와 상호작용할 때
  • 상태 변경의 다른 소스: UI 또는 도메인, 데이터 영역(스낵바 시간 제한 이벤트 또는 사용 사례, 저장소 등)의 앱 데이터를 각각 표시하는 API

UI 상태 생성 파이프라인

Android 앱의 상태 생성은 다음 요소로 구성된 처리 파이프라인으로 간주될 수 있습니다.

  • 입력: 상태 변경의 소스입니다. 다음과 같을 수 있습니다.
    • UI 레이어에 로컬: 사용자가 작업 관리 앱에서 '할 일'이라는 제목을 입력하는 등의 사용자 이벤트이거나 UI 상태의 변화를 유도하는 UI 로직에 대한 액세스 권한을 제공하는 API일 수 있습니다. 예를 들어 Jetpack Compose의 DrawerState에서 open 메서드를 호출합니다.
    • UI 레이어 외부: UI 상태의 변경을 일으키는 도메인 또는 데이터 영역의 소스입니다. NewsRepository 또는 기타 이벤트에서 로드가 완료된 뉴스를 예로 들 수 있습니다.
    • 위의 모든 요소가 혼합된 것일 수 있습니다.
  • 상태 홀더: 상태 변경 소스에 비즈니스 로직 또는 UI 로직을 적용하고 사용자 이벤트를 처리하여 UI 상태를 생성하는 유형입니다.
  • 출력: 앱에서 사용자에게 필요한 정보를 제공하기 위해 렌더링할 수 있는 UI 상태입니다.
상태 생성 파이프라인
그림 2: 상태 생성 파이프라인

상태 생성 API

진행 중인 파이프라인 단계에 따라 상태 생성에 사용되는 기본 API는 2가지입니다.

파이프라인 단계 API
입력 UI 버벅거림을 방지하려면 UI 스레드 밖에서 작업을 실행하는 비동기 API를 사용해야 합니다. 예를 들어 Kotlin의 코루틴 또는 Flow, 자바 프로그래밍 언어의 RxJava 또는 콜백이 있습니다.
출력 상태가 변경될 때 UI를 무효화하고 다시 렌더링하려면 관찰 가능한 데이터 홀더 API를 사용해야 합니다. 예를 들어 StateFlow 또는 Compose State, LiveData가 있습니다. 관찰 가능한 데이터 홀더는 UI에 항상 화면에 표시할 UI 상태가 있음을 보장합니다.

이 두 가지 중에서 입력용 비동기 API 선택이 출력용 관찰 가능한 API 선택보다 상태 생성 파이프라인의 특성에 더 큰 영향을 미칩니다. 이는 입력이 파이프라인에 적용될 수 있는 처리의 종류를 결정하기 때문입니다.

상태 생성 파이프라인 어셈블리

다음 섹션에서는 다양한 입력에 가장 적합한 상태 생성 기법과, 일치하는 출력 API를 다룹니다. 각 상태 생성 파이프라인은 입력과 출력의 조합이며 다음과 같아야 합니다.

  • 수명 주기 인식: UI가 표시되거나 활성 상태가 아닌 경우 상태 생성 파이프라인은 명시적으로 필요하지 않은 한 어떤 리소스도 사용해서는 안 됩니다.
  • 사용하기 쉬움: UI는 생성된 UI 상태를 쉽게 렌더링할 수 있어야 합니다. 상태 생성 파이프라인 출력에 관한 고려사항은 뷰 시스템이나 Jetpack Compose와 같은 여러 뷰 API에 따라 다릅니다.

상태 생성 파이프라인의 입력

상태 생성 파이프라인의 입력은 다음을 통해 상태 변경 소스를 제공할 수 있습니다.

  • 동기식 또는 비동기식일 수 있는 원샷 작업(예: suspend 함수 호출)
  • 스트림 API(예: Flows)
  • 위 항목 모두

다음 섹션에서는 위의 각 입력에 대해 상태 생성 파이프라인을 조합하는 방법을 설명합니다.

상태 변경 소스로서의 원샷 API

MutableStateFlow API를 관찰 가능하고 변경 가능한 상태의 컨테이너로 사용합니다. Jetpack Compose 앱에서는 특히 Compose 텍스트 API로 작업할 때 mutableStateOf도 고려할 수 있습니다. 두 API 모두 업데이트가 동기식이든 비동기식이든 관계없이 호스트하는 값의 안전한 원자적 업데이트를 허용하는 메서드를 제공합니다.

예를 들어 간단한 주사위 굴리기 앱에서 상태 업데이트를 고려해 보세요. 사용자가 주사위를 굴릴 때마다 동기 Random.nextInt() 메서드가 호출되고 결과는 UI 상태에 작성됩니다.

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Compose 상태

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

비동기 호출에서 UI 상태 변경

비동기 결과가 필요한 상태 변경의 경우 적절한 CoroutineScope에서 코루틴을 실행합니다. 이를 통해 앱은 CoroutineScope가 취소될 때 작업을 삭제할 수 있습니다. 그러면 상태 홀더는 UI 상태를 노출하는 데 사용되는 관찰 가능한 API에 정지 메서드 호출의 결과를 씁니다.

예를 들어 아키텍처 샘플AddEditTaskViewModel을 생각해 보세요. 정지 saveTask() 메서드가 비동기식으로 작업을 저장하면 MutableStateFlow의 update 메서드는 상태 변경을 UI 상태에 전파합니다.

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Compose 상태

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

백그라운드 스레드에서 UI 상태 변경

UI 상태를 생성하려면 기본 디스패처에서 코루틴을 실행하는 것이 좋습니다. 즉, 아래 코드 스니펫의 withContext 블록 외부입니다. 하지만 다른 백그라운드 컨텍스트에서 UI 상태를 업데이트해야 하는 경우에는 다음 API를 사용하면 됩니다.

  • withContext 메서드를 사용하여 다른 동시 컨텍스트에서 코루틴을 실행합니다.
  • MutableStateFlow를 사용할 때는 일반적으로 update 메서드를 사용합니다.
  • Compose 상태를 사용할 때는 Snapshot.withMutableSnapshot을 사용하여 동시 컨텍스트에서 상태의 원자적 업데이트를 보장합니다.

예를 들어 아래 DiceRollViewModel 스니펫에서는 SlowRandom.nextInt()가 CPU에 바인딩된 코루틴에서 호출해야 하는 계산 집약적인 suspend 함수라고 가정합니다.

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Compose 상태

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

상태 변경 소스로서의 스트림 API

스트림에서 시간이 지남에 따라 여러 값을 생성하는 상태 변경 소스의 경우 모든 소스의 출력을 응집된 전체로 집계하는 것은 상태를 생성하는 간단명료한 접근 방식입니다.

Kotlin Flow를 사용할 때는 결합 함수를 사용하면 됩니다. InterestsViewModel의 'Now in Android' 샘플에서 예를 확인할 수 있습니다.

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

stateIn 연산자를 사용하여 StateFlows를 만들면 UI가 표시될 때만 활성화되어야 할 수 있으므로 상태 생성 파이프라인의 활동을 더 세밀하게 제어할 수 있습니다.

  • 수명 주기 인식 방식으로 흐름을 수집하는 동안 UI가 표시될 때만 파이프라인이 활성 상태여야 하는 경우 SharingStarted.WhileSubscribed()를 사용합니다.
  • 사용자가 UI로 돌아갈 수 있는 한, 즉 UI가 백 스택에 있거나 화면 밖의 다른 탭에 있는 한 파이프라인이 활성 상태여야 하는 경우 SharingStarted.Lazily를 사용합니다.

스트림 기반 상태 소스 집계가 적용되지 않는 경우 Kotlin Flow와 같은 스트림 API는 병합, 평면화 등의 다양한 변환 세트를 제공하여 스트림을 UI 상태로 처리할 수 있도록 합니다.

상태 변경 소스로서의 원샷 및 스트림 API

상태 생성 파이프라인이 상태 변경의 소스로 원샷 호출과 스트림에 모두 의존하는 경우 스트림이 확실한 제약 조건입니다. 따라서 원샷 호출을 스트림 API로 변환하거나 출력을 스트림으로 파이핑하고 위의 스트림 섹션에 설명된 대로 처리를 계속합니다.

흐름에서는 일반적으로 비공개 지원 MutableStateFlow 인스턴스를 하나 이상 만들어 상태 변경을 전파합니다. Compose 상태에서 스냅샷 흐름을 만들 수도 있습니다.

아래 architecture-samples 저장소의 TaskDetailViewModel을 고려하세요.

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Compose 상태

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

상태 생성 파이프라인의 출력 유형

UI 상태 출력 API의 선택과 표시 방법의 특성은 대체로 앱이 UI를 렌더링하는 데 사용하는 API에 따라 다릅니다. Android 앱에서는 뷰 또는 Jetpack Compose를 사용하도록 선택할 수 있습니다. 고려사항은 다음과 같습니다.

다음 표에는 특정 입력 및 소비자의 상태 생성 파이프라인에 사용할 API가 요약되어 있습니다.

입력 소비자 출력
원샷 API StateFlow 또는 LiveData
원샷 API Compose StateFlow 또는 Compose State
스트림 API StateFlow 또는 LiveData
스트림 API Compose StateFlow
원샷 및 스트림 API StateFlow 또는 LiveData
원샷 및 스트림 API Compose StateFlow

상태 생성 파이프라인 초기화

상태 생성 파이프라인을 초기화하려면 파이프라인을 실행할 초기 조건을 설정해야 합니다. 여기에는 파이프라인 시작에 중요한 초기 입력 값(예: 뉴스 기사의 세부정보 뷰 id 또는 비동기 로드를 시작하는 것)이 포함될 수 있습니다.

가능하다면 시스템 리소스를 보존하기 위해 상태 프로덕션 파이프라인을 늦게 초기화해야 합니다. 실제로 출력을 소비하는 소비자가 있을 때까지 기다렸다가 초기화하는 경우가 많습니다. Flow API는 stateIn 메서드에서 started 인수를 사용하여 이 작업을 허용합니다. 이 방법을 적용할 수 없는 경우 다음 스니펫과 같이 멱등성 initialize() 함수를 정의하여 상태 생성 파이프라인을 명시적으로 시작합니다.

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

샘플

다음 Google 샘플은 UI 레이어에서 상태가 생성되는 방식을 보여줍니다. 이러한 샘플을 참고하여 가이드가 실제로 어떻게 적용되는지 살펴보세요.