Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.
책의 모든 내용을 정리할 목적으로 작성하는 글은 아니라, 내용에 대한 정리는 아래 블로그글을 참고하면 도움이 될듯 합니다.
그 이유는 Runtime과 Compiler는 그 요구 사항을 충족하는 어떤 클라이언트 라이브러리에서든 사용될 수 있도록 포괄적으로 디자인되었기 때문이다.
Compose UI는 Runtime과 Compiler를 활용하는 클라이언트 중 하나에 불과하다. Compose UI와 관련해서 다른 클라이언트 라이브러리들도 있는데, 대표적으로 JetBrains에서 개발 중인 데스크톱 및 웹용 클라이언트 라이브러리가 있다.
그 말인 즉, Compose UI를 살펴보면 Compose가 Composable 트리의 런타임 인메모리 표현을 제공하는 방법과, 궁극적으로 해당 인메모리 표현을 어떻게 실제 요소로 구체화하는지를 이해하는 데 도움이 된다.
Compose Compiler와 Annotation Processor 의 가장 큰 차이점은, Compose의 경우 실제로 어노테이션이 붙어있는 선언이나 표현식을 변형한다는 것이다.
대부분의 Annotation Processor 는 표현식을 변형하는 행위 등은 할 수 없으며, 새로운 코드를 생성하거나 기존 코드와 동등한 수준의 코드만 추가로 생성할 수 있다. 그렇기 때문에 Compose Compiler는 IR 변환을 사용합니다.
@Composable 어노테이션은 실제로 어노테이션이 붙은 대상의 타입을 변경하며, 컴파일러 플러그인은 프론트엔드에서 Composable 타입이 일반적인(Composable이 아닌) 함수들과 동일한 취급을 받지 않도록 모든 종류의 규칙을 강제하는 데 활용된다.
@Composable을 통해 선언이나 표현식의 타입을 변경하는 것은 대상에게 “메모리“를 부여하는 것을 의미한다. 즉, remember를 호출하고 Composer 및 슬롯 테이블을 활용할 수 있는 능력을 의미한다. 또한, Composable의 본문 내에서 구동된 이펙트들(effects)이 준수할 수 있는 라이프사이클을 제공한다. (ex. recomposition이 수행되어도 기존 작업 유지하기 등)
Compose Compiler와 Annotation Processor는 둘다 코드를 생성할 수 있다. 둘다 런타임 라이브러리들이 사용할 수 있는 편리한 코드를 생성하거나 합성하는데 자주 활용되기 때문이다.
방문자 패턴은 객체 구조를 변경하지 않고 새로운 동작을 추가할 수 있게 해주는 디자인 패턴이다.
트리 구조를 순회하면서 각 노드에 대해 특정 작업을 수행
// 방문받을 요소들
interface PsiElement {
fun accept(visitor: PsiVisitor)
}
class FunctionCall(val name: String, val isReadOnly: Boolean) : PsiElement {
override fun accept(visitor: PsiVisitor) = visitor.visit(this)
}
class PropertyAccess(val name: String, val isWrite: Boolean) : PsiElement {
override fun accept(visitor: PsiVisitor) = visitor.visit(this)
}
// 방문자
interface PsiVisitor {
fun visit(call: FunctionCall)
fun visit(access: PropertyAccess)
}
// 구체적인 검사 로직
class ReadOnlyChecker : PsiVisitor {
val errors = mutableListOf<String>()
override fun visit(call: FunctionCall) {
if (!call.isReadOnly) {
errors.add("${call.name}은 ReadOnly가 아닙니다")
}
}
override fun visit(access: PropertyAccess) {
if (access.isWrite) {
errors.add("쓰기 작업은 허용되지 않습니다")
}
}
}
// 사용
fun main() {
val elements = listOf(
FunctionCall("Text", isReadOnly = true),
FunctionCall("remember", isReadOnly = false),
PropertyAccess("state", isWrite = true)
)
val checker = ReadOnlyChecker()
elements.forEach { it.accept(checker) }
checker.errors.forEach { println(it) }
// 출력:
// remember은 ReadOnly가 아닙니다
// 쓰기 작업은 허용되지 않습니다
}
Element: accept(visitor) 메서드로 방문자를 받아들임
Visitor: 각 Element 타입별로 다른 visit() 메서드 구현
-> 트리 구조 변경 없이 새로운 검사 로직 추가 가능
Compose Compiler 는 이 패턴으로 PSI 트리를 순회하며 @ReadOnlyComposable 같은 제약을 검사한다.
PSI 는 Program Structure Interface 의 약자로, 파일을 구문 분석하고 플랫폼의 다양한 기능을 지원하는 문법(syntatic) 및 의미론적(semantic) 모델을 생성하는 Intellij 플랫폼의 계층 구조를 의미한다.
중간 표현(Intermediate Representation)은 플랫폼에 독립적인 코드 형태이다. Compose Compiler는 IrGenerationExtension을 통해 IR을 생성하며, 개발자가 작성한 소스 코드를 수정할 수 있다.
코드 변형: 매개 변수 삽입/교체 -> 코드 구조를 재구성
Composer 주입: 각 Composable 함수에 암시적인 추가 매개변수인 Composer 매개 변수 삽입
멀티플랫폼: IR 생성으로 모든 플랫폼(JVM,JS,Native) 지원
고수준 개념을 저수준 원자적(atomic)개념으로 변환하는 과정
Compose Compiler는 IR 트리를 순회하며, Compose Runtime 이 이해할 수 있는 표현으로 정규화를 수행한다.(코드를 변환한다로 해석)
클래스 안정성 추론: 클래스 안정성 메타데이터 추가
라이브 리터럴: 리터럴(코드에 하드코딩된 값)을 가변 state로 변환(런타임 반영)
Composer 전파: 모든 Composable 호출에 Composer 전달
Composable 함수의 본문을 래핑:
Control flow group 생성(replaceable groups, movable groups)
별도의 디폴트 매개변수 지원
Recomposition 스킵 로직 추가
상태 변경 트리 하향식 전파 -> 상태 변경시 자동으로 Recomposition
라이브 리터럴(Live Literals) 은 리컴파일 없이 상수 값 변경을 실시간으로 반영하는 개발 편의성을 위한 기능으로, 상수를 state로 변환하여 런타임에 동적으로 값을 변경 가능하게 한다. 성능에 민감한 코드의 속도를 크게 저하시킬 수 있으므로, release 빌드에선 비활성화를 권장
Kotlin에선 equals가 ==
매개변수 변경 정보를 트리 아래로 전파하여 불필요한 비교와 recomposition을 생략하는 최적화 기법이다.
Compose Compiler는 성능 최적화를 위해 비트마스킹 기법을 적극 활용한다.
각 매개변수의 변경 상태를 비트로 인코딩한 메타데이터
함수에 전달된 매개변수들의 안정성 정보를 함께 인코딩
매개변수가 실제로 변경되었는지(dirty) 판단하는 로컬 변수
$dirty를 기반으로 recomposition 여부 결정
디폴트 매개변수 사용 여부를 비트로 표현한 메타데이터(각 매개변수의 인덱스를 비트로 매핑)
Kotlin 디폴트 매개변수는 생성된 그룹 범위 밖에서 실행
-> Composable은 그룹 내부에서 디폴트 값 평가(실행 및 계산) 필요
-> $default 비트마스크로 독자적 구현
Compose Compiler는 Composable 함수의 제어 흐름에 따라 3가지 그룹을 Composable 함수 본문에 삽입하여 Compose Runtime이 상태를 관리하고 Recomposition을 효율적으로 처리하도록 함
교체될 수 있는 블록을 감싸는 그룹(조건부 블록)
@NonRestartableComposable 함수// 변환 전
@NonRestartableComposable
@Composable
fun Foo(x: Int) {
Wat()
}
// 변환 후
@Composable
fun Foo(x: Int, $composer: Composer, $changed: Int) {
$composer.startReplaceableGroup(<key>)
Wat($composer, 0)
$composer.endReplaceableGroup()
}
if (condition) {
Text("Hello") // 교체 가능한 그룹 1
} else {
Text("World") // 교체 가능한 그룹 2
}
// condition 변경 시 그룹 전체 교체
정체성을 유지하며 재정렬 가능한 그룹
key() 함수 내부에서만 생성
// 변환 전
@Composable
fun Test(value: Int) {
key(value) {
Wrapper {
Leaf("Value $value")
}
}
}
// 변환 후
@Composable
fun Test(value: Int, $composer: Composer, $changed: Int) {
$composer.startMovableGroup(<key>, value)
Wrapper(composableLambda(...) {
Leaf("Value $value", $composer, 0)
}, $composer, 0b0110)
$composer.endMovableGroup()
}
Column {
for (talk in talks) {
key(talk.id) { // 이동 가능한 그룹 생성
Talk(talk) // 리스트 순서 변경 시에도 정체성 유지
}
}
}
상태를 읽고 recomposition이 필요한 Composable을 감싸는 그룹
상태(state)를 읽는 모든 Composable 함수
// 변환 전
@Composable
fun A(x: Int) {
f(x)
}
// 변환 후
@Composable
fun A(x: Int, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(<key>)
f(x)
$composer.endRestartGroup()?.updateScope { next ->
A(x, next, $changed or 0b1) // 재시작 방법 등록
}
}
endRestartGroup()이 null이 아닌 값 반환 시, Composable을 재시작(재실행)하는 람다 생성
updateScope()로 recomposition 방법 등록
null 반환 시: 상태를 읽지 않음 → recomposition 불필요
Composable 함수의 실행을 추적하고, 슬롯 테이블을 관리하며, recomposition을 제어하는 런타임 객체
이전에 언급했듯 Compose Compiler가 모든 Composable에 자동으로 주입
@Composable
fun Greeting(name: String, $composer: Composer) {
$composer.startRestartGroup(...) // 그룹 시작
// 이전 값과 비교
if ($composer.changed(name)) {
// 변경됨 → recomposition
}
$composer.endRestartGroup() // 그룹 종료
}
이전 composition의 데이터를 슬롯 테이블에 저장, 현재 값과 비교하여 변경 여부 판단
changed(): 값 변경 감지
@Composable
fun Greeting(name: String, $composer: Composer) {
if ($composer.changed(name)) {
// name이 변경됨 → recomposition 수행
}
}
skipping: recomposition 생략 여부 판단
@Composable
fun Header(text: String, $composer: Composer, $changed: Int) {
var $dirty = $changed
// $dirty 계산...
if ($dirty and 0b1011 xor 0b1010 !== 0 || !$composer.skipping) {
// recomposition 필요 → 본문 실행
Text(text)
} else {
// 변경 없음 → 생략
$composer.skipToGroupEnd()
}
}
skipToGroupEnd(): 변경 없으면 건너뛰기
@Composable
fun Profile(user: User, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(...)
if (/* 변경 없음 */) {
$composer.skipToGroupEnd() // 그룹 끝까지 건너뜀
return // 본문 실행 안 함
}
// 변경 있을 때만 실행
Text(user.name)
Text(user.email)
$composer.endRestartGroup()
}
startRestartGroup: 재시작 가능한 그룹 생성
startReplaceableGroup: 교체 가능한 그룹 생성
startMovableGroup: 이동 가능한 그룹 생성
reference)
Composer의 역할