선언형 UI
Compose로 변경됨에 따라, 명령형 UI에서 선언형 UI로 변경되었다. 앞에서 이야기한 선언형 UI는 어떤 것이며, 명령형 UI와 어떤 차이점이 있을까?
기존 안드로이드 개발에서 데이터의 State가 변경되는 경우, findViewById를 통해 직접 Elements에 접근하여 setText, setImage와 같은 명령어를 사용하여 State를 직접 변경해야 했다. (ViewBinding 및 DataBinding도 개념은 같다.)
하지만 컴포즈는 선언형 UI이다. UI의 최종형태를 기술하면, 프레임 워크가 자동으로 관리한다.
구체적으로, 프레임워크가 데이터의 상태가 변하는 경우 Elements의 State를 업데이트하는 것이 아닌 화면 전체를 다시 그린다. 모든 화면을 그리는 것이 아닌, 컴포즈 컴파일러가 업데이트가 필요한 부분을 판단하여 부분 업데이트한다.
// Imperative
view = findViewById(R.id.view)
view.setText(helloText)
view.setFontFamily(jainoFont)
// Declarative
Text(
text = helloText,
fontFamily = jainoFont
)
이런 선언형 Ui의 장점은 아래와 같다.
- 기존에 수동으로 업데이트시 발생했던 오류를 줄여준다.
- 수동적으로 변경하는 것은 구현해야할 복잡성이 높아지고, 실수로 업데이트를 빼먹을 가능성도 높다.
- 여러 환경에서 동시에 UI 상태를 수동으로 업데이트할 때 충돌이 발생할 수 있다.
- 관리해야할 요소가 많을 수록, 유지보수성은 더 어려워진다.
- 작성 코드량 감소
- 직접 코드를 통해 업데이트 하는 작업은 많은 코드량을 발생시킨다.
- 람다 없이 자바로 개발한다면, 상태 하나만 변경했을 뿐인데, 5줄은 그냥 먹고 들어가는게 태반이다.
그렇다면 어떻게 Compose는 State의 변경을 확인하여 자동으로 업데이트할까?
이는 Compose State Object를 활용하는 것이다. 이번 포스팅에서는 Compose State와 State를 관리하는 방법에 대해 소개한다.
Recomposition
Compose는 State가 변경되면, 관련된 Composable 함수를 호출한다. 호출된 함수는 재구성되며, 관련된 UI가 다시 그려지게된다. 이렇게 재구성되고, UI가 다시 그려지는 것을 Recomposition이라고 하며, Recomposition의 특징은 다음과 같다.
- State와 관련된 함수 혹은 람다만 호출하고 영향을 받지 않는 나머지는 건너뛴다.
- 최대한 많이 건너뛰도록 설계되어 있다. 변경된 State의 같은 함수 안, Column(열)의 요소들 중에서도 Recompose가 되지 않을 수 있다.
- 에니메이션이 랜더링될 때와 같이 높은 빈도로 Recomposition은 재실행될 수 있다.
- Recomposition은 낙관적이고 취소가 가능하다.
- 다시 State가 업데이트가 되기 전까지 Recomposition이 완료될 수 있다고 생각한다.
- Recomposition이 실행되고 끝나기 전에, 다시 State가 변경된다면, 진행되고 있는 Recomposition을 취소하고 다시 업데이트된 State와 함께 새로운 Recomposition이 시작된다.
Compose State
모든 Composable 함수의 매개변수가 변경되었다 해서, Recomposition이 발생하지 않는다. 아래의 코드를 확인하자.
@Composable
fun Counter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count = 0
Text("$count times.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
해당 코드는 카운터를 만든 컴포저블 함수의 예제이다. 버튼을 클릭함으로써, count의 숫자를 늘렸으니, 당연히 Recomposition이 발생해 Text에 count가 업데이트되는 것을 기대할 수 있다. 하지만 동작하지 않는다.
이는 컴포즈가 해당 count의 State를 추적하지 않고 있기 때문이다. 그러면 어떻게 해야 추적이 가능하게 할까?
Compose의 State와 MutableState를 활용하면, 컴포즈에서 상태를 추적하고 Recomposition을 예약할 수 있다. mutableStateOf 함수를 통해 구현할 수 있고, 해당 함수는 MutableState<T> 객체를 리턴하고, 컴포즈는 State가 변경될 때마다 변경 사항을 추적해서 UI를 업데이트한다.
@Composable
fun Counter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = mutableStateOf(0)
Text("${count} times.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
이제 MutableState를 활용하였으니, 당연히 count가 업데이트되는 것을 기대할 수 있다. 그러나 카운트는 0에서 변경되지 않는다.
Recompostiton이 발생하지 않은 것일까? 아니다. Recomposition은 아주 잘 작동한다. 문제는 State를 0으로 초기화를 했기 때문이다. 계속해서 Recomposition은 발생하지만, 0으로 계속 초기화되고 있어 카운트는 변경되지 않은 것이다.
이는 컴포즈가 이전 count의 값을 기억하지 않고 있기 때문이다. 그러면 어떻게 해야 이전의 값을 기억하게 할 수 있을까?
Compose의 Composable inline 함수 remember을 활용하면, 컴포즈에서 초기 Composition에서 값을 기억하고, Recomposition 되어도 값을 유지시킬 수 있다.
@Composable
fun Counter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = remember { mutableStateOf(0) }
Text("${count.value} times.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
remember를 활용하면, 이전의 값을 잘 기억하고 있어 카운터가 정상적으로 동작하는 것을 확인할 수 있다.
하지만 remember를 사용하면 getter와 setter를 통해 값에 접근해야 한다.
by를 사용하면 count를 var로 정의하여 매번 value를 참조하지 않고도 간접적으로 읽고 수정할 수 있다.
@Composable
fun Counter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
Text("${count} times.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
remember는 설정 변경(Configuration Change)에 대응하지 않는다. 따라서 remember를 사용해도, 화면을 회전하면 다시 값이 초기화된다. 이를 방지하기 위해 rememberSaveable을 지원한다.
RememberSaveable을 사용하면 자동으로 saveState을 사용하여 설정 변경에 영향을 받지 않게 할 수 있다.
@Composable
fun Counter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by rememberSaveable { mutableStateOf(0) }
Text("${count} times.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
Stateful and Stateless Composables
위 예제에서 count를 가지는 remember 변수로 가지는 Counter함수는 Stateful 함수이다. Stateful 함수는 필요한 상태를 함수 내부에서 가지고 있는 함수를 의미한다.
이러한 Stateful 함수는 외부에서 해당 함수의 State를 관리하거나 제어할 필요가 없다. 그러나 내부에 State를 가지고 있기 때문에 재사용성이 떨어지며, 테스트하기도 더 어려운 경향이 있다.
Counter와 반대로 state를 외부에 두는 Stateless 함수가 존재한다. Stateless 함수는 Stateful과 반대로 외부에서 State를 제어 및 관리해야 하지만, 재사용성과 테스트성이 더 뛰어나다.
이러한 장점 때문에, 모든 함수를 Stateless로 만들고 싶을 수도 있다. 그러나 Stateless가 항상 좋은 것은 아니며, 이는 권장하는 패턴도 아니다. 실제 구글은 가능하면 composable함수는 적은 state를 가지고 있도록 구성하고, state를 hositing하도록 권장한다.
// Stateful
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Text("${count} times.")
}
// Stateless
@Composable
fun Counter(count: Int) {
Text("${count} times.")
}
State Hoisting
그렇다면, 어떻게 외부에 State를 구성하는 Stateless 함수를 구현할 수 있을까?
StateHositing을 통해 Stateless를 구현할 수 있다. StateHositing은 함수 내부에 사용되는 State를 매개변수를 통해 caller로 전달하는 방법이다. Compose에서 State Hoisting을 하는 일반적 패턴은 State 변수를 다음 두 개의 매개변수로 바꾸는 방법이다.
- value T: 표시해야 할 값
- onValueChage: (T) -> Unit: T의 값이 변경될 때, 변경을 요청하는 이벤트
- 다른 상호작용에 발생하는 이벤트
State Hositing을 사용하면 다음과 같은 특징을 얻을 수 있다.
- Single source of truth : 상태를 복제하는 대신 옮겼기 때문에, 정보 소스가 하나에만 있다.
- Encapsulated : 상태가 caller로 끌어올려졌더라도 해당 상태는 위치한 composable의 내부 속성이 되므로 캡슐화된다.
- Sharable : 호이스팅한 상태를 여러 컴포저블과 공유할 수 있다. 다른 컴포저블에서 hositing한 state를 다시 사용할 수 있다.
- Interceptable : 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있다.
- Decoupled : 스테이트리스(Stateless)의 상태는 어디에나 저장할 수 있다. 예를 들어 예제의 count를 ViewModel로 옮길 수 있다.
특징을 위의 Counter 예제에 적용한다면, 다음과 같은 요구사항 및 코드를 작성할 수 있다.
count 값은 함수와 분리되어 viewModel에서 관리한다. ViewModel에서 count가 10회가 넘는 경우, 더 이상 증가하지 않는다. 랭킹 시스템에도 count를 전달하여, 실시간 변경되는 랭킹 시스템을 확인할 수 있어야 한다.
class CounterViewModel: ViewModel(){
var count by mutableStateOf(0)
private set
fun updateCount(){
if(count < 10){
count++
}
}
}
@Composable
fun StatelessCounter(viewModel: CounterViewModel = viewModel()){
Button(onClick = viewModel.updateCount()) {
Text("Clicked ${viewModel.count} times")
}
}
다시 한번 말하지만, StateHositing이 정답이 아니다.
상위에서 사용하지 않는 상태까지 올려버리면, 인자가 기하급수적으로 늘어나 가독성을 해치고, 상위부터 컴포지션이 발생해 성능에도 영향을 줄 수 있다.
그럼 StateHositing하는 기준은 어떻게 될까 ?
- State를 사용하는 모든 컴포저블 함수의 최소 공통 항목으로 호이스팅 되어야 한다.
- State는 수정할 수 있는 최고 수준으로 호이스팅 되어야 한다.
- 동일한 이벤트에 대한 응답으로 여러 State가 한번에 변경되면 함께 호이스팅 되어야 한다.
요약하면, 상위에서 사용하는 State를 최소 공통 항목까지 호이스팅하고, 호이스팅하는 상태는 최대로 (값, 이벤트, 변경사항) 전달해야 한다.
Bonus : Compose State vs StateFlow
기존 xml 개발에서는 ViewModel에서 State를 StateFlow로 관리하였다. Compose에서는 CollectAsStateWithLifeCycle을 사용하여 StateFlow를 사용할 수 있다. 이 함수는 flow에서 값을 수집하고 최신 값을 Compose state로 나타내는 라이프사이클 인식 방식의 composable 함수이다.
두 개가 같은 기능을 할 수 있다면 어떤 것을 쓰는게 더 좋을까? 대부분 StateFlow를 추천하며, 그 이유는 아래와 같다.
- Flow operation
- map, filter, combine과 같이 State에서 제공하지 않는 Flow 연산을 사용할 수 있다.
- SaveStateHandle
- Compose State를 사용하는 경우 SaveStateHandle을 수정하고, 따로 값을 Compose State에 대입해야 한다.
- StateFlow는 getStateFlow() 함수를 사용할 수 있다. 해당 함수를 통해, SaveStateHandle을 수정하면 자동적으로 값을 emit 받을 수 있는 StateFlow로 변환한다. 해당 함수를 통해 코드가 간결해지고, saveStateHandle을 효율적으로 사용할 수 있다.
- Non-compose Project
- Compose 100% Project를 한다면, State만을 사용할 수 있다. 그러나 xml과 섞여 있는 프로젝트이거나, kmm Project의 경우(Swift를 사용해 Ui를 구성)의 경우 공통된 코드베이스는 Flow로 구성해야만 한다.
마지막으로
컴포즈는 매번 새롭게 다가온다. 기존에 사용하지 않는 패턴과 개념이 자주 등장해 배우는 재미가 확실히 있다. 이번 포스팅을 준비하며, 다양한 곳에서 참고를 하였는데, 구글의 코드랩과 안드로이드 디벨로퍼 공식 레퍼런스가 너무 잘되어 있다. 그만큼 구글이 약간 진심으로 컴포즈를 준비하고 있는 것이 느껴졌다. 필자도 컴포즈 준비를 천천히 해볼 예정이고, 언젠가 맞이할 컴포즈 세상을 기대한다.
소스코드
https://github.com/jeongjaino/EveryAndroid3/tree/main/ComposeCamp1
참고문헌
https://tourspace.tistory.com/410
https://developer.android.com/jetpack/compose/state?hl=ko
https://developer.android.com/codelabs/jetpack-compose-state?hl=ko&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%3Fhl%3Dko%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-state#0 https://www.youtube.com/watch?v=T8vApYJlW8o