[Android] Jetpack Compose에서 State 완전 정복하기


개요

Android Jetpack Compose에서는 UI 업데이트의 기준으로 State라는 개념을 사용합니다. 얼핏보면 ReactFlutter 같은 SPA 방식의 프레임워크에서 사용하는 개념과 비슷해보이지만 분명히 안드로이드에서만의 State 개념이 존재합니다. 이에 대해 알아보고, Jetpack Compose를 사용할 때 state를 통해서 MVVM 아키텍쳐와 AACViewModel을 사용하여 단방향 데이터 플로우 패턴을 어떻게 적용하면 좋을지도 알아봅시다.


State로 UI 업데이트

Jetpack Compose에서는 state를 변경함으로써 UI를 업데이트합니다. State의 선언 방법은 다음과 같이 3가지가 있습니다.

1
2
3
4
5
val text = remember { mutableStateOf("") } // text.value = "Hello, Kotlin!"

var text by remember { mutableStateOf("") } // text = "Hello, Kotlin!"

val (text, setText) remember { mutableStateOf("") } // setText("Hello, Kotlin!")

첫 번째 방법과 두 번째 방법의 차이는 LiveData와 유사합니다. State 또한 LiveData와 동일하게 항상 value를 가지며, 첫 번째 방법은 State<T> 객체의 value로써 접근해야 하지만 두 번째의 경우 값 그 자체로 생각할 수 있습니다. 대신 두 번째의 경우 state를 변경할 경우 객체 자체에 접근하므로 val이 아닌 var을 사용해야 변경이 가능합니다. 반면 세 번째의 경우 React와 같은 웹 프레임워크에서 자주 등장하는 방식입니다. State와 그것을 초기화하는 함수를 따로 선언하는 방식입니다. 구글 안드로이드 Github 레포지토리를 참고해본 결과 두 번째 방법을 가장 선호하는 것 같습니다.

위에서 remember 키워드의 의미를 알아보기 전에, 이 state를 이용해서 다음과 같은 UI를 작성한다고 가정해봅시다.

0

위와 같이 Show more 버튼을 눌러 리스트 아이템 영역을 확장하려고 합니다. 이때 Compose에서는 하나의 state를 만들고 이에 따라 하단 padding 값을 다르게 해준 뒤, 버튼을 통해 state를 변경하는 식으로 구현할 수 있습니다. 코드로 보면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Composable
fun Example() {

    var expanded by remember { mutableStateOf(false) } //state 선언

    val extraPadding = if (expanded) 48.dp else 0.dp // state에 따른 padding 값

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding) // state에 따른 UI(padding) 변경
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more") // state 값 수정
            }
        }
    }
}

이와 같은 동작이 가능한 이유는, 하나의 Composable에서 state 값이 변경되면 해당 요소가 다시 그려지기 때문입니다. 이렇게 컴포저블이 재생성되는 과정을 recomposition이라고 합니다. 그런데 요소가 재생성되면 위 코드에서 expanded 상태도 계속 false 값으로 초기화되지 않을까요? 이러한 state의 초기화를 막아주는 요소가 바로 remember 키워드입니다. 정리하면 하나의 컴포넌트에서 state 변화가 일어나면 해당 컴포넌트는 리컴포지션을 거치고, 이때 remember 키워드를 통해 state를 기억함으로써 성공적으로 UI를 업데이트합니다.

따라서 위와 같이 Jetpack Compose에서는 state를 통해 직관적인 코드로 강력한 UI를 손쉽게 작성할 수 있습니다.


Stateless를 위한 state hoisting

Jetpack Compose의 컴포저블은 크게 statefulstateless로 나뉠 수 있습니다. Flutter에서 사용하는 용어와 동일한 개념인데, 말그대로 statefulstate를 갖으며 이를 직접 변경할 수 있는 컴포저블이고 stateless는 그렇지 않은 컴포저블을 말합니다. 이때 stateless 컴포저블은 stateful에 비해 다음과 같은 장점을 갖습니다.

  • 테스트하기 쉽다
  • 버그가 적다
  • 재사용성이 높다

그 이유는 의존성 주입을 사용하는 이유와 비슷합니다. Stateless 컴포저블에서 상태를 선언하는 것이 아닌 주입하는 방식으로 구현할 수 있기 때문입니다. 따라서 개발자는 최대한 stateful 컴포저블을 줄이고 이들을 stateless로 변경하는 것이 이상적입니다. 이러한 작업은 하위의 컴포저블에 선언된 state들을 이들의 공통 조상인 상위 컴포저블로 옮기는 방식으로 수행할 수 있습니다. 이를 state hoisting이라고 합니다. 한글로 직역하면 상태 끌어올리기 정도가 됩니다.

예를 들어, 다음과 같이 버튼을 통해 특정 컴포넌트를 숨기고 표시하는 예제를 생각해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
fun MyScreen(/* ... */) {
    var visible by remember { mutableStateOf(true) }

    Column() {
        if(visible) MyCardView(/* ... */) {/* ... */}
        MyButton(onClick = { visible = !visible }, visible = visible)
    }
}

