Native UI를 Build하기 위한 Android의 최신 Tool Kit으로 적은 양의 코드와 강력한 도구를 지원하고 직관적인 Kotlin API로 UI 개발을 간소화하고 가속화할 수 있다. Compose의 경우, 선언적 접근 방식을 사용하여 데이터를 UI 계층 구조로 변환하는 함수를 호출하여 UI 생성이 가능하다. 데이터가 변경될 경우, 함수를 재실행하여 UI 계층 구조를 업데이트한다.
👉 테스트하기 쉽고 버그 발생 가능성이 감소하며 코드를 읽고, 이해하기 쉽다. 또, 유지관리할 코드가 적어져서 편리하다. Android View 시스템보다 적은 코드로 더 많은 작업이 가능하다.
👉 선언적 API 사용을 통해 Compose가 나머지 처리를 맡아서 하므로 UI를 설명하기만 하면 된다. 특정 이벤트나 Fragment에 종속되지 않은 Stateless 구성 요소를 빌드하여 코드를 재사용하고 테스트하기 쉽다. 또, Compose의 상태가 명시적이고, Composable로 전달하여 상태에 관한 정보 출처가 하나가 되도록 캡슐화하고 분리한다.
👉 Views, Navigation, ViewModel, Kotlin Coroutine 등의 Android 요소들을 Compose와 함께 쓸 수 있다. 또, 실시간 미리보기 기능을 사용하여 코드를 더 빨리 확인 가능하고 수정할 수 있다.
👉 Android Platform API에 직접 액세스하여 Material Design, 어두운 테마, Animation 등을 사용할 수 있다. Animation의 경우, 라이브러리 사용을 통해 쉽고 빠르게 동작을 구현할 수 있다. Android Platform API를 통해 원하는 디자인을 유연하게 구현하여 Compose에 적용할 수 있다.
Compose 사용 시, 전달받은 데이터를 바탕으로 Composable 함수로 정의하여 UI를 구현한다. 아래 예시는 간단한 Greeting 위젯으로, 전달받은 name 데이터를 Text 위젯에 표시한다.
@Composable
fun Greeting(name: String) {
Text("Hello Kotlin")
}
❗주의 사항
- Composable 함수를 정의할 때, 해당 함수가 전달받은 데이터를 바탕으로 UI로 변환하기 위한 함수라는 것을 컴파일러에게 알리기 위해 @Composable 주석이 필요.
- Composable 함수는 매개변수를 통해 데이터를 받을 수 있고, 매개변수를 통해 UI를 구성할 수 있음.
기존의 UI를 구성하는 방식인 명령형 UI 모델은 Tree 형태로 View를 구성한다. 하지만, 이와 같은 모델은 개발자 관점에서 XML 리소스를 반복적으로 낭비하게 되고, 변경사항이 있을 때마다 findViewById, ViewBinding, DataBinding 등을 통해 노드 접근 후 setText() 등의 메소드를 사용해야 한다. 이로 인해 불필요한 코드가 만들어지면서 하나의 노드에 대한 업데이트 충돌이 발생할 수 있다. 그러나, Compose와 같은 선언형 UI 모델은 새로운 데이터가 들어올 때마다 Composable 함수를 재호출한다. 이를 재구성이라고 하며, 새로운 데이터를 기반으로 재구성할 때 변경될 부분의 함수 혹은 람다만 호출하고, 변경되지 않은 나머지 부분은 건너뛴다.
Compose를 업데이트하기 위해서는 새로운 데이터로 동일한 Composable 함수를 재호출해야 하는데, 이때 새로운 데이터는 UI의 상태를 표현한다. 즉, 상태가 업데이트될 때마다 재구성이 실행된다. 그리고 재구성에 필요한 상태는 remember API를 사용하여 메모리에 객체를 저장하게 된다. remember에 의해 상태는 초기 컴포지션 중에 저장되고, 저장된 상태는 리컴포지션 중에 반환된다. remember에는 변경 가능한 객체와 변경할 수 없는 객체 둘 다 저장할 수 있다.
❇️ 용어 참고
- 컴포지션(Composition): Jetpack Compose가 Composable을 실행할 때 빌드한 UI
- 초기 컴포지션(Initial Composition): 처음 Composable을 실행하여 생성된 컴포지션
- 리컴포지션(Recomposition): 데이터 변경 시 컴포지션을 업데이트하기 위해 Composable을 재실행하는 것
remember에 저장할 객체를 선언할 때, mutableStateOf 함수를 사용하고, 이 함수는 관찰 가능한 MutableState를 생성하는데, 이는 런타임 시 Compose에 통합된다.
interface MutableState<T> : State<T> {
override var value: T
}
위와 같이 State의 value가 변경되면, value를 읽는 Composable 함수의 리컴포지션이 예약된다. 예를 들어 ExpandingCard의 경우, expanded가 변경될 때마다 재구성된다.
Composable에서 MutableState 객체를 선언하는 방법에는 세 가지가 있다.
val mutableState = remember { mutableStateOf(default) }
val value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
remember는 재구성 과정 전체에서 상태 유지가 가능하지만, 구성 변경(화면 회전 등) 전반에서는 상태가 유지되지 않는다. 이때는 rememberSaveable을 사용해야 하고, 이 경우 값을 bundle로 사용하기 때문에 primitive type, string, array 등을 저장할 수 있다. 그 외의 Custom Object는 Saver Object를 사용해야 한다.
remember를 사용하여 객체를 저장하는 Composable 함수는 내부 상태를 생성하여 Composable을 Stateful로 만든다. Stateful Composable 함수는 호출자가 상태를 제어할 필요가 없고, 상태를 직접 관리하지 않아도 상태를 사용할 수 있다. 그러나 이러한 Composable 함수는 재사용 가능성이 적고 테스트하기 어려운 경향이 있다.
Stateless Composable 함수는 상태를 갖지 않는 Composable 함수이다. Stateless의 경우 상태를 제어하거나 끌어올려야 하는 호출자에게 필요하고, Stateless를 만드는 방법으로 상태 호이스팅이 있다.
Compose에서 상태 호이스팅은 Composable 함수를 Stateless로 만들기 위해 상태를 Composable 함수의 호출자로 옮기는 패턴이다.
끌어올린 상태에는 중요한 속성이 있고 다음과 같다.
@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it},
label = { Text("Name") }
)
}
}
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
}
❗ 주의 사항
- 상태는 적어도 그 상태를 사용하는 모든 Composable 함수의 가장 낮은 공통 상위 요소로 끌어올려야 함.
- 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 함.
- 동일한 Event에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 함.
활동 또는 프로세스가 다시 생성된 이후, 화면 회전과 같은 구성 변경 이후에 rememberSaveable을 사용하여 UI 상태를 복원한다. rememberSaveable은 재구성 과정 전체, 활동 및 프로세스 재생성 전체에서 상태를 유지한다.
Bundle을 통해 primitive type, string, array 등의 모든 데이터 유형을 자동으로 저장한다. 그러나 Bundle에 저장할 수 없는 Object의 경우 아래와 같은 방법으로 저장할 수 있다.
가장 간단한 방법으로 @Parcelize Annotation을 추가하여 State Object를 Parcelize로 바꾸는 방법이다.
@Parcelize
data class City(val name: String, val country: String) : Parcelable
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(City("Madrid", "Spain"))
}
}
Map 형태인 (Key, Value)로 Object의 속성을 직접 지정하여 저장하는 방법이다.
data class City(val name: String, val country: String)
val CitySaver = run {
val nameKey = "Name"
val countryKey = "Country"
mapSaver(
save = { mapOf(nameKey to it.name, countryKey to it.country) },
restore = { City(it[nameKey] as String, it[countryKey] as String) }
)
}
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}
Map의 키를 정의하지 않고 색인을 키로 사용하는 방법이다.
data class City(val name: String, val country: String)
val CitySaver = listSaver<City, Any>(
save = { listOf(it.name, it.country) },
restore = { City(it[0] as String, it[1] as String) }
)
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}