상태를 호이스팅할 대상 위치

Compose 애플리케이션에서 UI 상태를 어디로 호이스팅해야 하는지는 UI 상태가 UI 로직과 비즈니스 로직 중 어느 쪽에서 필요한지에 따라 달라집니다. 이 문서에서는 이러한 두 가지 기본 시나리오를 설명합니다.

권장사항

UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 요소로 호이스팅해야 합니다. 상태는 상태가 소비되는 위치에서 가장 가까운 곳에 유지해야 합니다. 상태 소유자로부터 소비자에게 변경 불가능한 상태 및 이벤트를 노출하여 상태를 수정합니다.

가장 낮은 공통 상위 요소가 컴포지션 외부에 있을 수도 있습니다. 비즈니스 로직이 관련되어 있기 때문에 ViewModel에서 상태를 호이스팅하는 경우를 예로 들 수 있습니다.

이 페이지에서는 이 권장사항 및 주의사항을 자세히 설명합니다.

UI 상태 및 UI 로직의 유형

다음은 이 문서에서 사용되는 UI 상태 및 로직 유형의 정의입니다.

UI 상태

UI 상태는 UI를 설명하는 속성입니다. UI 상태에는 두 가지 유형이 있습니다.

  • 화면 UI 상태: 화면에 표시해야 하는 항목입니다. 예를 들어 NewsUiState 클래스에는 UI를 렌더링하는 데 필요한 뉴스 기사와 기타 정보가 포함될 수 있습니다. 이 상태는 앱 데이터를 포함하므로 대개 계층 구조의 다른 레이어에 연결됩니다.
  • UI 요소 상태: 렌더링 방식에 영향을 주는 UI 요소에 고유한 속성을 나타냅니다. UI 요소는 표시하거나 숨길 수 있으며 특정 글꼴이나 글꼴 크기, 글꼴 색상을 적용할 수 있습니다. Android 뷰에서 뷰는 기본적으로 스테이트풀(Stateful)이므로 이 상태 자체를 관리하여 상태를 수정하거나 쿼리하는 메서드를 노출합니다. 텍스트에 관한 TextView 클래스의 getset 메서드를 예로 들 수 있습니다. Jetpack Compose에서 상태는 컴포저블의 외부에 있으며 컴포저블 아주 가까이에서 호출 구성 가능한 함수나 상태 홀더로 호이스팅할 수도 있습니다. Scaffold 컴포저블의 ScaffoldState를 예로 들 수 있습니다.

로직

애플리케이션 로직은 비즈니스 로직 또는 UI 로직일 수 있습니다.

  • 비즈니스 로직은 앱 데이터에 대한 제품 요구사항의 구현입니다. 예를 들어 사용자가 버튼을 탭할 때 뉴스 리더 앱에서 기사를 북마크에 추가합니다. 북마크를 파일이나 데이터베이스에 저장하는 이 로직은 일반적으로 도메인 또는 데이터 레이어에 배치됩니다. 상태 홀더는 일반적으로 노출되는 메서드를 호출하여 이 로직을 이러한 레이어에 위임합니다.
  • UI 로직은 화면에 UI 상태를 표시하는 방법과 관련이 있습니다. 사용자가 카테고리를 선택했을 때 올바른 검색창 힌트를 가져오는 것, 목록의 특정 항목으로 스크롤하는 것, 또는 사용자가 버튼을 클릭할 때 특정 화면으로의 탐색 로직을 예로 들 수 있습니다.

UI 로직

UI 로직에서 상태를 읽거나 써야 하는 경우 UI의 수명 주기에 따라 UI 상태 범위를 지정해야 합니다. 이렇게 하려면 구성 가능한 함수에서 상태를 올바른 수준으로 호이스팅해야 합니다. 또는 UI 수명 주기로 범위가 지정된 일반 상태 홀더 클래스에서 상태를 호이스팅할 수도 있습니다.

다음은 이 두 가지 방법과 각각을 사용해야 하는 경우에 대한 설명입니다.

상태 소유자로서의 컴포저블

상태와 로직이 간단하다면 컴포저블에 UI 로직과 UI 요소 상태를 사용하는 것이 좋습니다. 필요에 따라 상태를 컴포저블 내부에 유지하거나 호이스팅할 수 있습니다.

상태 호이스팅 불필요

