Android/Jetpack Compose

Jetpack Compose State 정리

MJ핫산 2023. 1. 12. 00:37

Jetpack Compose는 리액티브한 프레임워크다. 그래서 UI를 변경하기 위해서 setText()setColor() 같은 함수를 부르지 않고, 상태를 변경해주면 UI도 자동으로 변경된다.

그럼 컴포즈는 UI 상태가 변했음을 어떻게 인식할까? 바로 Jetpack Compose State Object를 사용하는 것이다. 이번 글에서는 바로 이 State에 대해 알아본 것을 정리해봐야겠다. ദ്ദി˙∇˙)ว

💡 Jetpack Compose에서 상태(State)란?

Jetpack Compose에서 상태란 UI의 업데이트와 관련있다. 위에서 언급한 것 처럼 상태 값이 변경될 때 마다 UI가 업데이트되기 때문이다.

상태 값은 어떤 타입이든 될 수 있다. Boolean, String 같은 단순한 값일 수도 있고, 렌더링된 화면 상태에 대한 여러 값을 포함한 data class 일 수도 있다.

컴포즈가 상태 변경을 인식하기 위해서는 상태 값을 mutableStateOf()를 사용해서 State Object로 감싸면 된다. mutableStateOf() 함수는 MutableState<T> 객체를 리턴하고, 컴포즈는 값이 변경될 때마다 변경 사항을 추적해서 UI를 업데이트한다.

🍪 State Object는 어떻게 만드는건가요?

아래 코드가 바로 컴포저블 함수에서 상태를 나타내기 위해 사용한 코드이다. 자세히 살펴보자면:

@Composable
fun MyComponent() {
    var enabled by remember { mutableStateOf(true) } // <- this line

    // ...
    Text("Enabled is ${enabled}")
}
  • mutableStateOf(true)는 상태에 대한 값을 보유할 MutableState<Boolean> 객체를 반환하고, 상태의 초기 값은 true이다.
  • remember{}은 컴포저블 함수가 재구성 되더라도 람다{}를 실행하지 않고, 전달된 값을 기억해야 한다고 컴포즈에 말한다.
  • by는 kotlin delegates 키워드인데, 이 키워드를 사용하면 mutableStateOf()MutableState 객체를 반환하는게 아니라 그냥 Boolean 타입을 반환하는 것처럼 된다.

    다음은 mutableStateOf(), remember{}, by 중 하나라도 제대로 사용하지 않았을 때 발생할 수 있는 일들이다.

mutableStateOf() 가 빠졌다면

@Composable
fun MyComponent() {
    var enabled by remember { true }

    // ...
    Text("Enabled is ${enabled}")
}

👎 enabled 값을 수정해도 UI가 상태가 변경되었음을 인식하지 못해서 UI에 변화가 생기지 않는다.

remember{} 가 빠졌다면

@Composable
fun MyComponent() {
    var enabled by mutableStateOf(true)

    // ...
    Text("Enabled is ${enabled}")
}

👎 이 코드 역시 제대로 동작하지 않는다. enabled 값을 false로 바꾸면 이 컴포저블 함수는 상태의 변화를 인식해서 UI를 변경(리컴포지션)하겠지만, 이 과정에서 mutableStateOf()를 한 번 더 실행하게 되고 state 객체가 다시 초기값인 true로 생성될 것이다. 그러면 UI도 다시 true인 상태로 렌더링 되기 때문에 항상 true인 UI만 보이게 될 것이다.

우리는 컴포저블 함수가 얼마나 자주, 얼마나 많이 실행될 지 컨트롤 할 수 없기 때문에 항상 리컴포지션에 주의해야 한다.

추가로 remember은 컴포저블 함수 안에서 상태를 생성하는 경우에만 사용해주면 된다. 만약 ViewModel에서 상태 객체를 생성하는 경우 remember로 그 상태를 감쌀 필요는 없다. 하지만 ViewModel은 재생성되면 안되기 때문에 remember로 감싸주어야 한다. (viewModel{} 이나 hiltViewModel{}이 대신 해주기도 한다.)

by 가 빠졌다면

@Composable
fun MyComponent() {
    var enabled = remember { mutableStateOf(true) }

    // ...
    Text("Enabled is ${enabled.value}")
}

🙆 이건 by를 사용하지는 않았지만 작동한다!

하지만 더 이상 enabled를 그냥 Boolean처럼 사용하지는 못하기 때문에 state를 변경하기 위해서는 위에 예제처럼 state.value 를 사용해서 값에 접근해야 한다.

by를 사용하고 안하고는 개인의 취향 차이니까 좋아하는 방법으로 개발하자! ˛(ˊʙˋ)੭˒˒

⚔️ Stateful vs Stateless Composables

우선 상태를 가지고 있는 컴포저블 함수를 Stateful 하다고 하고, 상태를 가지고 있지 않은 함수를 Stateless라고 한다.

Stateless Composables

대부분의 경우 컴포저블 함수를 Stateless하게 유지하고 싶어한다. 이상적으로는 전체 화면의 상태가 한 곳(보통 뷰모델)에서 계산되고, 이 상태가 모든 컴포저블 함수에 전달되어야 한다. 그러면 개발자는 무슨 일이 일어나고 있는지 파악하는 것이 쉽고, 테스트도 쉬워진다.

