[Compose] Compose Compiler

13 분 소요

최근들어 Compose 를 UI toolkit 으로 많이 사용하고 있습니다. 저 역시 마찬가지로 기존의 xml 기반의 뷰 작업들을 모두 Compose 로 migration 한 뒤로, 새로운 기능을 개발할 때 Compose 로 개발하고 있습니다. Compose 를 사용하고는 있지만, 정확한 동작 방식을 이해하지 못하고 있었고, Compose internals 를 여러 번 다시 읽으면서 학습했던 내용을 정리해보려 합니다.

이번 포스팅의 주제는 Compose Compiler 입니다. Compose Compiler 의 주된 역할은 Compose Runtime 이 필요로 하는 정보들을 IR 변환에 직접 개입하여 코드를 생성 혹은 변환하여 제공 하는 것 입니다. 개발자가 작성한 Composable 함수들은 Runtime 이 되어서야 모두 실행 됩니다. 실행된 후, 결과적으로 트리 구조 기반의 UI 에 대한 인메모리 표현을 생성합니다. (이를 “방출” 과정이라고 합니다.) 방출을 통해 UI 에 대한 인메모리 표현을 생성하는 전체 과정을 Composition 이라고 합니다.

이렇게 생성된 UI 의 인메모리 표현(트리)은 Slot table 이라는 자료구조로 관리됩니다. Slot table 은 Gap buffer 라는 자료구조를 기반으로 만들어 졌습니다. 해당 자료구조의 정확한 동작 원리가 궁금하시다면 개발자가 직접 작성한 아티클 을 참고하시면 도움이 될 것 같습니다.

Composition 이 끝나면, UI tree의 각 노드들의 크기와 위치를 결정한 뒤, 뷰를 그리게 됩니다.

이후, 상태의 변화에 따라 Invalidiation(무효화) 를 하게 되고, 이는 지연된 Recomposition 들을 트리거 합니다. Recomposition 동안에 Composable 함수를 재 실행하여, 현재 상태를 기반으로 생성된 변경사항들을 특정 시간에 적용하는(구체화) 과정들을 실행하게 됩니다. 이 과정에서 모든 Composable 함수들을 매번 Recomposition 하면, 짧은 시간이내에 여러번 호출될 경우 버벅임과 같은 성능 문제를 야기할 수 있습니다.

따라서, Compose Compiler 는 Compose Runtime 이 다음과 같은 동작을 효율적으로 실행할 수 있도록 코드를 생성 및 변환해 주는 책임을 맡습니다. UI 의 인메모리 표현을 생성하는 것과, 해당 과정의 성능 개선에 초점을 맞추고 있습니다.

Compose Compiler 와 Compose Runtime 의 내부 동작을 학습해 둔다면, 근본적으로 Recomposition 이 발생하게 되는 원인인 “상태 관리” 가 중요하다는 것을 알고 방법들을 고민해볼 수 있고, 불필요하게 Recomposition 이 발생하지 않고, 이를 건너뛸 수 있도록 Skippable 하게 만드는 최적화 전략 들을 이용할 수 있습니다. 이를 토대로 작성한 코드가 어떻게 동작할지에 대해 예측할 수 있고, 성능 개선을 위한 최적화 방법들을 적용할 수 있습니다.

따라서, 이 글에서는 Compose Compiler 가 성능 개선을 위해 어떻게 최적화 하는지 에 대한 방법들을 중심으로 개발하는데 필요한 지식들을 기반으로 정리해보려 합니다.

Compose Compiler

Compose Compiler 는 Kapt 를 통해 생성하는 어노테이션 프로세서가 코드를 생성하는 방식과는 다릅니다. Kotlin Compiler Plugin 의 일종으로, 컴파일러 아키텍처 중 프론트엔드 단계 동안 발생하는 IR 변환에 직접 개입할 수 있습니다. 다르게 생각하면 프론트엔드 단계에 직접 개입한다는 것은 코드 변환의 낮은 수준에 접근할 수 있다는 것이므로 이를 남용하는 것은 안정성 측면에서 위험할 수 있다고 저자는 설명하고 있습니다. 따라서 Kotlin Compiler Plugin 으로 사용되는 것을 권장한다고 합니다.

