본 글은 Android developers' Jetpack Compose 공식문서를 참고하여 작성되었습니다.
Jetpack Compose를 공부하다보면 컴포지션, 리컴포지션 이라는 용어가 나옵니다. Composition은 사전적 의미로 '구성' 이라고 하는데 쉽게말해 @Composable 어노테이션을 활용하여 작성한 함수가 실제 UI로 구성되는 과정을 의미합니다.
즉 맨 처음 UI로 초기화 될 때는 composition, 클릭이나 다른 이벤트가 일어나 UI의 상태가 바뀌어 재구성 될 때는 recomposition이라 부르는 것입니다.
클릭하거나 데이터를 제출하여 어떠한 이벤트가 발생했을 때 UI에 변화가 필요할 수 있습니다.
위와 같이 버튼을 눌렀을 때 상세보기 페이지가 펼쳐지고, 버튼의 상태가 바뀌거나 하는 것과 같이 UI가 변경되어야 할 필요성이 있는데 이를 Recomposition이라 부릅니다.
처음 뷰가 초기화 되는 과정을 Composition이라 부른다고 했는데 데이터의 변화를 관찰하여 Composition이 다시 일어나는 과정을 Recomposition이라 부르는 것입니다.
그렇다면 Recomposition이 일어나기 위해서는 데이터의 변화를 관찰하여 뷰가 새로 그려지는 시점을 정의하는 트리거 역할을 무언가가 있어야 합니다.
'데이터의 변화를 관찰하여 뷰가 새로 그려지는 시점을 정의' 한다는 것은 공식문서에 의한 표현으로 '상태(State)가 변경되는 시점' 을 의미합니다.
상태(State)가 변경되는 시점을 관찰하기 위해서는
등의 클래스와 메서드를 활용할 수 있으며 아래에서 실습을 통해 알아보도록 하겠습니다.
@Composable
private fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colors.background
) {
Greeting("Android")
}
}
@Composable
private fun Greeting(name: String) {
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)) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton (
onClick = {
// TODO
}
) {
Text(text = "Show more",
color = MaterialTheme.colors.primary)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun DefaultPreview() {
Compose_practiceTheme {
MyApp()
MyApp()
}
}
위와 같은 코드를 통해 아래의 View를 만들었습니다.
이제 Show more 버튼을 눌러서 UI가 업데이트 되는 recomposition을 구현해야 하는데요 요구사항은 아래와 같습니다.
위 기능을 구현하기 위해서는 각 페이지가 현재 펼쳐진 상태인지를 나타내는 flag변수 하나가 필요합니다.
또한 flag 변수의 상태에 의해 버튼의 onClick 동작을 구현해주어야 합니다.
그래서 위의 Greeting 컴포넌트를
@Composable
private fun Greeting(name: String) {
var expanded = false // 펼쳐져 있는 지에 대한 상태변수
val extraPadding = if (expanded) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = {
expanded = !expanded
Log.d("is_expannded", expanded.toString())
}
) {
Text(text = if (expanded) "Show less" else "Show more",
color = MaterialTheme.colorScheme.primary)
}
}
}
}
와 같이 수정하였습니다.
Greeting이라는 컴포넌트가 펼쳐져있는 지를 나타내는 상태변수 expanded를 만들었고 expanded의 상태에 따라 ElevatedButton의 text가 바뀌고 Column 부분의 패딩이 동적으로 바뀌게 하여 펼쳐지는 효과를 주었습니다.
이론상 완벽하게 구현한것 같지만 이 뷰는 우리의 생각대로 동작하지 않습니다.
상태에 대한 변수를 지정해주긴 했지만, 상태 변화를 감지할 수 없기 때문에 아무짝에도 쓸모없는 변수가 되었기 때문입니다.
즉 상태에 대한 변수를 선언하기 위해서는 일반적인 변수가 아닌 상태 변화를 감지할 수 있는 타입의 변수가 필요하고 이를 MutableStateOf<T>
타입의 변수로 선언가능합니다.
상태 변화를 감지해서 recomposition을 일으키기 위해서는 MutableStateOf 변수를 사용하면 됩니다.
mutableStateOf()
메서드는 관찰 가능한 StateOf<T>
객체를 생성하는데 이는 런타임시 Compose에서 관찰 가능한 객체로써 구현되어 있습니다.
즉 MVVM 패턴에서 LiveData를 생성하고 이를 Observer에서 관찰하는 것과 동일한 효과라고 생각하시면 될것 같습니다.
위 코드에서 일반타입의 변수 expanded를 MutableStateOf로 바꾸기 위해 아래와 같이 선언하게 된다면
val expanded = mutableStateOf(false) // error
아마도 이런 오류를 마주치게 될 텐데요 위의 오류메시지와 설명을 통해 알 수 있는 정보로
정도가 있겠네요.
가장 첫번째 에러메시지에 해당하는 remember 키워드와 함께 사용해야 한다는 것은 무슨 의미일까요?
일단 remember에 대해 다루기 전에 recomposition이 어떤 과정을 통해 이루어지는지 알아볼 필요성이 있습니다.
상태의 변화가 일어날 때 마다 모든 UI가 업데이트 되는 것은 비효율적이며 UX에 있어서도 좋지않은 경험을 줄 것입니다.
따라서 recomposition이 일어날 때에는 필요한 Composable만 업데이트가 되도록 해야합니다.
먼저 Composable의 lifecycle을 보면 아래와 같습니다.
딱 3가지로 분류되는데
이것이 Activity나 Fragment에 비해 매우 간단한 Composable의 생명주기입니다.
그렇다면 Composition에 진입할 때에는 무슨 일이 일어날까요?
NewsFeed를 구성하는 화면을 띄운다고 가정해봅시다. 최종적으로 Newfeed가 Screen에 띄워져야 하고 NewFeed를 구성하는 각각의 작은 Composable단위인 StoryWidget들이 있습니다.
각각의 Composable 단위인 StoryWidget에는 MutableState와 같이 상태나 정보를 담은 데이터들이 있습니다.
즉 Composition이 일어나면서 전체 뷰 (NewsFeed)가 초기화되며 세부 단위인 각각의 작은 Composable들의 데이터가 초기화되게 됩니다.
그렇다면 Recomposition이 일어날 땐 어떻게 될까요?
가장 아래쪽의 작은 초록색 부분의 데이터가 변경되어 상태변화를 감지하게 되면 해당 데이터가 존재하는 Composable만 업데이트가 되도록 Compose Compiler에서 추적하여 계층적으로 업데이트 할 수 있습니다.
즉 어떤 상태 변수가 어떤 Composable 안에 있는지 컴파일러가 추적가능함으로써 상태변화에 의해 변경되어야만 하는 필요한 부분만 업데이트 될 수 있는 것입니다.
만약에 val expanded = mutableStateOf(false)
와 같은 코드를 사용해서 에러가 나지 않고 제대로 빌드되었다고 해봅시다.
그러면 expanded 변수의 값이 변화될 때를 감지하여 해당 expanded 변수가 있는 Composable에 recomposition이 일어날 것입니다.
@Composable
private fun Greeting(name: String) {
var expanded = mutableStateOf(false)
val extraPadding = if (expanded.value) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = {
expanded.value = !expanded.value
Log.d("is_expannded", expanded.value.toString())
}
) {
Text(text = if (expanded.value) "Show less" else "Show more",
color = MaterialTheme.colorScheme.primary)
}
}
}
}
즉 상태변화를 일으킨 버튼(ElevatedButton)이 속해있는 위의 Greeting 컴포넌트가 recomposition이 일어나면서 다시 처음부터 빌드가 될겁니다.
위 그림처럼 Composition은 데이터와 뷰를 모두 초기화 하는 과정이고 Recomposition역시 데이터의 변경을 감지하여 Composable의 모든 초기화 과정이 다시 일어나게 됩니다.
다시 위의 코드를 보면 버튼을 눌러서 expanded 변수가 false -> true로 토글되어 Recomposition이 일어나게 됩니다. 그러나 Composable이 다시 초기화가 되는 과정에서 expanded 변수 역시 다시 false로 초기화가 되버리기 때문에 상태를 저장할 수 없는 상태변수인 아이러니한 상황이 벌어지게 됩니다.
그래서 remember 키워드를 사용해야 합니다.
@Composable
private fun Greeting(name: String) {
var expanded = remember {mutableStateOf(false)}
val extraPadding = if (expanded.value) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = {
expanded.value = !expanded.value
Log.d("is_expannded", expanded.value.toString())
}
) {
Text(text = if (expanded.value) "Show less" else "Show more",
color = MaterialTheme.colorScheme.primary)
}
}
}
}
expanded 변수를 remember로 감싸 선언해주기만 하면 끝입니다.
remember가 하는 일은 recomposition에 의해 Composable의 초기화가 다시 수행될 때 remember 코드블럭 안의 내용은 실행되지 않도록 해줍니다.
즉 composition일 때에는 모든 코드가 동작하고, recomposition일 때에는 remember 안쪽의 코드를 제외하고 동작하게 함으로써 초기에만 데이터를 초기화 시키고 이후부터는 값을 유지할 수 있도록 하는 것입니다.
이제 제대로된 코드가 동작하겠네요!
정리해보면 아래와 같습니다.
- Composable의 라이프사이클은 가장 처음 초기화를 수행하는 Commposition과 업데이트가 일어나는 Recomposition으로 구성된다.
- Recomposition이 일어나기 위해서는 MutableStateOf() 메서드를 통해 MutableState 객체를 만들어 상태를 관찰할 수 있도록 해야한다.
- 데이터가 매번 초기화되지 않고 recomposition이 일어날 때 값을 유지하기 위해서는 remember 키워드로 감싸줘야 한다.