Jetpack Compose Internals를 참고하여 작성하였습니다
이 글에서는 Composable 함수가 composition에 어떻게 변경사항을 방출(emit)하고 컴파일러를 통해 주입된 composer 인스턴스를 이용해 composition이 연관된 정보를 업데이트 받는 방법을 소개한다
컴포즈 런타임 (Compose Runtime)
Compose Runtime의 동작 순서
- Composition 단계
- Composable 함수를 실행하고 이 단계에서 일어난 모든 정보를 슬롯 테이블에 저장한다
- 변경 목록 생성
- Composer가 슬롯 테이블에 저장된 정보를 기반으로 UI 변경사항을 파악해 변경 목록 생성
- 변경 사항 적용
- Applier(최종적으로 트리 구체화)가 변경 목록을 적용
- 실제 노드 트리를 구체화한다
- Recomposer
- 전체 과정을 조율한다
- (재구성 시점과 스레드 결정, 변경 사항 적용하는 시점과 스레드 결정)
슬롯 테이블 및 변경 목록 (The slot table and the list of changes)
런타임이 Composition의 현재 상태를 저장하는 데 사용하는 최적화된 인메모리 구조
(Composition의 상태를 저장하고 업데이트하는 데 사용되는 데이터 구조)
Slot Table
Jetpack Compose에서 Composable 함수(UI 요소)의 상태를
선형적이고 최적화된 형식으로 저장하는 자료구조의 한 형태
Composition의 Group과 Slot
Group
Compose Compiler가 코드를 분석해 Group 구조를 결정하고 Runtime에 group을 생성
Group은 Composable 함수 호출의 계층 구조를 표현하고
부모 자식 관계의 Composable 함수들은 연관된 Group으로 구성된다
내부적으로 고유 식별자를 가지고 이를 통해 슬롯 테이블에서 추적된다
Slot
각 그룹에 대한 데이터를 저장
// UserProfile group
@Composable
fun UserProfile(user: User) {
// Column group
Column {
// Text group
Text(user.name)
// Restartable group (조건부)
if (user.isVerified) {
// Text group
Text("verified")
}
// Button group
Button(onClick = { }) {
// Text group
Text("Click")
}
}
}
SlotTable의 구조
Composition의 상태(group, slot)를 2개의 선형 배열에 저장
Group에 대한 정보와 각 그룹에 속한 Slot을 저장
internal class SlotTable : CompositionData, Iterable<CompositionGroup> {
var groups = IntArray(0)
private set
// The number of groups contained in [groups].
var groupsSize = 0
private set
var slots = Array<Any?>(0) { null }
private set
// The number of slots used in [slots]
var slotsSize = 0
private set
// active reader 숫자
private var readers = 0
// active writer 플래그
internal var writer = false
private set
// 현재 active한 anchor
internal var anchors: ArrayList<Anchor> = arrayListOf()
inline fun <T> read(block: (reader: SlotReader) -> T): T = openReader()
.let { reader ->
try {
block(reader)
} finally {
reader.close()
}
}
inline fun <T> write(block: (writer: SlotWriter) -> T): T = openWriter()
.let { writer ->
try {
block(writer)
} finally {
writer.close()
}
}
// write 중일 경우 error
fun openReader(): SlotReader {
if (writer) error("Cannot read while a writer is pending")
readers++
return SlotReader(table = this)
}
// 하나의 writer만 사용, 읽고 있는 reader 체크
fun openWriter(): SlotWriter {
runtimeCheck(!writer) { "Cannot start a writer when another writer is pending" }
runtimeCheck(readers <= 0) { "Cannot start a writer when a reader is pending" }
writer = true
version++
return SlotWriter(table = this)
}
}
Group 배열
- group fields만 저장해 Int값 사용
- group의 메타데이터 표시
- 부모 group, 자식 group이 field 형태로 저장
- 선형 데이터 구조이기에 부모 group 이후 자식 group 필드가 이어져 트리를 형성
- 앵커(anchor) 포인터를 사용해 group을 접근할 때 비용을 줄인다
- 각 group은 슬롯 배열에서 슬롯을 찾고 해석하는 방법을 서술
[0]: { key: "UserProfile", parent: -1, size: 1, anchor: 0, ... }
[1]: { key: "Column", parent: 0, size: 2, anchor: 1, ... }
[2]: { key: "Text-name", parent: 1, size: 2, anchor: 3, ... }
[3]: { key: "if-block", parent: 1, size: 1, anchor: 5, ... }
[4]: { key: "Text-verified", parent: 3, size: 2, anchor: 8, ... }
[5]: { key: "Button", parent: 1, size: 2, anchor: 10, ... }
[6]: { key: "Text-Follow", parent: 5, size: 2, anchor: 12, ... }
Slot 배열
- 해당 group과 관련한 데이터 저장 (모든 타입 저장 -> Any)
- 실제 composition 데이터가 저장되는 곳
[0]: User(id=123, name="John", ...)
[1]: ColumnScopeInstance
[2]: { remember: ColumnImpl }
[3]: "Park"
[4]: TextStyle(...)
[5]: true
...
슬롯 테이블은 갭 버퍼(gap buffer) 개념을 베이스로 가지며 갭(gap)을 사용하여 데이터를 읽고 쓴다
(Compose에서 Composable 트리에 잦은 변화가 발생하기에 갭 버퍼를 활용해 효율을 높인다)
갭은 테이블에서 일종의 범위성 포인터이다
배열 내에서 동적으로 이동할 수 있는 빈 공간이다
(갭은 데이터를 읽거나 쓸 위치를 지정하고 필요에 따라 이동된다)
예시) group은 테이블에 현재 활성화된 자식에 대한 데이터를 저장한다
a가 true인 경우 Text(a)를 저장하고 조건이 전환되면 갭은 group의 시작 위치로 되돌아가서 해당 위치에서 다시 쓰기 시작해 Text(b)에 대한 데이터로 기존의 슬롯들을 덮어쓴다
// 어노테이션으로 인해 Restartable 그룹을 생성하지 않음
@NonRestartableComposable
@Composable
fun ConditionalText() {
if (a) {
Text(a)
} else {
Text(b)
}
}
테이블에 데이터를 읽고 쓰기 위한 SlotReader, SlotWriter가 있다
Slot Writer
- 단 하나의 활성화된 작성자(active writer)를 가진다
- 작성자가 write 작업을 하지 않을 때만 read가 가능해 동기화 안전성을 보장 (race condition 방지)
- 배열에 group과 슬롯을 쓰는데 사용된다 (Any 타입의 데이터를 테이블에 쓸 수 있다)
Slot Reader
- 여러 활성된 구독자(reader)가 있을 수 있다
- 방문자(visitor)로서 작용
- 구독자는 배열에서 group과 그 슬롯에 대한 정보를 읽는데 사용된다
변경 목록 (The list of changes)
Composition이나 Recomposition 동안 발생한 UI 트리의 변경사항을 추적하고 관리하는 목록
Composable 함수가 실행될 때마다 슬롯 테이블을 확인하고 현재 사용 가능한 정보에 따라 지연 중인 변경사항을 생성하고, 해당 변경사항을 모두 변경 목록에 추가한다
나중에 composition이 끝나면 변경 목록에 기록된 내용들이 실제로 실행되면서 구체화된다
Composer
주입된 $composer는 Composable 함수를 Compose Runtime에 연결하는 역할을 한다
composer의 특징과 기능들
Composer 키우기 (Feeding the Composer)
트리의 메모리 표현에 노드가 추가되는 과정
예시) Layout Composable에서 ReusableComposeNode 사용
(모든 UI 컴포넌트의 기초)
@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable
inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
val compositeKeyHash = currentCompositeKeyHash
val localMap = currentComposer.currentCompositionLocalMap
ReusableComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update = {
set(measurePolicy, SetMeasurePolicy)
set(localMap, SetResolvedCompositionLocals)
@OptIn(ExperimentalComposeUiApi::class)
set(compositeKeyHash, SetCompositeKeyHash)
},
skippableUpdate = materializerOf(modifier),
content = content
)
}
Layout Composable에서는 ReusableComposeNode를 사용해 재사용 가능한 Composition Node를 생성한다
- 적절한 시기에 composition의 현재 위치에서 노드를 생성하고 초기화하고 삽입하는 방법을 런타임에게 가르치는 것
ReusableComposeNode Composable
@Composable @ExplicitGroupsComposable
inline fun <T, reified E : Applier<*>> ReusableComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>.() -> Unit,
noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
content: @Composable () -> Unit
) {
..
// ReusableNode 시작
currentComposer.startReusableNode()
if (currentComposer.inserting) {
// inserting == true 일 경우 새로운 노드 생성 (초기 컴포지션 or 노드 추가 시)
currentComposer.createNode(factory)
} else {
// 기존 Node 재사용 (recomposition)
currentComposer.useNode()
}
// initialization
Updater<T>(currentComposer).update()
SkippableUpdater<T>(currentComposer).skippableUpdate()
// 자식 Content를 처리하기 위해 replaceableGroup 시작 후 종료
currentComposer.startReplaceableGroup(0x7ab4aae9)
content()
currentComposer.endReplaceableGroup()
// Node 종료
currentComposer.endNode()
}
구조
currentComposer에 모든 것을 위임
(startReusableNode, createNode, useNode, update, startReplaceableGroup ..)
Composable 계층 구조에서 부모 자식 관계를 구현
예시)
Column { // ReusableComposeNode 호출
Text("Hello") // content() 람다 내부의 첫 번째 자식
Text("World") // content() 람다 내부의 두 번째 자식
}
예시) remember Composable
(람다식에 의해 반환된 값을 composition에 캐싱하기 위해 currentComposer 사용)
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
@Suppress("UNCHECKED_CAST")
return rememberedValue().let {
if (invalid || it === Composer.Empty) {
val value = block()
updateRememberedValue(value)
value
} else it
} as T
}
먼저 슬롯 테이블에서 값을 검색하고(rememberedValue())
찾을 수 없는 경우, 새 값을 계산하고 업데이트 하기 위해 변경사항을 방출하거나 기존의 값을 반환한다
변경 사항 모델링 (Modeling the Changes)
currentComposer에 위임된 모든 방출 작업은 Change로 모델링 되어 내부의 변경 목록에 추가된다
Change는 현재의 Applier 및 SlotWriter에 접근할 수 있는 지연된 함수
internal typealias Change = (
applier: Applier<*>,
slots: SlotWriter,
rememberManager: RememberManager
) ‐> Unit
방출(emit) 행위는 본질적으로 Change들을 생성하는 것을 의미하고
Slot table에서 노드를 추가, 제거, 교체, 이동할 수 있는 지연된 람다식이며 결과적으로 Applier에게 변경 사항들을 알린다
작성 시기 최적화 (Optimizing when to write)
composer는 두 가지 방법으로 변경사항을 처리
- 새로운 노드를 삽입하는 경우, composer가 곧바로 슬롯 테이블에 변경 사항을 작성할 수 있다
- 그 외의 경우(기존 노드 업데이트 등), 아직 해당 변경 사항들을 적용할 시점이 아니기 때문에 변경 목록에 기록되고 잠시 지연된다
쓰기 및 읽기 그룹 (Writing and reading groups)
Composition이 완료되면 composition.applyChanges()가 호출되어 변경 사항이 슬롯 테이블에 기록된다
슬롯 테이블에서 모든 정보는 궁극적으로 Group 형태로 저장된다 (다른 group field를 가질 뿐)
composer는 Group에 대해 시작(StartGroup)할 수 있고 종료(EndGroup)할 수 있다
제거(RemoveGroup)할 수도 있고 이동(MoveGroup)할 수 있다
Composer가 그룹을 시작하려고 할 때 동작 순서
- Composer가 값들을 삽입하는 과정이면 기다릴 필요가 없어 슬롯 테이블에 Group들을 바로 작성
- 보류 중인 작업이 있는 경우 변경 사항을 적용할 때 변경 사항을 모두 기록
- 테이블에 이미 대상 group이 존재하는 경우 composer는 이를 재사용
- Group이 저장되었지만 다른 위치에 있는 경우, 해당 group의 모든 슬롯을 이동하는 동작이 기록된다
- 새로운 group이면 삽입 모드로 이동하여 group이 모두 완성될 때까지 모든 항목을 다른 slotTable에 작성한다
- Composer가 값들을 삽입하지 않고, 보류중인 작성 작업이 없는 경우 Group을 읽기 시작한다
값 기억하기 (Remembering values)
값이 슬롯 테이블에 기록되고 마지막으로 값이 잘 변경되었는지 확인할 때
remember 함수가 호출되면 즉시 이전 값과 비교를 수행하고
업데이트 시에는 Composer가 값을 삽입하는 중이 아니라면 Change로 기록한다
재구성 범위 (Recompose scopes)
Smart Recomposition을 가능하게 하는 재구성 범위
재시작 가능한 그룹(Restartable Group)이 생성될 때마다 RecomposeScope이 같이 생성되고 이를 현재 composition에 대한 currentRecomposeScope로 설정한다
Recompose scope은 Composition의 특정 영역을 독립적으로 재구성할 수 있게 하고 필요한 UI 부분만 효율적으로 업데이트한다
Recompose Scope은 Composable 함수 내에서 상태(State) 스냅샷 읽기가 감지될 때 활성화된다
이 경우 composer는 RecomposeScope을 used로 표시한다
Composer와 사이드 이펙트 (SideEffects in the Composer)
Composer는 SideEffect도 기록할 수 있다
SideEffect는 항상 Composition 이후에 실행되고 UI 트리에 대한 모든 변경 사항이 적용된 후 호출된다
CompositionLocals 저장 (Storing CompositionLocals)
Composer는 CompositionLocals를 등록하고 키를 통해 해당 값을 얻는 방법을 제공한다
CompositionLocals는 슬롯 테이블에 group 형태로 저장된다
소스 정보 저장 (Storing source information)
Composer는 각종 Compose 툴에서 활용할 수 있도록 composition 중에 수집된 내용을 CompositionData 형태로 소스 정보를 저장
CompositionContext를 이용한 Composition 연결 (Linking Compositions via CompostionContext)
CompositionContext를 통해 Composition과 Subcomposition을 연결
상위 Composition의 context를 하위 Composition에 전달한다
rememberCompositionContext 함수를 통해 subcomposition을 생성할 수 있다
@OptIn(InternalComposeApi::class)
@Composable fun rememberCompositionContext(): CompositionContext {
return currentComposer.buildContext()
}
현재 상태 스냅샷에 접근 (Accessing the current State snapshot)
Composer는 현재 스냅샷에 대한 참조를 가진다
노드 탐색 (Navigating the nodes)
노드 트리 탐색은 Applier에 의해 수행되지만 직접적으로 수행되지 않는다
구독자에 의해 탐색되는 노드의 모든 위치를 기록하고 downNodes 배열에 기록함으로써 수행
노드 탐색이 실현되면 downNodes에 있는 모든 하향 노드 정보는 Applier에 전달된다
구독자와 작성자의 동기화 유지 (Keeping reader and writer in sync)
슬롯 테이블에 접근할 때 구독자(Reader)와 작성자(Writer)의 Group위치가 일시적으로 다를 수 있다
(Group의 삽입, 삭제, 이동 시 슬롯 테이블의 구조 변경)
그래서 "작성자와 구독자의 현재 슬롯 위치를 정확하게 일치시키기 위해 이동해야 하는 실현되지 않은 거리"를 추적하기 위해
델타(delta)를 유지 관리해야 한다
변경 사항 적용하기 (Applying the changes)
변경 사항을 적용하는 것은 Applier이다
Applier는 구체화를 담당
- Composition 이후 기록된 모든 변경 사항 목록을 실행하고 슬롯 테이블 업데이트
- 슬롯 테이블에 저장된 composition 데이터를 해석하여 결과 생성
Applier는 플랫폼 독립성을 가진다
- Runtime은 특정 Applier 구현에 의존하지 않고 독립적이다
- 다양한 플랫폼과 환경에 맞게 구현 가능
Applier는 모든 노드를 방문하고 적용하면서 전체 트리를 순회한다
interface Applier<N> {
// 다양한 노드 타입(N) 지원
val current: N
// Composer가 applier를 사용해 변경 사항을 적용하기 시작할 때 호출
fun onBeginChanges() {}
// Composer가 applier를 사용해 변경 사항을 적용이 끝났을 때 호출
fun onEndChanges() {}
// 트리 탐색
fun down(node: N)
fun up()
// 노드 관리 함수
fun insertTopDown(index: Int, instance: N)
fun insertBottomUp(index: Int, instance: N)
fun remove(index: Int, count: Int)
fun move(from: Int, to: Int, count: Int)
// 초기화
fun clear()
}
노드 트리 구축 시 성능 (Performance when building the node tree)
하향식 삽입 (Inserting top-down)
위 트리를 하향식으로 구축하기 위한 순서
(B를 R에 삽입, A를 B에 삽입, C를 B에 삽입)
상향식 삽입 (Inserting bottom-up)
위 트리를 상향식으로 구축하기 위한 순서
(A,C를 B에 삽입, B를 R에 삽입)
하향식과 상향식의 성능 차이
성능은 Applier의 구현체에 따라 결과가 달라지고 새로운 하위 노드가 삽입될 때마다 알림을 받아야 하는 노드 수에 따라 달라진다
하향식의 경우
- 새 노드가 삽입될 때 모든 상위 노드에 알린다
- 루트에서 리프까지 트리를 따라 내려오며 구성
- 트리 depth가 증가할수록 알림을 받는 노드 수가 기하급수적으로 증가해 성능이 저하될 수 있다
상향식의 경우
- 각 노드는 직속 부모에게만 알린다 (자식이 먼저 생성되어 부모가 아직 트리에 연결되어 있지 않기 때문)
- 리프에서 루트까지 트리를 따라 올라가며 구성
- 트리 depth에 상관없이 알림을 받는 노드 수가 일정하게 유지되어 비용 절감 가능
변경 사항이 적용되는 방식 (How changes are applied)
클라이언트에서는 Applier에 대한 구현체를 제공한다
UiApplier는 Android에서 사용되는 구현체이다
UiApplier
Applier의 타입 N이 LayoutNode로 변경됨
렌더링될 UI 노드를 나타내기 위해 Compose UI가 선택한 노드 타입이다
노드를 삽입, 제거, 이동하는 방법은 모두 노드 자체에 위임된다
internal class UiApplier(
root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
// Android의 경우 노드 삽입이 상향식으로만 수행되기에 무시
override fun insertTopDown(index: Int, instance: LayoutNode) {
// Ignored. Insert is performed in [insertBottomUp] to build the tree bottom-up to avoid
// duplicate notification when the child nodes enter the tree.
}
// current -> LayoutNode
override fun insertBottomUp(index: Int, instance: LayoutNode) {
current.insertAt(index, instance)
}
override fun remove(index: Int, count: Int) {
current.removeAt(index, count)
}
override fun move(from: Int, to: Int, count: Int) {
current.move(from, to, count)
}
override fun onClear() {
root.removeAll()
}
// 변경 사항 적용이 끝나면 호출됨
// 최종적인 요구사항을 루트 노드 소유자에게 위임
override fun onEndChanges() {
super.onEndChanges()
root.owner?.onEndApplyChanges()
}
}
AbstractApplier
방문한 노드를 스택에 저장하는 기본 구현체, 여러 Applier에서 공통적으로 사용되는 스펙인 추상클래스
하단(down)에서 새 노드 방문 시 스택에 추가 (down 함수)
방문자가 위로 이동할 때마다 마지막으로 방문한 노드를 스택 상단에서 제거 (up 함수)
abstract class AbstractApplier<T>(val root: T) : Applier<T> {
private val stack = mutableListOf<T>()
override var current: T = root
protected set
override fun down(node: T) {
stack.add(current)
current = node
}
override fun up() {
checkPrecondition(stack.isNotEmpty()) { "empty stack" }
current = stack.removeAt(stack.size - 1)
}
....
}
노드 연결 및 그리기 (Attaching and drawing the nodes)
트리에 노드를 삽입(부모에 연결)하게 되면 결국 화면에 어떻게 나타나는 것인가?
노드는 스스로 연결하고 그리는 방법을 알고 있다
Android UI를 위해 채택된 LayoutNode
UiApplier 구현이 LayoutNode에게 삽입을 위임할 시 작업 순서
- 노드 삽입을 위한 조건 충족 확인 (부모 노드가 없는지 등)
- Z 인덱스에서 정렬된 자식 목록 invalidate. Z 인덱스별로 모든 자식을 정렬하여 저장하는 병렬 목록
- 새 노드를 부모 노드 및 Owner에 연결
- 최종적으로 invalidate 수행
Owner는 트리의 루트에 존재하며 Composable 트리를 기본 View 시스템과 연결하는 것을 구현
실제로 AndroidComposeView에 의해 구현된다
모든 레이아웃, 그리기, 입력, 접근성은 Owner를 통해 연결
LayoutNode는 화면에 나타나기 위해 Owner와 연결되어야 한다
노드를 연결한 후 Owner를 통해 invalidate을 호출하여 Composable 트리를 렌더링할 수 있다
마지막으로 Owner가 설정될 때 궁극적인 통합 지점이 발생한다.
이는 Activity, Fragment나 ComposeView에서 setContent를 호출할 때마다 일어난다
ComposeView의 setContent -> createComposition() -> ensureCompositionCreated()
-> setContent
// AndroidComposeView 생성하거나 재사용
internal fun AbstractComposeView.setContent(
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
GlobalSnapshotManager.ensureStarted()
val composeView =
if (childCount > 0) {
getChildAt(0) as? AndroidComposeView
} else {
removeAllViews(); null
} ?: AndroidComposeView(context, parent.effectCoroutineContext).also {
addView(it.view, DefaultLayoutParams)
}
return doSetContent(composeView, parent, content)
}
// doSetContent
private fun doSetContent(
owner: AndroidComposeView,
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
...
// Composition을 생성
// owner(AndroidComposeView)의 root LayoutNode가 Composition의 루트 노드
val original = Composition(UiApplier(owner.root), parent)
val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
as? WrappedComposition
?: WrappedComposition(owner, original).also {
owner.view.setTag(R.id.wrapped_composition_tag, it)
}
wrapped.setContent(content)
...
if (owner.coroutineContext != parent.effectCoroutineContext) {
owner.coroutineContext = parent.effectCoroutineContext
}
return wrapped
}
'Compose' 카테고리의 다른 글
[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (3) Recomposer (0) | 2025.03.30 |
---|---|
[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (2) Composition (0) | 2025.03.30 |
[ComposeInternals] Composable 함수들 (0) | 2025.03.03 |
[Compose] Compose 아키텍처 레이어링 (0) | 2025.02.23 |
[Compose] Compose 아키텍처 (0) | 2025.02.23 |