책에서 다뤄지는 Compiler 아키텍처나 Compiler Plugin 과 관련된 내용들은 포스팅의 주제에서 벗어나기 때문에, Compose Runtime 을 위해 생성하는 코드들을 중심으로 정리해 보겠습니다. (필요하다면 여기를 참고해주세요.)

@Composable

개발자가 Compose 로 UI를 구현하기 위해서는 간단하게 함수에 @Composable 어노테이션을 작성하기만 하면 됩니다.

@Composable
fun MyScreen() {
	Text("Hello World")
}

Compose 는 선언적 프로그래밍을 실현하는 함수형 프로그래밍 방식의 Toolkit 입니다. 함수형 프로그래밍에서의 주요한 특징 중 하나는 순수함수 입니다. 이는 함수의 같은 입력 매개변수가 주어졌을 때, 여러번 거듭 실행하더라도 같은 출력(결과)이 만들어져야 한다는 특성 입니다.(이를 다른 말로 “멱등성” 이라고도 합니다.)

fun add(a: Int, b: Int): Int = a + b // a = 1, b = 2 일때 몇번을 실행하더라도 결과값은 3 입니다.

Composable 함수도 마찬가지입니다. 스냅샷 상태의 변화가 감지되면, 이는 Invalidiation(무효화) 을 트리거 하게 됩니다. 결과적으로 변경사항이 있는 Composable 함수에 대해서만 선택적으로 Recomposition 을 트리거하고, 나머지에 대해서는 Recomposition 을 건너뛸 수 있어 그대로 재 사용 함으로써 성능적 이점을 만들어 줄 수 있습니다.

@Composable
fun MyScreen() {
	Text("Hello World") // 몇번을 실행하더라도, 입력 매개변수인 "Hello World" 는 변경되지 않으므로, 해당 그룹의 변경사항이 발생했을 때, 재 구성되지 않고 재 사용 됩니다.
}

내부적으로는 Composition 이 발생할 때, Composable 함수를 읽어 들여 방출하게 되면 Slot table 내에 상태(State)나 Composable 함수와 같은 모든 추적 가능한 관련 정보를 기억(저장) 해둔 뒤, 변경 사항이 없는 경우 기억된 값을 그대로 재 사용하여 성능을 개선합니다. Compose Runtime 이 이러한 동작을 가능하도록 필요한 코드를 변환하고 생성하는 것이 Compose Compiler 의 주된 역할입니다.

순수함수적 특징을 검증하기 위해 Compose 에서는 안정성(Stability) 이라는 규칙을 이용합니다. Compose Runtime 은 안정성을 기반으로 동일한 입력에 대해 동일한 출력이 발생할 것을 예상하고 해당 부분의 Recomposition 을 건너뛸(skip) 수 있습니다. 따라서, 안정적이지 않은 경우 변경사항이 발생할 경우 매번 Recomposition 을 수행하게 됩니다.

안정성은 기본적으로 Compose Compiler 에 의해 추론되어 Runtime 이 이해할 수 있는 방식으로 변형시키지만, interface 와 같이 구현체에 의해 안정적으로 간주될 수 있거나 또는 없거나 하는 모호한 경우에 대해 개발자가 직접 Stable Marker 어노테이션을 명시하여 안정적으로 만들 수 있습니다.

Stable Marker