@Composable
fun MyButton(
    onClick: () -> Unit,
    visible: Boolean
) {
    Button(onClick = onClick) {
        Text(if(visible) "hide" else "show")
    }
}

@Composable
fun MyCardView(/* ... */) {
    /* ... */
}

위와 같이 MyCardViewMyButtonstate를 선언할 필요 없이, 적절한 상단 컴포저블에 state를 선언한 뒤 상태의 값이나 상태를 변경하는 함수만 전달해주면 하위 컴포저블은 모두 stateless로 유지한 채 동일한 UI를 구현할 수 있습니다.

이와 더불어 state hoisting에는 다음과 같은 부가적인 장점이 있습니다.

  • 상태를 한 곳에서만 관리함으로써 버그 방지에 도움이 됩니다.
  • Hoisting한 state를 여러 컴포저블과 공유할 수 있습니다.

추가적으로, State어느 수준의 컴포저블까지 끌어올려야 할지 쉽게 파악할 수 있는 세 가지 규칙이 있습니다.

  • State는 적어도 이를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 합니다(읽기).
  • State는 최소한 이를 변경할 수 있는 컴포저블 중 가장 상위의 컴포저블로 끌어올려야 합니다(쓰기).
  • 동일한 이벤트에 대한 응답으로 두 state가 변경되는 경우 두 state를 같이 통일하여 끌어올려야 합니다.


생명주기 변경에도 state 유지하기

remember 키워드를 통해 리컴포지션 시에도 state를 유지할 수 있다고 했습니다. 그런데 remember 키워드는 컴포지션이 유지되는 동안에만 작동하기 때문에, 컴포저블이 그려지는 밑바닥의 activityfragment수명주기가 변경되어 버리면 state 또한 초기화되어 버립니다. 따라서 기기를 회전하거나 다크 모드로 전환 시에는 state가 초기화되어 화면이 바뀌어버릴 수 있습니다. 이를 해결하는 것이 바로 rememberSaveable 키워드입니다. 사용법은 remember 키워드와 동일합니다.

1
var importantState by rememberSaveable { mutableStateOf(true) }


ViewModel에서 state 사용하기

이번에는 Jetpack Compose를 사용하며 state를 통해 어떻게 MVVM 아키텍쳐와 단방향 데이터 플로우 패턴을 적용할지 알아봅시다.

기존의 View 방식을 사용할 때에는 ViewModelLiveData와 같은 관찰 가능한 데이터 홀더 타입의 객체를 사용했습니다. 그런데 Compose에서는 state 변화 시에 해당 상태와 연결된 컴포저블들이 리컴포지션을 거치기 때문에 ViewModel에서 state와 이를 업데이트하는 함수를 직접 선언하고, 컴포저블에 이들을 내려주면 됩니다. 코드로 보면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TodoViewModel : ViewModel() {

   // state: todoItems
   var todoItems = mutableStateListOf<TodoItem>()
    private set

   // event: addItem
   fun addItem(item: TodoItem) {
        todoItems.add(item)
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
       todoItems.remove(item)
   }
}

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem
   )
}

여기서 눈여겨볼 것은 TodoScreen에 바로 ViewModel을 주입하지 않고 TodoActivityScreen 이라는 하나의 컴포저블을 더 거쳐서 각각의 요소를 따로 전달한 점입니다. 만약 실제 UI를 그리는 TodoScreen에 직접 ViewModel을 전달하면, Android Studio의 Preview 기능 사용이 어려워지고 테스트 가능성이 떨어집니다. 따라서 위와 같이 UI를 직접 그리는 컴포저블에는 ViewModel을 직접 주입하지 않고 state holder를 담당하는 컴포저블을 한 단계 거치도록 하는 것이 좋습니다.

하지만 ViewModel에서 state가 아니라 꼭 LiveDataFlowStateFlow를 사용해야 하는 경우가 있습니다. 이때는 컴포저블에서 이들을 state로 변환하여 사용하면 됩니다. 다음과 같은 메서드를 통해 변환하여 컴포저블에서 사용할 수 있습니다.

1
2
LiveData<T>.observeAsState()
Flow<T>.collectAsState()


마치며

Android Jetpack Compose에서 State를 이용한 기본적인 UI 업데이트 방법과, 아키텍쳐 관점에서 이를 어떻게 사용해야 MVVM 및 단방향 데이터 플로우 패턴을 적용할 수 있는지 알아보았습니다. 직관적인 코드로 빠르게 강력한 UI를 작성할 수 있는 Compose 이지만, State에 대하여 충분히 이해하고 리컴포지션을 고려하여 작성하는 것과 그렇지 않은 코드에는 분명히 성능적인 차이가 있다는 것을 알 수 있었습니다. 또한 기존의 View 방식보다 단방향 데이터 플로우 패턴을 훨씬 깔끔하고 직관적으로 적용할 수 있다는 것도 Compose의 또다른 매력이자 장점인 것 같습니다.

0%