상태를 항상 호이스팅할 필요는 없습니다. 상태를 제어해야 하는 다른 컴포저블이 없는 경우 상태를 컴포저블 내부에 유지할 수 있습니다. 다음 스니펫에는 탭하면 펼쳐지거나 접히는 컴포저블이 있습니다.

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

변수 showDetails는 이 UI 요소의 내부 상태입니다. 이 변수는 이 컴포저블에서만 읽고 수정되며, 적용된 로직은 매우 단순합니다. 따라서 이 경우에는 상태를 호이스팅해도 별다른 이익이 없으므로 내부에 유지할 수 있습니다. 이렇게 하면 이 컴포저블이 확장 상태의 소유자이자 단일 정보 소스가 됩니다.

컴포저블 내부에서 호이스팅

UI 요소 상태를 다른 컴포저블과 공유하고 여러 위치에서 상태에 UI 로직을 적용해야 하는 경우 상태를 UI 계층 구조의 상단으로 호이스팅할 수 있습니다. 이렇게 하면 컴포저블을 재사용하고 테스트하기가 쉬워집니다.

다음 예는 두 가지 기능을 구현하는 채팅 앱입니다.

  • JumpToBottom 버튼은 메시지 목록을 하단으로 스크롤합니다. 이 버튼은 목록 상태를 대상으로 UI 로직을 실행합니다.
  • MessagesList 목록은 사용자가 새 메시지를 보낸 후에 하단으로 스크롤됩니다. UserInput은 목록 상태를 대상으로 UI 로직을 실행합니다.
JumpToBottom 버튼과 새 메시지의 아래로 스크롤 기능이 있는 채팅 앱
그림 1. JumpToBottom 버튼이 있고 새 메시지에서 아래로 스크롤하는 채팅 앱

컴포저블 계층 구조는 다음과 같습니다.

Chat 컴포저블 트리
그림 2. Chat 컴포저블 트리

앱이 UI 로직을 실행하고 상태를 필요로 하는 모든 컴포저블에서 상태를 읽을 수 있도록 LazyColumn 상태가 대화 화면으로 호이스팅됩니다.

LazyColumn 상태를 LazyColumn에서 ConversationScreen으로 호이스팅
그림 3. LazyColumn 상태를 LazyColumn에서 ConversationScreen로 호이스팅

최종적으로 컴포저블은 다음과 같습니다.

LazyListState가 ConversationScreen으로 호이스팅된 Chat 컴포저블 트리
그림 4. LazyListStateConversationScreen로 호이스팅된 Chat 컴포저블 트리

코드는 다음과 같습니다.

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState는 적용될 UI 로직에 필요한 수준만큼 상단으로 호이스팅됩니다. LazyListState는 구성 가능한 함수에서 초기화되므로 수명 주기에 따라 컴포지션에 저장됩니다.

lazyListStateMessagesList 메서드에서 기본값 rememberLazyListState()로 정의되는 것을 볼 수 있습니다. 이는 Compose에서 일반적인 패턴으로, 이로 인해 컴포저블의 재사용과 유연성이 향상됩니다. 그러면 앱의 여러 곳에서 컴포저블을 사용할 수 있습니다. 이 중에는 상태를 제어할 필요가 없는 곳도 있을 수 있습니다. 주로 컴포저블을 테스트하거나 미리 보는 경우에 그렇습니다. 이것이 바로 LazyColumn가 상태를 정의하는 방법입니다.

LazyListState의 가장 낮은 공통 상위 요소는 ConversationScreen입니다
그림 5. LazyListState의 가장 낮은 공통 상위 요소는 ConversationScreen입니다.

상태 소유자로서의 일반 상태 홀더 클래스

컴포저블에 UI 요소의 하나 또는 여러 개의 상태 필드가 사용되는 복잡한 UI 로직이 포함되어 있다면 일반 상태 홀더 클래스와 같은 상태 홀더로 그 책임을 위임해야 합니다. 이렇게 하면 컴포저블의 로직을 격리된 상태에서 더 쉽게 테스트할 수 있고 복잡성이 줄어듭니다. 이 접근 방식은 관심사 분리 원칙을 따릅니다. 즉, 컴포저블이 UI 요소를 방출하고 상태 홀더가 UI 로직과 UI 요소의 상태를 포함합니다..