Compose Compiler 에게 안정적으로 추론되기 위해서는 다음과 같은 조건을 만족해야 합니다.

  • 이전과 이후의 두 인스턴스에 대해 Equals() 의 결과가 항상 같아야 합니다.
  • Primitive Type(Int, Double, String …) 은 안정적으로 간주됩니다.
  • 값을 캡처하지 않거나, 안정적인 값을 캡처하는 람다식은 안정적으로 간주됩니다.(Kotlin 2.0.20 버전 부터는 Strong Skipping Mode 가 Default 로 적용되어 안정적이지 않은 값을 캡처하는 람다식도 안정적으로 변환됩니다. )
  • Stable Marker 가 지정된 경우 안정적으로 간주됩니다.

해당 조건을 만족하는 경우 Compose Compiler 는 이를 안정적으로 추론합니다. 다만, 몇가지 경우에 대해서는 안정적으로 추론할 수 없습니다.

  • 추상클래스나, 인터페이스는 구현체에 따라 안정적이거나 안정적이지 않을 수 있으므로 안정적으로 추론될 수 없습니다.
  • 가변적인 타입, 예)MutableList 에 대해서는 안정적으로 추론 될 수 없습니다.

대표적으로 class, data class, sealed class 들이 안정적으로 추론될 수 있으며, companion class, 익명객체, interface, abstract class, enum class 등은 모두 안정적으로 추론되지 않습니다.

모호하지만 개발자가 보기에 해당 타입이 안정적이고, 재 사용할 수 있도록 만들어 주고 싶은 경우가 있을 수 있습니다. 이런 경우 Stable Marker 어노테이션을 명시하여 직접 특정 타입을 안정적으로 만들 수 있습니다. 이를 통해 안정적으로 간주되는 타입들은 다음과 같은 특징을 얻게됩니다.

  • 해당 객체의 Public 프로퍼티들도 모두 안정적 이어야 합니다.
  • 객체의 Public 프로퍼티의 변경사항들은 Composition 에게 알려지게 됩니다.

@Stable Marker 는 메타 어노테이션이며, 이는 동일한 동작 및 규칙들을 생성하고 재 사용 하기 위한 어노테이션 입니다. 이를 이용하여 직접 사용하는 어노테이션으로 @Immutable 과 @Stable 이 있습니다.

Immutable

@Immutable 은 해당 인스턴스가 절대 변하지 않음을 Compose Runtime 에게 약속합니다. 이는 인스턴스의 Equals() 의 결과값뿐만 아니라, 내부 프로퍼티들도 모두 변경되지 않는 강력한 약속 입니다. 따라서, 모든 public 프로퍼티가 val 이며, 불변적 타입인 경우 유용하게 사용될 수 있습니다. 또한, 인스턴스의 변경이 일어나지 않음을 약속하기 때문에 @Stable 과 달리 public 프로퍼티들의 변경사항이 composition 에게 알려지지 않습니다.

불변적 타입을 대표적으로 List 로 생각할 수도 있습니다. List 는 인터페이스이며, 위에서 언급한 바와 같이 MutableList 나 ArrayList 와 같이 변경 가능한 구현체가 존재하기 때문에 불변적이라고 간주할 수 없습니다. 또한 val 역시 Kotlin 언어적 수준에서 불변적임을 보장하는 프로퍼티 입니다. 하지만 val 의 경우에도 List 와 같은 타입에 대해 가변적 상태를 보유할 수 있으므로 완전한 불변성을 보장하지는 않습니다.

@Immutable 은 이와같은 List 나 val 보다 훨씬 더 강력한 불변성을 보장하는 약속이며, 해당 타입이 외부 또는 public 한 내부 프로퍼티에 대해 모두 완전한 불변성을 Compose Runtime 에게 약속합니다.

@Immutable
interface B {} // interface 는 안정적으로 추론되지 않지만, 어노테이션을 명시하여 안정적으로 만들 수 있습니다.

data class A(val b: B) // 따라서, B 는 안정적인 타입이기 때문에, A 는 Compose Compiler 에게 안정적으로 추론됩니다. 

Stable