Stateless한 컴포저블은 이렇게 생겼다.

@Composable
fun MyStatelessButton(label: String, onClick: () -> Unit) {
    Button(onClick) {
        Text(label)
    }
}

MyStatelessButton은 호출자가 넘겨주는 label과 onClick 리스너에 의존해있고, State를 가지고 있지 않다. 이게 Stateless한 함수다.

Stateful Composables

스크린 레벨의 컴포저블 함수는 전체 화면의 상태를 유지하기에 적합하다. 일반적으로 이런 컴포저블이 화면 전체의 상태를 계산하는 ViewModel을 참조하고 있다. 그래서 새로운 상태가 발행되면, 필요한 값들을 하위 객체로 전달한다.

스크린 레벨의 컴포저블(Stateful)은 이렇게 ViewModel을 가지고 있다.

@Composable
fun HomeScreen() {
    val homeViewModel = viewModel { HomeScreenViewModel() }

    val state by homeViewModel.inputText
    // TODO use state
}

State Hoisting

State Hoisting은 컴포저블 함수가 상태를 가지고 있지 않도록 없애는 것을 의미한다. 그래서 상태를 가지고 있는 것 대신에 함수 파라미터로 상태를 전달한다.

이렇게 Stateful한 함수를:

@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

State Hoisting을 사용해서 상태를 없앨 수 있다.

@Composable
fun StatelessCounter(count: Int, onClick : ()->Unit){
    Button(onClick = onClick) {
        Text("Clicked $count times")
    }
}

끝! Counter 컴포저블 안에 있는 상태(mutableStateOf())를 지우는 대신 함수 파라미터로 count 값을 받아서 보여준다. 또 버튼을 클릭할 때 호출자에게 이벤트를 알릴 수 있도록 파라미터로 onClick 리스너도 전달받았다. 결과적으로 UI로직을 Counter로직에 연결하지 않고, 앱의 여러 위치에서 사용할 수 있게 되었다. ˘ᵜ˘

👽 상태를 변경하여 컴포저블을 업데이트하는 방법

라이브러리에 내장된 컴포저블들을 더 많이 사용하게 될 수록 Jetpack Composable 모든 곳에 상태가 있다는 것을 깨달을 수 있다. 대부분의 컴포저블은 State Object를 파라미터로 보여주므로, 다른 컴포저블 함수에서 상태를 수정해서 상태를 가지고 있는 컴포저블을 업데이트 할 수 있다. 이 바텀시트 코드를 예시로 보자면

val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)

ModalBottomSheetLayout(
    sheetContent = {
        BottomSheetContent()
    },
    sheetState = sheetState
) {
    Button(
        onClick = {
            scope.launch {
                sheetState.show()
            }
    }) {
        Text("Show Sheet")
    }
}

이 예제에서 sheetState는 ModalBottomSheetLayout에서 사용된다. 그리고 sheetState는 Button의 onClick 리스너로 전달되어 Button에서 상태를 변경할 수 있다. 이것이 Jetpack Compose의 공통된 패턴이다.

rememberModalBottomSheetState()remember{mutableStateOf(ModalBottomSheetState)}로 감싸져있는 편리한 함수이다.

  • 사용자가 만든 컴포저블 함수일 경우 상태를 나타내기 위한 전용 클래스까지 만들 필요는 없다고 한다. 대부분의 경우 컴포저블 함수에 텍스트를 표시해야 한다면 그냥 String을 매개변수로 제공하는 것만으로도 충분할 듯 하다. ˣ‿ˣ

🎁 BONUS: Jetpack Compose와 Kotlin Flow, RxJava, LiveData는 어떻게 같이 사용할까?

Jetpack Compose에서 상태를 나타낼 때 LiveData, RxJava, Kotlin Flow를 역시 사용할 수 있다. 각각의 확장함수를 사용하면 되는데 각각 reactive object를 Compose State로 변환해준다.

Kotlin Flow

컴포저블 함수에서 다음과 같이 사용한다.

val flow = MutableStateFlow("")
// ...
val state by flow.collectAsState()

// for lifecycle aware version
val state by flow.collectAsStateWithLifecycle()

LiveData

app/build.gradle에 디펜던시를 추가한다.

dependencies {
    implementation "androidx.compose.runtime:runtime-livedata:x.y.z"
}

그리고 컴포저블 함수에서는 이렇게 사용한다.

val liveData = MutableLiveData<String>()
// ...
val state by liveData.observeAsState()

RxJava

app/build.gradle에 디펜던시를 추가한다.

dependencies {
    implementation "androidx.compose.runtime:runtime-rxjava2:x.y.z"
    // or implementation "androidx.compose.runtime:runtime-rxjava3:x.y.z"
}

그리고 컴포저블 함수에서는 이렇게 사용한다.

val observable = Observable.just("A", "B", "C")

val state by observable.subscribeAsState("initial")

'Android > Jetpack Compose' 카테고리의 다른 글

RecyclerView Compose로 마이그레이션 해보기 (1)  (64) 2023.09.25