본문 바로가기

Compose

[Compose] 상태 호이스팅(State Hoisting)

Compose 공식문서를 참고하여 작성하였습니다

 

UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 요소로 호이스팅해야 합니다

 

UI 상태(UiState)와 UI 로직 종류

 

UI 상태 (UI를 설명하는 property)

화면 UI State -> 화면에 표시해야 하는 정보 

예를 들어, NewsUiState 클래스는 뉴스 기사, 기타 UI 렌더링에 필요한 정보를 포함

data class NewsUiState(
    val isLoading: Boolean = false,
    val newsList: List<News> = emptyList(),
    val error: String? = null
)

 

UI element State -> UI 요소 자체의 속성 (Visibility, font, fontSize, fontColor, enabled, selected 등등)

viewSystem에서는 get, set 함수 통해 속성들을 다룸

compose에서는 Composable 외부에서 이런 속성들을 다룸

@Composable
fun MyComposable() {
    var isVisible by remember { mutableStateOf(true) }
    var text by remember { mutableStateOf("Hello") }

    if (isVisible) {
        Text(text = text)
    }

    Button(onClick = { isVisible = !isVisible }) {
        Text("Toggle Visibility")
    }

    TextField(value = text, onValueChange = { text = it })
}

 

 

로직

UI 로직 -> 화면에 데이터를 표시하고 사용자 인터렉션을 처리하는 로직

비즈니스 로직 -> 핵심 기능 담당

 

UI 로직

UI 로직에서 상태를 읽거나 쓰려면 상태를 UI 라이프사이클에 맞게 잘 호이스팅해야 한다

StateHolder를 사용해도 된다

 

2가지 경우

1. 호이스팅 하지 않는 경우

여러 Composable이 상태를 공유하지 않고 로직이 간단하다면 호이스팅하지 않아도 된다

이런 경우 Composable 내부에 상태 유지

// showDetail은 ChatBubble Composable에서만 사용는 상태
@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)
    }
}

 

2. 호이스팅이 필요한 경우

UI 요소 상태를 여러 곳에서 사용하거나 적용해야 할 경우 호이스팅이 필요하다

 

예시) 메세지 보내기 버튼을 누르면(onMessageSent) 메세지 목록이 하단으로 스크롤된다

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()
    val lazyListState = rememberLazyListState() // 호이스팅된 state

    MessagesList(messages, lazyListState) // 재사용
    UserInput(
        onMessageSent = {
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

// listState에 디폴트 값을 정의함으로 재사용성이 높아짐
@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState()
) {

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

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0)
        }
    })
}

 

Composable Tree 구조

 

 

만약 Composable 함수가 복잡한 UI 로직이나 여러 상태 필드를 포함하는 경우,

Plain State Holder 클래스를 사용하여 상태 관리를 위임하는 것이 좋다

 

Holder를 사용하면

Composable 복잡성 감소

관심사 분리 원칙 준수

테스트가 쉬워짐

 

PlainStateHolder

UI로직과 UI 상태를 캡슐화하는 클래스

Composable 함수는 UI 요소를 표시하는 것에 집중, Holder는 로직과 상태 관리

 

Compose에서 기본적으로 제공하는 Holder를 사용하는 것도 좋다 (LazyListState 등등)

@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() { /*...*/ }
}

 

예시) text 상태와 update, clear 할 수 있는 로직을 포함한 holder

class StateHolder(
    private val initialText: String
) {
    var text by mutableStateOf(initialText)
        private set // 외부에서 직접 수정 불가능하도록 설정

    fun updateText(newText: String) {
        text = newText
    }

    fun clearText() {
        text = ""
    }
}

 

비즈니스 로직

Composable과 StateHolder 클래스가 UI 로직과 UI 요소의 상태를 담당하는 경우

화면 수준 상태는 ViewModel을 통해 관리할 수 있다

 

ViewModel 역할

비즈니스 로직에 접근

데이터를 화면에 표시하기 위한 형태로 매핑하여 UI 상태를 만든다

 

Compose에서 ViewModel을 사용할때의 특징

ViewModel은 Composition 외부에 저장

ViewModelStoreOwner에 범위가 지정된다