@Immutable 과 달리 @Stable 은 해당 타입이 변경 가능하지만 안정적으로 간주될 수 있음을 의미하는 어노테이션 입니다. 해당 타입의 Equals() 의 결과값은 같지만 내부적으로 가변적 상태를 보유하는 경우에 해당합니다. 보통 public 프로퍼티들은 불변적이지만, 내부적으로 private 한 가변적 상태를 보유하고 있는 경우 사용됩니다.

@Stable
data class A(val a: List<Int>) { // data class 의 Equals() 의 결과는 생성자의 프로퍼티들의 값의 비교입니다. A 클래스는 안정적이지 않은 List 타입의 프로퍼티가 있지만, List 의 요소가 모두 같은 경우 재 사용 가능하다고 판단되어, 안정적으로 만들어 주기 위해 @Stable 을 명시합니다.
	private var b: Int = 0 // private 한 내부 프로퍼티는 equals() 결과에 영향을 주지 않기 때문에 안정성에 영향을 주지 않습니다.

	fun updateB(input: Int) {
		b = input
	}
}

다시 정리하자면, 안정적으로 추론되거나 간주되면 Compose Runtime 은 해당 타입의 Equals() 의 결과값이 달라졌는지 확인후, 같다면 Recomposition 을 건너뛸 수 있습니다. 이러한 동작은 Slot table(메모리) 내에 값을 기억(캐싱)한 후, 재 사용 하는 매커니즘을 활용하는 것 입니다.

또한 안정적으로 간주되지 않는 타입을 입력 매개변수로 갖는다면, 해당 Composable 의 Recomposition 을 건너뛸 수 없고 매번 동작하게 되므로 성능 문제가 발생할 여지를 갖게 된다는 점을 기억해두시면 좋겠습니다. 이러한 매커니즘을 활용하여 성능 개선에 초점을 맞춘 코드랩을 보시는것도 활용 측면에서의 이해를 높이는데 도움이 될 수 있을것 같습니다.

Lowering (낮추기)

Lowering 은 Compose Compiler 가 Compose Runtime 이 이해할 수 있는 낮은 수준의 표현으로 정규화하는 과정을 나타냅니다. 낮추기는 Composable 함수가 Slot table, Recomposer 와 같은 런타임 수준에서 필요로 하는 것들을 가지는 Composer 인스턴스를 생성하여 주입하는 과정, 안정성 추론, 람다식 최적화, 비교전파, 디폴트 매개변수 지원, 그룹 생성 과 같은 과정들을 포함합니다.

해당 과정들을 하나씩 살펴보겠습니다.

Composer

Composer 는 Composable 함수와 Compose Runtime 을 연결하는 역할을 담당합니다. Compose Compiler 는 최상위 Root Composable 함수에서 Composer 라는 인스턴스를 생성하여 최하위까지 전파합니다. 이를 입력 매개변수에 삽입하는 형태로 모든 Composable 함수에 대해 합성하여 대체 합니다.

@Composable
fun A(a: Int, b: Int) {
	B(b)
}

@Composable
fun B(b: Int) {
}

// 이는 이렇게 합성됩니다.

@Composable
fun A(a: Int, b: Int, $composer: Composer) {
	composer.start(123)
	B(b, composer)
	composer.end()
}

@Composable
fun B(b: Int, $composer: Composer) {
	composer.start(456)
	// Something
	composer.end()
}

Composer 인스턴스는 기억된값, Composable 함수의 입력 매개변수, 그룹(해당 Composble 이 인메모리 표현의 특정 노드로 간주되는 정체성인 key 값) 등과 같이 모든 Compose Runtime 에 의해 추적될 수 있는 것들을 가집니다. 그리고 인스턴스를 트리의 끝까지 전파하여 모든 Composable 함수들이 공유하게 됩니다.

람다식 최적화