일반 상태 홀더 클래스는 구성 가능한 함수의 호출자가 로직을 직접 작성할 필요가 없도록 편리한 함수를 제공합니다.

이러한 일반 클래스는 컴포지션에서 생성되고 기억됩니다. 일반 클래스는 컴포저블의 수명 주기를 따르므로 rememberNavController(), rememberLazyListState()와 같이 Compose 라이브러리에서 제공하는 형식을 받을 수 있습니다.

LazyColumn 또는 LazyRow의 UI 복잡성을 제어하기 위해 Compose에서 구현되는 LazyListState 일반 상태 홀더 클래스를 예로 들 수 있습니다.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState는 이 UI 요소의 scrollPosition을 저장하는 LazyColumn의 상태를 캡슐화합니다. 또한 특정 항목으로 스크롤하는 등의 방식으로 스크롤 위치를 수정하는 메서드도 노출합니다.

보시다시피 컴포저블의 책임을 늘리면 상태 홀더의 필요성이 증가합니다. 책임은 UI 로직이거나, 단순히 추적할 상태의 양일 수 있습니다.

또 다른 일반적인 패턴은 일반 상태 홀더 클래스를 사용하여 앱에서 루트 구성 가능한 함수의 복잡성을 처리하는 것입니다. 이러한 클래스를 사용하여 탐색 상태 및 화면 크기 조정과 같은 앱 수준 상태를 캡슐화할 수 있습니다. 자세한 내용은 UI 로직 및 상태 홀더 페이지에서 확인할 수 있습니다.

비즈니스 로직

컴포저블과 일반 상태 홀더 클래스가 UI 로직과 UI 요소의 상태를 담당하는 경우 화면 수준 상태 홀더가 다음 작업을 담당합니다.

  • 비즈니스 레이어, 데이터 레이어 등 주로 계층 구조의 다른 레이어에 배치되는 애플리케이션의 비즈니스 로직에 대한 액세스 권한 제공
  • 특정 화면에 표시하기 위한 애플리케이션 데이터 준비(화면 UI 상태가 됨)

상태 소유자로서의 ViewModel

Android 개발에서 AAC ViewModel이 가진 이점이 있으므로, 비즈니스 로직에 대한 액세스 권한을 제공하고 화면에 표시하기 위한 애플리케이션 데이터를 준비하는 데는 ViewModel이 적합합니다.

ViewModel에서 UI 상태를 호이스팅하면 상태가 컴포지션 외부로 이동됩니다.

ViewModel로 호이스팅된 상태는 컴포지션 외부에 저장됩니다.
그림 6. ViewModel로 호이스팅된 상태는 컴포지션 외부에 저장됩니다.

ViewModel은 컴포지션의 일부로 저장되지 않습니다. ViewModel은 프레임워크에 의해 제공되며, ViewModelStoreOwner(활동, 프래그먼트, 탐색 그래프 또는 탐색 그래프의 대상)로 범위 지정됩니다. ViewModel 범위에 관한 자세한 내용은 문서를 참고하세요.

그러면 ViewModel이 정보 소스이자 UI 상태의 가장 낮은 공통 상위 요소가 됩니다.

화면 UI 상태

위의 정의에 따라 화면 UI 상태는 비즈니스 규칙을 적용하여 생성됩니다. 화면 UI 상태는 화면 수준 상태 홀더가 담당한다는 사실을 고려하면 이는 화면 UI 상태는 일반적으로 화면 수준 상태 홀더(여기서는 ViewModel)에서 호이스팅됨을 의미합니다.

채팅 앱의 ConversationViewModel과 이것이 화면 UI 상태 및 이벤트를 노출하여 수정하는 방식을 살펴보겠습니다.

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

컴포저블은 ViewModel에서 호이스팅된 화면 UI 상태를 소비합니다. 화면 수준 컴포저블에 ViewModel 인스턴스를 삽입하여 비즈니스 로직에 대한 액세스를 제공해야 합니다.

다음은 화면 수준 컴포저블에 사용된 ViewModel의 예입니다. 여기서 컴포저블 ConversationScreen()ViewModel에서 호이스팅된 화면 UI 상태를 소비합니다.

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

속성 드릴

'속성 드릴'은 여러 중첩된 하위 구성요소를 통과하여 데이터를 데이터가 읽힌 위치로 전달하는 것을 의미합니다.