Activity나 Fragment의 Lifecycle보다 오래 지속되어 ConfigurationChange 후에도 데이터 유지 가능

 

 

 

화면 UI 상태

일반적으로 화면 UI State는 ViewModel에서 관리되고, ViewModel은 비즈니스 로직을 통해 데이터를 화면에 표시하기 적합한 형태로 매핑하여 UI 상태를 생성한다

Composable은 viewModel에서 제공하는 UI 상태를 사용해 화면을 구성

 

예시)

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

    // 최신 메세지 목록을 UI 상태로 제공
    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // 메세지를 보내는 비즈니스 로직
    fun sendMessage(message: Message) { /* ... */ }
}

// 화면 UI 상태를 사용하는 Composable
@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)
    /*... */
}

 

 

Property Drilling

데이터를 실제로 읽힌 위치(Composable)까지 여러 Composable을 통해 전달하는 것을 의미

Compose에서 일반적으로 ViewModel을 최상위 컴포넌트에 주입하고 상태와 이벤트를 자식 Composable로 전달할 때 발생

@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)
    /*... */
}

 

문제점

Property Drilling이 많아지면 코드 복잡성이 높아질 수 있다

 

예시) property가 많아질수록 과도한 propertyDrilling 발생

class MainViewModel : ViewModel() {
    val name = MutableStateFlow("Park")
    val age = MutableStateFlow(31)
    val email = MutableStateFlow("aaa@bbb.com")

    fun updateName(input: String) {
        viewModelScope.launch {
            name.emit(input)
        }
    }
}

@Composable
fun HomeScreen(viewModel: MainViewModel) {
    val name by viewModel.name.collectAsState()
    val age by viewModel.age.collectAsState()
    val email by viewModel.email.collectAsState()

    Profile(name, age, email, viewModel::updateUserName)
}

@Composable
fun Profile(name: String, age: Int, email: String, onNameChange: (String) -> Unit) { ... }

 

이를 방지하기 위해 상황에 따라 여러가지 방법을 사용할 수 있다

 

1. 기본적으로 이벤트는 모두 람다 매개변수로 넘기는 것이 좋다 (하지만 이 방법도 매개변수가 많아지면 복잡해진다)

@Composable
fun Profile(
    name: String, 
    age: Int, 
    email: String, 
    onNameChange: (String) -> Unit, 
    onAgeChange: (Int) -> Unit, 
    onEmailChange: (String) -> Unit
) {
    // ...
}

 

2. 위에서 살펴 본 StateHolder 사용

class ProfileState {
    var name by mutableStateOf("Park")
    var age by mutableStateOf(31)
    var email by mutableStateOf("aaa@bbb.com")
}

@Composable
fun Profile(state: ProfileState, onNameChange: (String) -> Unit) { ... }

 

3. UI 상태를 ViewModel에서 한 번에 관리

class MainViewModel : ViewModel() {
    val uiState = MutableStateFlow(
        ProfileUiState(
            name = "Park",
            age = 31,
            email = "aaa@bbb.com"
        )
    )

    fun updateProfile(name: String, age: Int, email: String) {
        viewModelScope.launch {
            uiState.emit(ProfileUiState(name, age, email))
        }
    }
}

data class ProfileUiState(val name: String, val age: Int, val email: String)

@Composable
fun MainScreen(viewModel: MainViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    Profile(uiState) { name, age, email ->
        viewModel.updateProfile(name, age, email)
    }
}

 

 

UI 요소 상태와 ViewModel

일반적으로 UI 요소 상태는 UI에서 관리하지만, 비즈니스 로직에서 읽거나 써야 하는 경우에는 ViewModel로 hoisting할 수 있다

(하지만 과도한 hoisting은 viewModel의 복잡성 증가, UI 요소 상태가 비즈니스 로직과 연관성이 없다면 UI에서 관리하는 것이 좋다)

 

예시) Composable에서 TextField를 사용할 경우, 입력 상태를 저장하는 inputMessage를 viewModel로 hoisting

viewModel은 inputMessage UI 요소 상태를 사용해 비즈니스 로직(getSuggestions)을 실행하고 화면 UI 생성(suggestions)

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
    }
}

'Compose' 카테고리의 다른 글