Compose Compiler 는 모든 람다식을 안정적으로 추론합니다. 하지만 람다식이 값을 캡처하는 경우 그 값의 변화에 따라 멱등성을 만족하지 못할 수 있습니다. 따라서 람다식에 대해 안정적으로 추론하기 위해서 람다식을 최적화 하는 과정을 거칩니다.

Composable 이 아닌 람다식

Composable 이 아닌 람다식이면서, 값을 캡처하지 않는 람다의 경우 Side Effect 가 존재하지 않으므로(외부상태의 변화에 따라 결과를 예측할 수 없는 상황) Compose Compiler 는 해당 람다식을 싱글톤으로 모델링한 뒤, 이를 재 사용하게 됩니다.

값을 캡처하는 람다식의 경우 그 값이 stable 하다면, remember 로 wrapping 하여 최적화 하게 됩니다. unstable 한 값을 캡처한다면 side effect 가 발생할 수 있으므로 최적화 할 수 없지만, Kotlin 2.0.20 버전 부터 기본적으로 지원되는 strong skipping mode 에 따라 해당 람다식 역시 remember 로 최적화를 알아서 해주게 됩니다.

Composable 람다식

Composable 람다식의 경우 Composable 이 아닌 람다식과는 달리 Slot table 에 저장되기 때문에 같은 방식으로 변환될 수 없습니다. 최종적으로는 remember 가 아닌 State 로 wrapping 되는 형태로 합성됩니다.

Composable 람다식은 composableLamda(composer, key, shouldBeTracked …) 이라는 팩토리 함수로 변환됩니다. 람다식을 매개변수로 호출하는 Composable 함수로 부터 전달받은 composer 인스턴스를 입력 매개변수로 사용되며, key 는 해당 람다식을 slot table 에 저장하기 위한 정체성(인메모리 표현에서의 해당 노드에 대한 식별 가능한 key 값)을 나타내며, shouldBeTracked 의 경우 해당 람다식이 Compose Runtime 에 의해 추적 가능한지의 여부를 나타냅니다.

람다식이 만약 값을 캡처하지 않는다면, Composable 이 아닌 람다식과 마찬가지로 싱글톤으로 모델링되어 재 사용 되기 때문에 shouldBeTracked 는 false가 되어 해당 람다식이 추적될 수 없음을 표시합니다.

값을 캡처하는 Composable 람다식의 경우 Compose Runtime 에 의해 추적 되어 shouldBeTracked 가 true 가 되고, State<@Composable () -> Unit> 로 구현된 것과 유사하게 변형됩니다. 궁극적으로 SnapShot State 객체를 생성하게 되고, Donut hole Skipping 이라고 불리는 최적화 방법으로 이용됩니다.

Donut hole Skipping 은 restartableGroup 으로 나뉘어진 각 그룹에서 읽고 있는 상태의 변화가 감지되면, 해당 그룹만 Recomposition 이 발생하고, 나머지 하위 Composable 에 대해서는 건너뛰는 최적화 방법을 의미합니다.

Donut_hole_skipping

따라서 Composable 람다식이 캡처하는 값의 변화는 실제로 읽고 있는 Composable 함수의 invalidation 만을 트리거하고, 그 외에 읽지 않고 단순히 전달만 하는 Composable 함수는 Recomposition 을 건너뛰게 됩니다.

이는 State Hoisting 을 이용하여 상태 자체가 하향으로 전달되는 빈도가 높지만, 실제로 해당 상태를 최하위에서만 읽고, 중간에서는 읽지 않는 경우 적합하게 사용되는 “상태의 지연읽기” 를 이용하는 방법 입니다.

비교 전파

Compose Compiler 는 Composable 함수에 대해 Composer 인스턴스를 생성하여 전달하는 것 처럼, Composable 의 입력에 대해 비트 마스크 한 결과인 Changed 라는 Int 타입의 변수를 생성하여 전달합니다.

val a = 10

@Composable 
fun A(a: Int, $composer: Composer, $changed: Int)

