[Compose] Compose Compiler

15 분 소요

최근들어 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 을 수행하게 됩니다. (Kotlin 2.0.20 버전 부터는 Strong Skipping Mode 에 의해 안정적이지 않은 타입은 메모리값을 비교하여 같은 경우 Recomposition 을 건너뛸 수 있습니다. )

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

Stable Marker

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

  • 해당 클래스가 변경 가능한 타입이 아니거나, 내부적으로 변경 가능한 프로퍼티 혹은 타입을 가지지 않아야 합니다.
  • Primitive Type(Int, Double, String …) 은 안정적으로 간주됩니다.(불변적 타입이기 때문에)
  • 값을 캡처하지 않거나, 안정적인 값을 캡처하는 람다식은 안정적으로 간주됩니다.(Kotlin 2.0.20 버전 부터는 Strong Skipping Mode 가 Default 로 적용되며, 안정적이지 않은 값을 캡처하는 람다식은 remember 로 wrapping 하여 최적화 합니다. )
  • Stable Marker 가 지정된 경우 안정적으로 간주됩니다.(State 나 MutableState 도 @Stable 이 지정되어 안정적으로 간주됩니다.)
  • 안정성 구성 파일에 추가된 클래스는 안정적으로 간주 됩니다.

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

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

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

즉, Compose Compiler 는 구체적인 타입이 불변적임을 보장할 수 있는 경우에 대해서 안정적으로 추론할 수 있습니다. 그 이유는 Compose Runtime 이 객체가 변경되었는지의 여부를 추적하지 않기 때문입니다. Compose Runtime 은 오로지 SnapShot State 에 대해서만 관찰자를 통해 변경의 여부를 추적하고 있습니다.

data class A(val a: Int)

data class B(var b: Int)

A 클래스는 Int 라는 불변적 타입과 val 프로퍼티로 불변적임을 확신할 수 있습니다. 따라서 Compose Compiler 는 A 타입이 절때 변경되지 않을 것임을 확신하고 Recomposition 에 대해 건너뛸 수 있습니다.

하지만, 클래스 B의 경우 Int 라는 불변적 타입이지만 var 프로퍼티로 b 변수의 값이 변할 수 있습니다. 하지만 실제로 Compose Runtime 은 스냅샷 상태가 아닌 일반 객체인 B가 변경되었는지의 여부를 추적하지 않기 때문에 반응적으로 UI 를 갱신할 수 없습니다. 이러한 문제 때문에 Compose 에서는 안정성 규칙을 안정적이지 않은 즉, 변경 가능한 타입에 대해 항상 무효화가 발생하면 Recomposition 을 수행하여 UI 를 갱신해 주게 됩니다.

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

  • 안정적인 타입에 대해 이전과 이후의 두 인스턴스의 Equals() 함수 결과가 같다면 건너뛸 수 있습니다.
  • 해당 객체의 Public 프로퍼티들도 모두 안정적 이어야 합니다.
  • 객체의 Public 프로퍼티의 변경사항들은 Composition 에게 알려지게 됩니다.(관찰자를 통해 변경사항이 자동으로 invalidation 을 트리거합니다.)

@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() 의 결과값은 같지만 내부적으로 가변적 타입을 보유하는 경우에 해당합니다.(data class는 생성자 프로퍼티들의 값을 Equals() 결과값에 이용한다는 점을 생각해 보세요.) 보통 생성자의 public 프로퍼티들은 불변적이지만, 생성자 이외의 내부적으로 private 한 가변적 상태를 보유하고 있는 data class 의 경우에 사용됩니다.

@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 함수 호출, 매개변수, 상태, 기억된값 등이 Slot table 내에 캐싱되고 추적되어진다는 의미입니다.

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

예외 사항

Kotlin 2.0.20 버전 부터는 Strong Skipping Mode 가 자동으로 활성화 됩니다. Strong Skipping Mode 는 안정적이지 않은 타입에 대해 메모리 동등성 비교를 진행하여 같은 경우 Recomposition 을 건너뛸 수 있으며, 안정적이지 않은 값을 캡처하는 람다를 remember 로 wrapping 하여 최적화 합니다. 결과적으로 Recomposition 이 발생했을 때, 안정적이지 않은 타입에 대한 최적화를 진행해 줍니다.

또한, 안정성 구성 파일에 클래스를 추가하여 Compose Compiler 에게 안정적으로 간주되도록 만들 수도 있습니다. 이에 대한 구체적인 예시가 바로 ImmutableList 입니다.

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 로 최적화를 알아서 해주게 됩니다.

fun A(A: Int) { }

fun B(b: Unstable) { }

// 이를 이렇게 최적화 해 줍니다.

fun A(A: Int) = remember(a) { }

fun B(b: Unstable) = remember(b) { }

두가지 방식 모두 remember 로 최적화 하는 것은 동일하지만, key 에 해당된 값을 비교하는 방법은 다릅니다. Primitive type 과 같은 안정적인 타입의 경우 Equals() 함수의 비교를 수행하지만, 안정적이지 않은 타입의 경우 메모리 동등성 비교(===)를 수행하며, 결과가 같다면 Recomposition 을 건너뛸 수 있습니다.

Composable 람다식

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

Composable 람다식은 composableLamda(composer, key, shouldBeTracked …) 이라는 팩토리 함수로 변환됩니다. 대표적 3개의 매개변수들을 차례로 보겠습니다. 첫번째는 람다식을 매개변수로 호출하는 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 는 멀티플랫폼을 타겟팅하는 프레임워크이기 때문에 Compose Runtime 은 특정 플랫폼(클라이언트)에 대해 구체적으로 알지 못합니다. 따라서 플랫폼과 런타임의 통합점이 필요하고 Compose-ui 에 의해 작성됩니다. 총체적으로 Compose Runtime 은 ui 가 의존할 추상화를 제공하고 있습니다. 그리고 ui 는 안드로이드 Lifecycle 에 대해 인식할 수 있게 하거나 사용자 상호작용(키보드, 클릭, 드래그 등의 접근성 이벤트)이 가능하도록 통합점을 구현합니다.

다음 챕터에서는 Compose Runtime 에서 slot table 을 운용하는 방법들과 ui 에게 제공하는 추상화, 그리고 Recomposition 의 동작 및 Effect Handler 에 대한 실행 Context(CoroutineContext 의 Dispatcher를 의미합니다.)의 제공과 타이밍을 결정하는 Recomposer 등에 대해 구체적으로 정리해보겠습니다.

태그:

카테고리:

업데이트:

댓글남기기