읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 관리할 State가 많아질 때 어떻게 책임을 나눠야하는 지 알아봅니다. 사실상, 대부분의 서비스 앱은 MVVM 패턴으로 구성된다. 이번 포스팅은 Compose를 도입 시 어떻게 MVVM 패턴으로 구현할 수 있는지 정리함을 목적으로 합니다.

Compose에서의 상태 관리 - UI, State Holder, ViewModel

Compose에서는 UI element의 상태, UI에 표시될 상태 등 다양한 상태를 관리하면서 사용자에게 화면을 출력한다. 간단한 상태 호이스팅은 컴포저블 함수에서 관리할 수 있다. 그러나, 루트 컴포저블에 가까워질 수록 추적할 상태가 많아지거나 컴포저블 함수에서 실행할 비교적 복잡한 로직이 있는 경우 로직과 상태에 대한 책임을 다른 클래스에 위임하는 것이 좋다.

특히 Compose UI element의 상태를 관리하는 다른 클래스, 상태 홀더(State Holder)에 위임하는 것을 권장한다. 아래는 Compose 화면의 상태 관리와 관련된 주요 항목이다.

  • 컴포저블(Composable): 간단한 UI element state를 관리
  • 상태 홀더(State Holder): 복잡한 UI element state를 관리하며 state와 연결된 UI 로직도 보유
  • ViewModel: 비즈니스 로직 및 화면 UI state에 대한 액세스 권한을 제공

Android_Compmose_Managing_State_001

위 그림은 Compose의 상태 관리와 관련된 항목과 역할을 요약한 그림이다.

컴포저블은 복잡성에 따라 0개 이상의 상태 홀더를(일반 객체 혹은 ViewModel) 사용할 수 있다. 상태 홀더를 유연하게 필요에 따라 사용할 수 있다는 의미이다.

일반적인 State Holder는(영어 버전 공식문서에서는 Plain State Holder로 명명되어 있으며 한국어 번역 상 "일반적인 상태 홀더"라고 작성된 것으로 보인다.) 비즈니스 로직이나 화면 상태에 액세스해야 하는 경우 ViewModel을 사용할 수도 있다. ViewModel은 비즈니스 로직을 담당하고 있으며 UI에 표시할 데이터 또한 보관하고 있기에 상태 홀더가 ViewModel에 접근하는 경우가 있다는 말이다.

ViewModel은 비즈니스 레이어 혹은 데이터 영역을 사용한다. ViewModel은 데이터를 조회하고 저장하기 위해 데이터 영역과 상호작용한다. 이는 비즈니스 로직 수행을 위한 비즈니스 레이어로의 접근과도 동일하게 볼 수 있다.

상태 및 로직 유형

상태 유형

  • 화면 UI 상태 : 화면에 표시되어야 하는 정보를 담는다. 앱의 데이터를 포함하고 있어 보통 다른 layer들과 연결된다.
  • UI element 상태 : UI element의(ex. Composable) 상태를 호이스팅한 결과다. 예를 들어, ScaffoldStateScaffold 컴포저블의 상태를 처리한다.

로직 유형

  • UI 로직 : 화면에 상태 변경을 표시하는 방법과 관련이 있다. 예를 들어, 버튼을 사용자가 클릭하면 특정 화면으로 탐색하는 로직이나 목록의 특정 아이템으로 스크롤하는 로직 등이 해당된다.
  • 비즈니스 로직 : 상태 변경에 따라 진행 여부가 결정되는 작업이다. 결제/환경설정 저장 등이 해당된다. 예를 들어, 유저가 버튼을 클릭하여 뉴스 앱에서 기사를 북마크할 때 비즈니스 로직은 북마크 정보를 파일이나 데이터베이스에 저장한다. 따라서, 해당 로직은 보통 도메인이나 데이터 레이어에 속하며 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)
                    }
                }
            )
        }
    }
}

위 코드는 UI element state인 ScaffoldStateCoroutineScope를 처리하는 임의로 작성된 MyApp 컴포저블이다. Coroutine을 사용하여 화면에 스낵바 출력에 해당하는 UI 로직을 처리하고 있다.

ScaffoldState에 변경될 수 있는 속성이 포함되기에 해당 컴포저블과의 모든 상호작용은 위에 정의한 MyApp 컴포저블에서 이루어져야 한다. 그렇지 않고 다른 컴포저블에 전달해버리면 해당 컴포저블이 상태를 변경할 수 있기에 단일 정보 소스 원칙에 위배되고 버그 추적에 어려움을 겪는다.

정보 소스로서의 State Holder