입력이 정적인 경우, 위와 같이 a 라는 값은 10으로 Compile time 에 결정되고 runtime 에 변경되지 않습니다. 이러한 경우 runtime 동안 입력에 대한 변화가 없기 때문에 changed 값을 통해, 입력에 대한 Equals() 를 계산할 필요가 없다는 사실을 Compose Runtime 에게 알려, 이를 생략합니다.

val a = mutableStateOf(0)

@Composable 
fun A(a: Int, $composer: Composer, $changed: Int) {
	B(a, composer, changed)
}

@Composable
fun B(a: Int, $composer: Composer, $changed: Int)

입력이 상태인 경우에도 비교(Changed)를 전달함으로써 메모리를 절약하고, 성능을 높일 수 있습니다. Changed 라는 입력에 대한 변화를 비트마스킹한 값을 해당 입력을 공유하는 하위 Composable 까지 전달하는 것은 Recomposition 이 짧은 시간 이내에 여러 번 트리거 되었을 때, equals() 의 결과를 여러 번 계산하여 비교하지 않고, 이 계산을 슬롯 테이블에 여러 번 저장하지 않아도 되며, 상위에서 전달한 비트마스크를 하위에서 그대로 이용할 수 있으므로 메모리를 절약하고, 성능상 이점을 만들 수 있습니다.

또한, Changed 에는 입력에 대해 안정적인지 불안정적인지 에 대한 정보를 인코딩되어 Recomposition 을 건너뛸 수 있는 여부를 판단하는데도 사용됩니다.

Composer 인스턴스를 생성하여 상위 Composable 에서 최하위 Composable 까지 전달하는 것과 마찬가지로 비교(Changed) 역시 하위로 전파됩니다. 입력에 대한 변경 여부, 정적 또는 상태의 여부, 안정성 과 같은 정보를 하위로 전달함으로써 메모리를 절약하고 성능상 이점을 만들 수 있습니다.

이러한 상위의 비교(Changed)를 하위로 전파하는 것을 “비교 전파” 라고 합니다. 전파되는 비교를 토대로, Composer 가 invalidation(무효화) 이 트리거된 상위 그룹의 하위 그룹들에 대해 invalidation 을 트리거할 때, 하위 그룹의 Recomposition 을 트리거하거나 마지막 까지 건너뛸 수 있습니다.

디폴트 매개변수

Kotlin 의 언어적 수준에서 지원하는 디폴트 매개변수는 Composable 함수에 대해 적용할 수 없습니다. 이는 Composable 함수가 Compose Runtime 에 의해 Slot table 의 해당 Group 에서 실행되는 원리 때문입니다. 따라서 compose-compiler 에 의해 각 디폴트 매개변수마다 changed 처럼 default 매개변수를 비트마스크로 생성하여 변환합니다.

@Composable fun A(x: Int = 0) {} 

// 이는 아래처럼 변환됩니다.

@Composable fun A(x: Int, $changed: Int, $default: Int) {}

그룹 생성

Compose Runtime 이 이해할 수 있는 형태인 인메모리 표현(tree) 의 Slot table 에는 그룹 단위의 일반화된 형태로 Composable 함수의 각 노드나, 기억된 값과 같은 재 사용하기 위한 캐싱된 값들이 저장됩니다. 이는 위치 메모제이션으로 이용되기 위함인데, 위치 메모제이션은 해당 그룹이 인메모리 표현(tree) 의 어느 위치에 존재하는지와 입력 매개변수들을 결합하여 해당 Composable 함수를 식별가능한 형태인 그룹 단위로 만들어 둔 후, Recomposition 을 해당 그룹에 대해 선택적으로 트리거하거나(RestartableGroup), Composable 함수인 특정 노드의 위치를 정렬하는(MoveableGroup) 등으로 사용되기 위해 이용됩니다.