Compose에서 속성 드릴이 나타날 수 있는 일반적인 예로 최상위 수준에서 화면 수준 상태 홀더를 삽입하고 상태와 이벤트를 하위 컴포저블에 전달하는 경우를 들 수 있습니다. 이로 인해 추가로 구성 가능한 함수 서명의 오버로드가 추가로 생성될 수 있습니다.

이벤트를 개별 람다 매개변수로 노출하면 함수 서명이 오버로드될 수 있지만 구성 가능한 함수 책임의 가시성이 극대화됩니다. 함수의 기능을 한눈에 확인할 수 있습니다.

래퍼 클래스를 만드는 것보다 속성 드릴을 사용하여 한곳에서 상태 및 이벤트를 캡슐화하는 것이 좋습니다. 이렇게 하면 컴포저블이 갖는 책임의 가시성이 줄어들기 때문입니다. 게다가 래퍼 클래스가 없으면 컴포저블에 꼭 필요한 매개변수만 전달할 가능성이 커집니다. 이렇게 하는 것이 권장사항입니다.

이러한 이벤트가 탐색 이벤트인 경우에도 동일한 권장사항이 적용됩니다. 자세한 내용은 탐색 문서를 참고하세요.

성능 문제를 발견했다면 상태의 읽기를 연기할 수도 있습니다. 자세한 내용은 성능 문서를 참고하세요.

UI 요소 상태

UI 요소 상태를 읽거나 써야 하는 비즈니스 로직이 있다면 상태를 화면 수준 상태 홀더로 호이스팅할 수 있습니다.

채팅 앱은 사용자가 @ 기호를 입력하고 힌트를 입력하면 그룹 채팅에 사용자 제안을 표시합니다. 이러한 제안은 데이터 레이어에서 제공되며, 사용자 제안 목록을 계산하는 로직은 비즈니스 로직으로 간주됩니다. 이 기능은 다음과 같습니다.

사용자가 `@` 기호와 힌트를 입력하면 그룹 채팅에 사용자 제안을 표시하는 기능
그림 7. 사용자가 @와 힌트를 입력하면 그룹 채팅에 사용자 제안을 표시하는 기능

이 기능을 구현하는 ViewModel은 다음과 같습니다.

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessageTextField 상태를 저장하는 변수입니다. 사용자가 새 입력을 입력할 때마다 앱이 비즈니스 로직을 호출하여 suggestions를 생성합니다.

suggestions는 화면 UI 상태로, StateFlow에서 수집하여 Compose UI에서 사용됩니다.

주의

일부 Compose UI 요소 상태의 경우 ViewModel로 호이스팅하려면 특별한 고려사항이 필요할 수 있습니다. 예를 들어 Compose UI 요소의 일부 상태 홀더는 상태를 수정하는 메서드를 노출합니다. 그중 일부는 애니메이션을 트리거하는 정지 함수일 수 있습니다. 이러한 정지 함수는 컴포지션으로 범위가 지정되지 않은 CoroutineScope에서 호출하는 경우 예외를 발생시킬 수 있습니다.

앱 검색 창의 콘텐츠가 동적이며 앱 검색 창이 닫힌 후에 데이터 레이어에서 콘텐츠를 가져와서 새로고침해야 한다고 가정하겠습니다. 이 요소에서 상태 소유자로부터 UI와 비즈니스 로직을 모두 호출할 수 있도록 검색 창 상태를 ViewModel로 호이스팅해야 합니다.

그러나 Compose UI에서 viewModelScope를 사용하여 DrawerStateclose() 메서드를 호출하면 IllegalStateException 형식의 런타임 예외가 발생하고 '이 CoroutineContext”에서 MonotonicFrameClock을 사용할 수 없음'이라는 메시지가 표시됩니다.

이 문제를 해결하려면 컴포지션으로 범위가 지정된 CoroutineScope를 사용하세요. CoroutineScope는 CoroutineContext에서 정지 함수가 작동하는 데 필요한 MonotonicFrameClock을 제공합니다.

이 비정상 종료를 해결하려면 ViewModel에 있는 코루틴의 CoroutineContext를 컴포지션으로 범위가 지정된 컨텍스트를 전환하세요. 다음을 참고하세요.

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

자세히 알아보기

상태 및 Jetpack Compose에 관한 자세한 내용은 다음 추가 리소스를 참고하세요.

샘플

Codelab

동영상