- Compose 에서는 UI element 의 상태, UI 에 표시될 상태 등 다양한 상태를 관리하면서 사용자에게 화면을 출력한다.
- 간단한 상태 호이스팅은 컴포저블 함수에서 관리할 수 있다.
- 그러나 루트 컴포저블에 가까워질수록 추적할 상태가 많아지거나 컴포저블 함수에서 실행할 비교적 복잡한 로직이 있는 경우, 로직과 상태에 대한 책임을 다른 클래스에 위임하는 것이 좋다.
- 특히 Composable UI element 의 상태를 관리하는 다른 클래스, 상태 홀더(State Holder)에 위임하는 것을 권장한다.
- Compose 화면의 상태 관리와 관련된 주요 항목
- 컴포저블(Composable) : 간단한 UI element state 를 관리
- 상태 홀더(State Holder) : 복잡한 UI element state 를 관리하며 state 와 연결된 UI 로직도 보유
- ViewModel : 비즈니스 로직 및 화면 UI state 에 대한 액세스 권한을 제공
- 컴포저블은 복잡성에 따라 0개 이상의 상태 홀더(일반 객체 or ViewModel)을 사용할 수 있다. 상태 홀더를 유연하게 필요에 따라 사용할 수 있다는 의미이다.
- 일반적인 State Holder 는 비즈니스 로직이나 화면 상태에 액세스해야 하는 경우 ViewModel 을 사용할 수도 있다.
- ViewModel 은 비즈니스 로직을 담당하고 있으며 UI 에 표시할 데이터 또한 보관하고 있기에 상태 홀더가 ViewModel 에 접근하는 경우가 있다는 말이다.
- ViewModel 은 비즈니스 레이어 혹은 데이터 영역을 사용한다. ViewModel 은 데이터를 조회하고 저장하기 위해 데이터 영역과 상호작용한다. 이는 비즈니스 로직 수행을 위한 비즈니스 레이어로의 접근과도 동일하게 볼 수 있다.
상태 유형
- 화면 UI 상태 : 화면에 표시되어야 하는 정보를 담는다. 앱의 데이터를 포함하고 있어 보통 다른 layer 들과 연결된다.
- UI element 상태 : UI element(예시: Composable)의 상태를 호이스팅한 결과다.
- 예시: ScaffoldState 는 Scaffold 컴포저블의 상태를 처리한다.
로직 유형
- UI 로직 : 화면에 상태 변경을 표시하는 방법과 관련이 있다.
- 예시: 사용자가 버튼을 클릭하면 특정 화면으로 탐색하는 로직이나 목록의 특정 아이템으로 스크롤하는 로직 등
- 비즈니스 로직 : 상태 변경에 따라 진행 여부가 결정되는 작업이다.
- 예시: 유저가 버튼을 클릭하면 뉴스 앱이나 기사를 북마크할 때, 비즈니스 로직은 북마크 정보를 파일이나 데이터베이스에 저장한다.
- 상태 홀더는 보통 비즈니스 로직을 도메인/데이터 레이어가 노출한 함수를 호출함으로써 위임한다.
정보 소스로서의 컴포즈블
- 상태와 로직이 간단하다면, 컴포저블에 UI 로직과 UI element 상태를 사용하는 것이 좋다.
@Composable
fun MyApp() {
MyTheme {
val scaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
MyContent(
showSnackbar = { message ->
coroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(message)
}
}
)
}
}
}
- ScaffoldState 에 변경될 수 있는 속성(예시: snackbarHostState)이 포함되기에 해당 컴포저블과의 모든 상호작용은 위에 정의한 MyApp 컴포저블에서 이루어져야 한다.
- 그렇지 않고, 다른 컴포저블에 전달해버리면 해당 컴포저블이 상태를 변경할 수 있기에 *단일 정보 소스 원칙에 위배되고 버그 추적에 어려움을 겪는다.
- 단일 정보 소스 원칙 : 특정 상태의 소유권을 단일 컴포넌트가 가져야 한다는 원칙
정보 소스로서의 State Holder
- 여러 UI element 들의 상태와 관련된 복잡한 UI 로직이 있는 컴포저블이라면, 책임을 상태 홀더에 위임하는 것이 좋다.
- 책임을 분리함으로써 격리하여 테스트를 진행할 수 있고, 컴포저블의 복잡성 또한 줄어들기 때문이다.
- 컴포저블은 UI element 를 출력하는 데 집중하고, 상태 홀더가 UI 로직과 UI element 상태를 담당함을 의미한다.
- 상태 홀더 구현은 일반 클래스로 하되 컴포지션에 의해 생성되고 기억되기에 Compose 종속 항목(예시: remember~State)을 사용할 수 있다.
- 컴포지션에서 기억할 수 있도록 remember 를 사용하여 함수를 작성한다.
- 만약 Activity 혹은 프로세스가 종료된 이후에도 보존하려는 상태를 상태 홀더에 포함하려면 rememberSaveable 을 사용하고 상태에 맞는 커스텀 Saver 를 만들면 된다.
// 앱의 UI 로직과 UI 요소의 상태를 관리하는 plain 클래스
class MyAppState(
val scaffoldState: ScaffoldState,
val navController: NavHostController,
private val resources: Resources,
/* ... */
) {
val bottomBarTabs = /* State */
// Logic to decide when to show the bottom bar
val shouldShowBottomBar: Boolean
get() = /* ... */
// Navigation logic, which is a type of UI logic
fun navigateToBottomBarRoute(route: String) { /* ... */ }
// Show snackbar using Resources
fun showSnackbar(message: String) { /* ... */ }
}
@Composable
fun rememberMyAppState(
scaffoldState: ScaffoldState = rememberScaffoldState(),
navController: NavHostController = rememberNavController(),
resources: Resources = LocalContext.current.resources,
/* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
MyAppState(scaffoldState, navController, resources, /* ... */)
}
- 이제 UI 로직과 UI element 상태를 보관하던 MyApp 은 UI element 명세에만 집중하고 나머지는 MyAppState 에 위임하였다.
- UI element state 인스턴스 조회를 위해 rememberMyAppState 함수를 새롭게 정의하여 MyApp 컴포저블에서 사용한다.
@Composable
fun MyApp() {
MyTheme {
val myAppState = rememberMyAppState()
Scaffold(
scaffoldState = myAppState.scaffoldState,
bottomBar = {
if (myAppState.shouldShowBottomBar) {
BottomBar(
tabs = myAppState.bottomBarTabs,
navigateToRoute = {
myAppState.navigateToBottomBarRoute(it)
}
)
}
}
) {
NavHost(navController = myAppState.navController, "initial") {
/* ... */
}
}
}
}
- 컴포저블이 제어할 UI element 가 늘어남에 따라 매번 따로 파라미터를 관리하기가 굉장히 번잡해졌다.
- 컴포저블의 책임이 늘어남에 따라 State Holder 도입 필요성이 증가한다.
정보 소스로서의 ViewModel
- 상태 홀더 클래스가 UI 로직과 UI element 의 상태를 담당했었다. ViewModel 은 다른 특별한 유형의 상태 홀더로서 다음의 작업을 맡는다.
- 비즈니스/데이터 레이어 등 주로 계층 구조의 다른 레이어에 배치되는 앱의 비즈니스 로직에 대한 액세스 권한 제공
- 특정 화면에 표시하기 위한 앱 데이터 준비(화면 UI 상태가 됨)
- ViewModel 은 컴포지션보다 수명이 더 길다.
- config change 가 발생하여 컴포지션이 종료되더라도 유지되기 때문이다.
- ViewModel 의 생명주기는 Compose contents(Activity or Fragment)의 호스트 생명주기나 Navigation Graph 의 생명주기를 따를 수 있다.
- 따라서 컴포지션보다 긴 생명주기를 가지고 있기에 컴포지션의 수명과 바인딩된 상태를 참조해선 안된다.
- 컴포지션의 수명과 바인딩된 상태를 참조하여 장기 지속 참조가 이어지면 메모리 누수가 발생할 수 있기 때문이다.
- ViewModel 의 인스턴스를 다른 컴포저블 함수로 전달하면 안된다고 설명한다.
- 만약 컴포저블 함수에 ViewModel 을 전달하면 해당 함수는 ViewModel 의 특정 타입과 결합되어 재사용성이 떨어지고 테스트와 preview 동작에 어려움을 겪기 때문이다.
- 또한, ViewModel 인스턴스를 관리하는 명확한 단일 소스 저장소가 없어진다.
- ViewModle 을 전달하면 여러 컴포저블이 ViewModel 의 함수를 호출하고 상태를 수정할 수 있기 때문에 디버깅도 더 어려워진다.
- 따라서, UDF 권장사항에 따라 필요한 상태만 전달함이 옳다.
- ViewModel 은 컴포저블 함수가 아니기 때문에 컴포지션의 구성요소가 아니다.
- 컴포저블에서 만든 상태(remember~State)를 ViewModel 이 보관해서는 안된다.
- 별도의 State Holder 클래스를 선언하여 해당 인스턴스를 보관해야 한다.
data class ExampleUiState(
val dataToDisplayOnScreen: List<Example> = emptyList(),
val userMessages: List<Message> = emptyList(),
val loading: Boolean = false
)
class ExampleViewModel(
private val repository: MyRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
var uiState by mutableStateOf(ExampleUiState())
private set
// Business logic
fun somethingRelatedToBusinessLogic() { /* ... */ }
}
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val uiState = viewModel.uiState
/* ... */
ExampleReusableComponent(
someData = uiState.dataToDisplayOnScreen,
onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
)
}
@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
/* ... */
Button(onClick = onDoSomething) {
Text("Do something")
}
}
- 커스텀 UI 상태 홀더 클래스인 ExampleUiState 클래스를 정의하였고, ExampleScreen 컴포저블에서 viewModel() 함수를 사용하여 ViewModel 인스턴스를 생성한 뒤 데이터를 죄회하여 ExampleReusableComponent 컴포저블의 파라미터로 전달함을 확인할 수 있다.
- 이렇듯 ViewModel 은 사용자에게 보여줄 커다란 데이터를 보관하거나 비즈니스 로직을 담당한다.
- viewModel() 함수 : 현재 생성된 viewModel 인스턴스를 불러오거나 새롭게 생성하는 함수
- 함수 사용을 위해 build.gradle 파일에 특정 라이브러리 implementation 이 필요하다.
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$version")
- State Holder 와 ViewModel 의 역할을 보면 사뭇 다르다는 점을 발견할 수 있다.
- State Holder 는 UI element state UI 로직(Behavior)에 대한 책임을 갖고 있다.
- ViewModel 은 Screen State(UI State), 비즈니스 로직에 대한 책임을 갖고 있다.
- 따라서, 두 가지의 상태 홀더를 화면 수준의 컴포저블에서 모두 다룰 수 있다.
class ExampleState(
val lazyListState: LazyListState,
private val resources: Resources,
private val expandedItems: List<Item> = emptyList()
) {
fun isExpandedItem(item: Item): Boolean = TODO()
/* ... */
}
@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val uiState = viewModel.uiState
val exampleState = rememberExampleState()
LazyColumn(state = exampleState.lazyListState) {
items(uiState.dataToDisplayOnScreen) { item ->
if (exampleState.isExpandedItem(item)) {
/* ... */
}
/* ... */
}
}
}
Android | Jetpack Compose에서의 상태 관리 - Composable, State Holder, ViewModel
읽기 전 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다. 개인적으로 실습하면서 배운 점을 정리한 글입니다, Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서
8iggy.tistory.com
'안드로이드 > Compose' 카테고리의 다른 글
[Compose] Jetpack Compose 및 State (0) | 2025.01.30 |
---|---|
[Compose] 페이징 데이터 로드 및 표시 (0) | 2024.11.24 |
[Compose] TextField에서 키보드 hide 처리하기 (0) | 2024.11.23 |