그룹의 종류는 여러가지가 있지만 대표적으로 이 책에서는 3가지를 언급합니다.

  • Restartable Group : 해당 그룹으로 감싸졌다면, Compose Runtime 이 해당 그룹을 선택적으로 재 구성할 수 있는 방법을 가르치기 위한 코드들을 Compose Compiler 가 작성합니다. 이는 재 구성이 일어날때 까지 지연(대기)한 후, 적절한 시간에 재구성이 일어나면 endRestartableGroup() 의 결과에 recomposeScope() 람다를 실행하도록 만듭니다. 단, 해당 restartable group 에서 상태를 읽는 경우에 해당합니다. 상태를 읽지 않는다면, 재 구성이 일어날 여지가 없으므로 endRestartableGroup() 이 null 을 반환하여 recomposeScope() 이 동작하지 않도록 만듭니다. 필요한 경우, Composer 는 현재 recompose 되는 composable 노드의 하위 노드들도 모두 recompose 할 수 있으며, 그렇지 않은 경우 마지막 노드까지 건너뛸(비교전파) 수 있습니다.
  • Replaceable Group : 이 그룹으로 감싸진 경우, RestartableGroup 과 달리 선택적으로 해당 그룹을 Recomposition 할 수 있는 방법이 없습니다. 재시작이 필요하지 않거나, 가능하지 않은 상황에서 단순히 해당 그룹에 대한 위치 메모제이션을 이용하여 정체성을 유지한 상태로 Slot table 내의 slot 정보를 업데이트 해야 할 때 사용합니다. 해당 그룹의 대표적인 사용사례로 조건부 논리(if) 의 분기문을 적용 받거나, Composable 람다식 최적화, @NonRestartableComposable 어노테이션이 작성된 Composable 의 경우 ReplaceableGroup 으로 wrapping 됩니다.
  • Moveable Group : 해당 그룹으로 wrapping 된 노드에 대해 정체성을 유지하면서, Slot table 내에서의 해당 노드의 위치를 이동(정렬)하는 방법을 Runtime 에게 가르치는 역할을 담당합니다. 아직까지는 Key() 컴포저블에 의해서만 생성되는 그룹입니다.

끝으로

정리하자면, Compose Compiler 는 Compose Runtime 이 필요로 하는 정보들을 IR 변환에 직접 개입하여 코드를 생성 혹은 변환하여 제공 하는 역할을 담당합니다. 이 과정에서 성능 최적화를 위한 여러 코드들을 생성한다는 점도 중요합니다.

Compose Runtime 은 Compose Compiler 가 생성하고 변환한 코드를 기반으로 Composable 함수들을 모두 실행하고, 이를 트리 자료구조 기반의 인메모리 표현을 생성하는 Composition 과정을 실행합니다. 이후 Composition 실행 후 Applier 와 SlotWritier 를 이용하여 변경사항들을 인메모리 표현에 적용하는 구체화 과정을 실행합니다.

그리고 이 과정(Composition)에서 전역 스냅샷 상태를 활성화하고, 상태들에 대한 변경사항을 감지하는 Observer 를 등록하여, Invalidation 이 트리거되었을 때, 그룹들에 대해 Recomposition 을 특정 스레드(AndroidUiDispatcher)에서 실행시키거나 건너뛰도록 하며 Effect 들을 실행하거나 중단시킨후 재실행시킵니다.

이런 총체적 과정이 실행되기 위해 Compose Runtime 은 특정 플랫폼(클라이언트)에 대해 구체적으로 알지 못합니다. 플랫폼과 런타임의 통합점이 필요하고 Compose-ui 에 의해 작성됩니다. 이는 안드로이드 Lifecycle 를 인식과 사용자 상호작용(키보드, 클릭, 드래그 등)이 가능하게 해주는 코드들이 포함합니다.

다음 챕터에서는 Compose Runtime 이 위와 같이 실행하는 동작에 대해 구체적으로 정리해보도록 하겠습니다.

태그:

카테고리:

업데이트:

댓글남기기