[Android] Smart Recomposition[1] - Key[Positional memoization]
오늘 이야기하고 싶은 것은 Jetpack compose의 최적화에 대한 이야기이다.
그전에 Composable 함수의 Data -> UI 과정을 대략적으로 살펴보자.
Composition Step에서는 UI Tree를 그린다.
Layout Step에서는 트리를 순회하며 자식 노드의 크기를 측정하고 위치를 배정하며
그것을 토대로 자신의 크기를 측정하는 일을 수행한다.
Drawing Step에서는 UI Tree를 선회하며 각 node를 픽셀을 그린다.
우리가 Composable을 활용하여 UI를 구성할 때 State 값의 변경을 통하여 Recomposition이 발생하도록 의도하는 부분이 있을 것이다.
하지만 그 외의 부분 UI의 변화를 의도하지 않은 곳에서 마저 Recomposition이 발생한다면 효율면에서 좋지 못할 것이다.
Jetpack compose에서는 각 Composable을 Composable Tree에 그리고 각 위치에 대해서 identity를 준다.
그리고 function memoization과 같이 Recomposition이 발생하였을 때 연산을 다시 실행하지 않고
기존 값을 사용하는 방식을 채택하였다.
위의 결과로 리스트의 값을 UI로 하는 Composable들과 이들의 parent Composable이 있다고 가정했을 때
자식 Composable들에게 Recomposition이 트리거 되어도 key값과 input 값이 보존되는 한에서는 기존 값을 그대로
사용할 수 있다는 것이다.
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NetWorkMemoTheme {
val columnList = remember { mutableStateListOf<Element>() }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Scaffold(
floatingActionButton = {
Column {
FloatingActionButton(
onClick = {
columnList.add(
Element(
id = ++Element.idGenerator,
text = "${Element.idGenerator}",
color = makeRandomColor()
)
)
}
) { Icon(imageVector = Icons.Filled.ArrowDownward, contentDescription = "") }
FloatingActionButton(
onClick = {
columnList.add(
index = 0,
element = Element(
id = ++Element.idGenerator,
text = "${Element.idGenerator}",
color = makeRandomColor()
)
)
}
) { Icon(imageVector = Icons.Filled.ArrowUpward, contentDescription = "") }
}
}
) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize()
) {
columnList.forEach { element ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(color = element.color)
) {
Text(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(16.dp),
text = "Text ${element.text}",
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
}
}
fun makeRandomColor(): Color {
val a = 0xFF000000
val b = Random.nextLong(0, 0xffffff)
return Color(a + b)
}
data class Element(
val id: Int,
val text: String,
val color: Color,
) {
companion object {
var idGenerator = 0
}
}
아이디, 텍스트, 색을 가지고 있는 Element data class를 만들어주고
Element 리스트의 삽입이 있을 때 Recomposition이 트리거 될 수 있도록 columnList를
MutableStateList로 만들어 주었다.
Scaffold에 2개의 버튼을 만들었고 하나는 columnList의 tail에 Element를 삽입,
나머지 하나는 head에 Element를 삽입하도록 해주었다.
그리고 테스트를 해보자.
columnList의 tail에 element를 추가할 때 기존에 있던 Composable들의
recomposition skip count가 증가하는 것을 확인할 수 있다.
그렇다면 head에 추가하면 어떻게 될까?
기존에 있던 Composable들의 Recomposition count가 증가하는 것을 확인할 수 있다.
위의 예시처럼 iteration을 사용하여 Composable들을 정의해 줄 경우에는 그 순서에 따라서
identity가 결정되기 때문에 Composable의 key와 input value의 매치가 되지 않아서 기존 값을 그대로 사용할 수 없게 된다.
따라서, 모두 Recomposition이 발생하게 되고 위가 그 결과라고 할 수 있겠다.
하지만 이 identity를 명시적으로 정해줄 수 있는 방법이 있으니 그것이 key Composable이다.
key를 사용하여 테스트해보자.
) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize()
) {
columnList.forEach { element ->
key(element.id) { // 변경
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(color = element.color)
) {
Text(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(16.dp),
text = "Text ${element.text}",
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
head, tail에 어디에 추가하는지 상관없이 Recompostion을 skip 하고 있는 것을 확인할 수 있다.
위와 같이 Composable Tree Position에 의해서 결정된 identity와 input value의 값으로 Composable 구성에 대한
연산을 생략하고 재사용하는 방식을 Positional Memoization이라고 한다.