본문 바로가기

Compose

[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (1) Composer

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 트리에 잦은 변화가 발생하기에 갭 버퍼를 활용해 효율을 높인다)

 

갭은 테이블에서 일종의 범위성 포인터이다

배열 내에서 동적으로 이동할 수 있는 빈 공간이다

(갭은 데이터를 읽거나 쓸 위치를 지정하고 필요에 따라 이동된다)

Slot table

 

예시) 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

주입된 $composerComposable 함수를 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 수행

LayoutNode 계층 구조

 

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
}