여러 UI element들의 상태와 관련된 복잡한 UI 로직이 있는 컴포저블이라면 책임을 상태 홀더에 위임하는 것이 좋다. 책임을 분리함으로써 격리하여 테스트를 진행할 수 있고 컴포저블의 복잡성 또한 줄어들기 때문이다. 컴포저블은 UI element를 출력하는 데 집중하고 상태 홀더가 UI로직과 UI element 상태를 담당함을 의미한다.

상태 홀더 구현은 일반 클래스로 하되 컴포지션에 의해 생성되고 기억되기에 Compose 종속 항목을(ex. remember~state) 사용할 수 있다. 컴포지션에서 기억할 수 있도록 remember를 사용하여 함수를 작성한다.

// Plain class that manages App's UI logic and UI elements' state
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 도입 필요성이 증가한다.

참고할 점 - Compose UI element state 복원

만약 Activity 혹은 프로세스가 종료된 이후에도 보존하려는 상태를 상태 홀더에 포함하려면 rememberSaveable을 사용하고 상태에 맞는 커스텀 Saver를 만들면 된다.

정보 소스로서의 ViewModel

상태 홀더 클래스가 UI 로직과 UI element의 상태를 담당했었다. ViewModel은 다른 특별한 유형의 상태 홀더로서 다음의 작업을 맡는다.

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

참고할 점 - ViewModel의 수명 > Composable의 수명

ViewModel은 컴포지션보다 수명이 더 길다. config change가 발생하여 컴포지션이 종료되더라도 유지되기 때문이다. ViewModel의 수명주기는 Compose contents의(Activity or Fragment) 호스트 수명주기나 Navigation Graph의 수명주기를 따를 수 있다.

따라서, 컴포지션보다 긴 수명주기를 가지고 있기에 컴포지션의 수명과 바인딩된 상태를 참조해선 안된다. 컴포지션의 수명과 바인딩된 상태를 참조하여 장기 지속 참조가 이어지면 메모리 누수가 발생할 수 있기 때문이다.

주의할 점 - Composable 변수로 인스턴스 전달

ViewModel의 인스턴스를 다른 컴포저블 함수로 전달하면 안된다고 설명한다. 만약 컴포저블 함수에 ViewModel을 전달하면 해당 함수는 ViewModel의 특정 타입과 결합되어 재사용성이 떨어지고 테스트와 preview 동작에 어려움을 겪기 때문이다. 또한, ViewModel 인스턴스를 관리하는 명확한 단일 소스 저장소가 없어진다. ViewModel을 전달하면 여러 컴포저블이 ViewModel의 함수를 호출하고 상태를 수정할 수 있기 때문에 디버깅도 더 어려워진다. 따라서, UDF 권장사항에 따라 필요한 상태만 전달함이 옳다.

주의할 점 - ViewModel에서 UI element state 저장

ViewModel은 컴포저블 함수가 아니므로 컴포지션의 구성요소가 아니다. 따라서, 컴포저블에서 만든 상태를(remember~State) ViewModel이 보관해서는 안된다. 별도의 State Holder 클래스를 선언하여 해당 인스턴스를 보관해야 한다.

다음 코드는 Screen Level Composable에서 사용된 ViewModel의 예시 코드이다.

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() 함수는 현재 생성된 ViewModel 인스턴스를 불러오거나 새롭게 생성하는 함수이다. 함수 사용을 위해 build.gradle 파일에 특정 라이브러리 implementation이 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$version"

참고할 점 - 프로세스 재생성 후 ViewModel에서 복원할 항목

위 코드의 ExampleViewModel 클래스 생성자를 보면 savedState 이름으로 SavedStateHandle 타입의 변수를 선언했음을 확인할 수 있다. 흔한 경우는 아니지만 프로세스 재생성 후 보존할 상태가 ViewModel에 포함된다면 SavedSateHandle을 사용하여 상태를 지속하라고 설명한다. 이에 대해서는 기회가 된다면 다뤄볼 예정이다.

ViewModel과 State Holder

지금까지 Plain State Holder와 ViewModel의 역할을 보면 사뭇 다르다는 점을 발견할 수 있다. State Holder는 UI element state UI 로직(Behavior)에 대한 책임을 갖고, ViewModel은 Screen State(UI State), 비즈니스 로직에 대한 책임을 갖고 있다. 따라서, 두 가지의 상태 홀더를 화면 수준의 컴포저블에서 모두 다룰 수 있다.

하단의 코드에는 ViewModel이 UI state를 ExampleUiState로 다루고 State Holder가 UI element state를 ExampleState로 다루는 동작을 정의하였다.

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

ExampleState 클래스와 rememberExampleState함수가 갑자기 등장했으나 해당 클래스는 앞서 작성되었던 MyAppState 클래스와 rememberMyAppState 함수와 동일한 구조를 갖는다.

참고 자료

  1. Android Developers - 상태 및 Jetpack Compose

+ Recent posts