12
14

Composition은 UI를 표현하는 트리 구조를 만드는 과정이다. XML로 쓸 때는 ViewGroup을 기반으로 한 ViewTree가 있었으면 Compose에서는 UI를 구성하는 composition tree로 ui 요소를 관리한다.

kotlin
접기
@Composable
fun UserProfile(user: User) {
Column {
Text(user.name)
Text(user.bio)
}
}

이 코드에서, Column도 Composable이고, Text도 Composable이다. 트리구조이기 때문에 Column이 이 sub tree에서 부모노드, Text가 자식 노드이자 leaf 노드라고 이해하면 된다.

 

Composable을 실행해주는 compose의 뇌라고 부를 수 있는 Composer를 조금 더 살펴보며 이해해보자.

 

UserProfile 컴포저블이 실행될 때, 아래 단계를 거친다.

  • 1. Compose Runtime이 Composition object 생성
  • 2. 각 @Composable 함수 호출을 트래킹
  • 3. Slot Table이라는 내부 데이터 구조에 정보 저장

Slot Table은 Composable 함수에 대응하는 Group과 parameter와 state에 대응 Slot으로 나눌 수 있다. 

kotlin
접기
internal class SlotTable : CompositionData, Iterable<CompositionGroup> {
var groups = IntArray(0)
private set
var groupsSize = 0
private set
var slots = Array<Any?>(0) { null }
private set
// 내부적으로 이렇게 생성된다
SlotTable {
slots: [
0: ColumnScope,
1: Text("Hello"),
2: ButtonScope,
3: Text("Click me")
]
groups: [
Group(key: "Column", start: 0, size: 4),
Group(key: "Text-1", start: 1, size: 1),
Group(key: "Button", start: 2, size: 2),
Group(key: "Text-2", start: 3, size: 1)
]
}

그룹에서는 각 그룹 별로 key가 들어가 있어서 그 key를 기준으로 식별할 수 있게된다.

kotlin
접기
private fun doCompose(
invalidationsRequested: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>,
content: (@Composable () -> Unit)?
) {
runtimeCheck(!isComposing) { "Reentrant composition is not supported" }
trace("Compose:recompose") {
compositionToken = currentSnapshot().id
providerUpdates = null
invalidationsRequested.forEach { scope, set ->
val location = scope.anchor?.location ?: return
invalidations.add(Invalidation(scope, location, set))
}
invalidations.sortWith(InvalidationLocationAscending)
nodeIndex = 0
var complete = false
isComposing = true
try {
startRoot()
// vv Experimental for forced
@Suppress("UNCHECKED_CAST")
val savedContent = nextSlot()
if (savedContent !== content && content != null) {
updateValue(content as Any?)
}
// ^^ Experimental for forced
// Ignore reads of derivedStateOf recalculations
observeDerivedStateRecalculations(derivedStateObserver) {
if (content != null) {
startGroup(invocationKey, invocation)
invokeComposable(this, content)
endGroup()
} else if (
(forciblyRecompose || providersInvalid) &&
savedContent != null &&
savedContent != Composer.Empty
) {
startGroup(invocationKey, invocation)
@Suppress("UNCHECKED_CAST")
invokeComposable(this, savedContent as @Composable () -> Unit)
endGroup()
} else {
skipCurrentGroup()
}
}
endRoot()
complete = true
} finally {
isComposing = false
invalidations.clear()
if (!complete) abortRoot()
createFreshInsertTable()
}
}
}

doCompose를 보면 컴포지션 과정을 알 수 있다.

 

먼저 Composition 토큰 생성을 해주고 compositionToken = currentSnapshot().id 루트 그룹을 startRoot()로 띄워준다.
startGroup(invocationKey, invocation), invokeComposable(this, content), endGroup() 이 세 단계로 컨텐츠가 실행되며 endRoot를 하면 그룹이 종료되면서 composition이 끝난다.

kotlin
접기
if (content != null) {
startGroup(invocationKey, invocation)
invokeComposable(this, content)
endGroup()
} else if (
(forciblyRecompose || providersInvalid) &&
savedContent != null &&
savedContent != Composer.Empty
) {
startGroup(invocationKey, invocation)
@Suppress("UNCHECKED_CAST")
invokeComposable(this, savedContent as @Composable () -> Unit)
endGroup()
} else {
skipCurrentGroup()
}

 

첫번 째 조건문은 UI가 처음 생성될 때나 새로운 content로 완전히 교체되는 경우, 예를 들어 전체 화면이 바뀌는 경우에 동작하고, 두번째 조건문에서 저장된 컨텐츠를 가져와서 재구성 CompositionLocal의 변경이나 강제 재구성 필요시에만 수행한다. savedContent로 invokeComposable을 호출을 하는 부분이 그 부분이다.

 

content가 null인 경우는 recomposition이 요청된 상황이기 때문에 이 함수를 보면 자연스럽게 recomposition도 알게된다. 이제 주목해야하는 부분이 skipCurrentGroup이다.

 

상태가 변경되면 Compose는 해당 컴포저블과 그 자식들을 다시 실행한다. 이걸 recomposition이라고 하는데 모든 UI를 다시 그리는 것이 아니라, 스마트 리컴포지션을 통해 변경된 부분만 업데이트하며 이걸 위해서 Compose 컴파일러가 상태 변경을 추적하고 필요한 부분만 재실행하는 플로우하는 과정이 있다.

 

recompose함수를 보자.

kotlin
접기
internal fun recompose(
invalidationsRequested: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>
): Boolean {
runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" }
if (
invalidationsRequested.isNotEmpty() ||
invalidations.isNotEmpty() ||
forciblyRecompose
) {
doCompose(invalidationsRequested, null)
return changes.isNotEmpty()
}
return false
}

if로 감싸진 조건에서 recomposition이 필요한 지 검사가 일어나게 된다. doCompose 호출할 때 null을 넣어주는 걸 꼭 확인하자.

이 때 Invalidation을 통해 시작된다.

 

Invalidation은 상태가 변경되었음을 나타내는 객체다.

kotlin
접기
private val invalidations: MutableList<Invalidation> = mutableListOf()
private class Invalidation(
val scope: RecomposeScopeImpl,
val location: Int,
var instances: IdentityArraySet<Any>?
) {
fun isInvalid(): Boolean = scope.isInvalidFor(instances)
}

각 파라미터는 위에서 부터 재구성이 필요한 scope,  slot table에서의 위치, 변경된 상태 객체들을 의미하며 이게 조건문 안에 존재하는 invalidations의 정체다. 이걸 모아서 상태가 변경된 객체가 있으면 doCompose에 invalidationsRequested가 들어가고, doCompose에서는 Invalidation들을 수집하고 정렬한다.

 

다시 recompose로 와서, 강제 재구성이나 CompositionLocal이 없고, recompose해야되는 상황이면 아래 함수가 호출된다.

kotlin
접기
val LocalTheme = compositionLocalOf { LightTheme }
@Composable
fun App() {
// 테마가 변경되면 해당 값을 사용하는 컴포저블만 재구성
CompositionLocalProvider(LocalTheme provides DarkTheme) {
HomeScreen() // 테마 사용시에만 재구성
ProfileScreen() // 테마 미사용시 재구성되지 않음
}
}

CompositionLocal은 다크모드 변경, 시스템 언어 변경 같은 예시를 떠올리면 된다. 

kotlin
접기
@ComposeCompilerApi
override fun skipCurrentGroup() {
if (invalidations.isEmpty()) {
skipGroup()
} else {
val reader = reader
val key = reader.groupKey
val dataKey = reader.groupObjectKey
val aux = reader.groupAux
updateCompoundKeyWhenWeEnterGroup(key, dataKey, aux)
startReaderGroup(reader.isNode, null)
recomposeToGroupEnd()
reader.endGroup()
updateCompoundKeyWhenWeExitGroup(key, dataKey, aux)
}
}

이때 invalidations가 비어있지 않다면 해당 그룹들만 다시 그려준다!

코드가 길어서 주석 부분만 일단 긁어왔다. 

kotlin
접기
/**
* Recompose any invalidate child groups of the current parent group. This should be called
* after the group is started but on or before the first child group. It is intended to be
* called instead of [skipReaderToGroupEnd] if any child groups are invalid. If no children
* are invalid it will call [skipReaderToGroupEnd].
*/
private fun recomposeToGroupEnd() {
val wasComposing = isComposing
isComposing = true
var recomposed = false
val parent = reader.parent
val end = parent + reader.groupSize(parent)
val recomposeIndex = nodeIndex
val recomposeCompoundKey = compoundKeyHash
val oldGroupNodeCount = groupNodeCount
var oldGroup = parent
var firstInRange = invalidations.firstInRange(reader.currentGroup, end)

즉 변경이 필요없는 부분은 빠르게 스킵하고 변경이 필요한 부분(invalidations이 있는)은 recomposeToGroupEnd를 통해 필요한 부분만 재구성하는 방법을 적용해서 최적화를 하고 있다.

mermaid
접기
State 변경
Analyze Changes
Location별로 정렬
Invalidations 검사
Has Invalid Parts(재구성할 부분들)
No Invalid Parts
Update UI
No Update Needed
CheckState
CollectInvalidations
SortInvalidations
HasInvalidations
Recompose
Skip
State 변화 추적하면서 영향 받는 부분 싹 거친다.
필요한 부분만 recomposition한다

이걸 통해 필요한 것만 다시 그리는, 스마트 리컴포지션이 돌아가고 있음을 알 수 있다.

 

이렇게 Composition&Recomposition이 수행되면 Applier가 UI를 그려주면 화면으로 볼 수 있게된다.

도움이 됐다면 댓글이나 공감 버튼 한 번씩 누르고 가주세요!

 

반응형

'Android 🖥️ > Compose📖' 카테고리의 다른 글

@Immutable  (0) 2025.02.23
SideEffect, LaunchedEffect, DisposableEffect 동작원리  (1) 2024.12.14
Jetpack Compose 기초  (0) 2024.08.16
COMMENT