Jetpack Compose는 Compose Compiler, Compose Runtime, Compose UI 세 가지 핵심 요소로 구성됩니다.
- Compose Compiler & Runtime: Jetpack Compose의 핵심 요소이며, 다양한 클라이언트 라이브러리에서 사용될 수 있도록 설계됨.
- Compose UI: Runtime과 Compiler를 활용하는 클라이언트 중 하나로, UI 구성에 초점을 둠.
Compose 컴파일러
Jetpack Compose는 Kotlin 컴파일러 플러그인을 사용하여 Composable 함수 정보를 변환합니다.
- Compose Compiler는 Composable 함수에 Composer 매개변수를 강제로 주입하여 코드 생성을 수행합니다.
- 프론트엔드(frontend) 단계에서 정적 코드 분석 후 컴파일러 백엔드로 전달하는 역할을 합니다.
- IDEA(IntelliJ 기반의 Android Studio)와 직접 통합되지 않아, Compose 검사는 별도의 IDEA 플러그인을 통해 수행됩니다.
이 방식은 빠른 피드백과 최적화된 코드 변환을 가능하게 합니다.
Kotlin 컴파일러 플러그인은 정적 분석을 통해 기존 소스를 수정할 수 있으며, IR(중간 표현, Intermediate Representation) 단계에서 코드 변환이 가능합니다. 이를 통해 Compose Compiler는 Composable 함수를 Compose Runtime에 최적화된 형태로 변형할 수 있습니다.
또한, 어노테이션 프로세서 대신 KSP(Kotlin Symbol Processing) 기반의 경량 컴파일러 플러그인이 점차 확산될 전망입니다.
Compose 어노테이션들
Compose Compiler는 일반적인 어노테이션 프로세서와 다르게, 코드 자체를 직접 변형할 수 있습니다. 이를 위해 IR 변환을 활용하며, @Composable 어노테이션이 붙은 함수는 일반 함수와 다르게 동작하도록 강제됩니다.
또한, @Composable 함수는 메모리를 관리하는 기능을 가지며, remember, Composer, 슬롯 테이블 등을 사용할 수 있습니다. 이를 통해 화면이 갱신될 때(recomposition) 데이터를 유지할 수 있도록 도와줍니다.
Composable 함수는 각각 고유한 ID와 위치를 가지며, Composition 트리에서 효율적으로 관리됩니다. 또한, CompositionLocals을 통해 트리 전체에 공유되는 데이터(예: 테마, 로컬 설정 등)를 전달할 수 있습니다.
Composable 함수는 실행되면서 트리에 노드를 추가하는 역할을 합니다. 이 노드는 UI 요소일 수도 있고, 다른 라이브러리에 따라 다양한 형태로 사용될 수도 있습니다.
Compose Runtime은 특정 UI에 한정되지 않고 어떤 유형의 노드든 관리할 수 있도록 설계되어 있어, UI뿐만 아니라 다양한 구조에서도 활용될 수 있습니다.
@ComposableCompilerApi
이 어노테이션은 Compose에서 컴파일러 전용임을 명시하여, 사용자가 주의해야 함을 알립니다.
@InternalComposeApi
이 어노테이션은 stable API라도 내부 변경이 가능하도록 하며, Kotlin의 internal보다 넓은 범위를 가집니다.
@DisallowComposableCalls
이 어노테이션은 특정 람다에서 Composable 함수 호출을 금지하여 불필요한 recomposition을 방지하는 역할을 합니다. 예를 들어, remember 함수의 calculation 람다는 최초 composition에서만 실행되고 이후에는 저장된 값을 반환해야 하므로, 이를 보장하기 위해 @DisallowComposableCalls를 사용합니다.
@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
이 어노테이션이 적용된 람다 내부에서는 Composable 함수를 호출할 수 없으며, 만약 다른 인라인 람다를 호출할 경우 해당 람다에도 동일한 어노테이션을 적용해야 합니다. 일반적인 앱 개발에서는 거의 사용되지 않지만, Compose Runtime을 기반으로 자체 UI 라이브러리를 개발할 때 유용하게 활용될 수 있습니다.
@ReadOnlyComposable
이 어노테이션은 해당 Composable 함수가 composition에서 데이터를 변경하지 않고 오직 읽기만 수행한다는 것을 보장합니다. 이를 통해 Compose Runtime은 불필요한 코드 생성을 방지하고 최적화할 수 있습니다.
보통 Composable 함수는 내부적으로 그룹을 생성하여 composition에 반영되는데, @ReadOnlyComposable이 적용된 함수는 이러한 그룹을 만들지 않아 불필요한 recomposition을 최소화합니다. 예를 들어, 특정 UI 상태를 읽기만 하는 함수에 이 어노테이션을 적용하면, Compose는 해당 함수가 상태를 변경하지 않는다고 가정하고 최적화할 수 있습니다.
예를 들어, 아래 코드에서 if 조건에 따라 "Hello" 또는 "World"를 표시하는 Text 함수가 실행됩니다.
if (condition) {
Text(”Hello”)
} else {
Text(”World”)
}
두 Text 함수는 같은 Composable 함수지만, 서로 다른 조건에서 실행되므로 Compose는 이를 서로 다른 개별 요소로 인식합니다. 만약 UI 요소가 이동할 가능성이 있다면, Compose는 이를 "이동 가능한 그룹(movable groups)"으로 관리하여 UI가 올바르게 업데이트되도록 합니다.
그러나 Composable 함수가 단순히 데이터를 읽기만 하고 composition 과정에서 UI 요소를 변경하지 않는다면, 이러한 그룹을 생성할 필요가 없습니다. @ReadOnlyComposable을 적용하면 Compose가 이를 최적화하여 불필요한 작업을 방지할 수 있습니다.
실제로, @ReadOnlyComposable은 CompositionLocal 값을 읽거나 시스템 정보를 가져오는 함수에 주로 사용됩니다.
예를 들면:
- MaterialTheme.colors (앱의 색상 테마 정보)
- isSystemInDarkTheme() (다크 모드 여부 확인)
- LocalContext.current (현재 앱의 Context 가져오기)
- LocalConfiguration.current (디바이스의 화면 크기나 설정 정보 가져오기)
@Composable
fun MyApp() {
val isDarkMode = isSystemInDarkTheme() // 읽기 전용 Composable
val backgroundColor = getBackgroundColor(isDarkMode)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Text(
text = if (isDarkMode) "Dark Mode" else "Light Mode",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
@ReadOnlyComposable
@Composable
fun getBackgroundColor(isDarkMode: Boolean): Color {
return if (isDarkMode) Color.Black else Color.White
}
이러한 값들은 프로그램이 실행될 때 한 번 설정되며, 이후에는 UI에서 읽기만 하면 되므로 @ReadOnlyComposable을 적용하면 최적화에 도움이 됩니다.
@NonRestartableComposable
이 어노테이션을 적용하면 해당 Composable 함수가 recomposition(다시 실행)되지 않도록 합니다.
일반적으로 Composable 함수는 상태 변화에 따라 다시 실행되지만, 이 어노테이션을 사용하면 recomposition을 위한 추가 코드 생성을 생략하여 성능을 최적화할 수 있습니다.
주로 재구성이 필요 없는 간단한 UI 요소(예: 아이콘, 고정된 텍스트 등)나 상위 Composable의 변화에만 영향을 받는 경우에 사용합니다. 다만, 대부분의 recomposition은 자동으로 관리되므로 실제로 사용되는 경우는 드물며, 신중하게 적용해야 합니다.
@StableMarker
Compose Runtime은 타입의 안정성을 보장하기 위해 @StableMarker, @Immutable, @Stable 어노테이션을 제공합니다.
@StableMarker는 @Immutable과 @Stable에 적용되는 메타 어노테이션으로, 데이터의 안정성을 보장하는 역할을 합니다. 이를 만족하려면 equals 함수의 결과가 항상 일정해야 하며, public 프로퍼티가 변경되면 composition에 반영되어야 합니다. 또한, 모든 public 프로퍼티는 안정적인 값이어야 합니다.
컴파일러는 기본적으로 타입의 안정성을 자동으로 추론하지만, 특정 경우에는 직접 어노테이션을 적용해야 합니다. 예를 들어, 인터페이스나 추상 클래스를 구현할 때 안정성을 보장해야 하거나, 내부적으로 캐시를 사용하지만 public API가 변경되지 않는 경우 @Stable 또는 @Immutable을 명시적으로 지정할 수 있습니다.
@Immutable
이 어노테이션은 클래스가 인스턴스 생성 이후 외부에 노출된 모든 프로퍼티가 변경되지 않음을 보장하는 역할을 합니다. 이는 val보다 강력한 개념으로, val이 참조 자체만 불변으로 보장하는 것과 달리, 내부 데이터 구조까지 변경되지 않음을 컴파일러와 약속합니다.
Compose Runtime은 @Immutable 타입이 변경되지 않는다고 가정하고, recomposition을 최적화하여 불필요한 UI 업데이트를 줄입니다. 따라서 모든 프로퍼티가 val이고, 사용자 정의 getter가 없으며, 원시 타입 또는 @Immutable로 표시된 타입이어야 합니다.
이 어노테이션은 @StableMarker의 요구사항을 따르며, Compose Runtime에 데이터의 불변성을 명확히 전달하여 성능을 최적화하는 데 사용됩니다.
@Stable
이 어노테이션은 @Immutable보다 완화된 개념으로, 적용 대상에 따라 의미가 달라집니다.
- 타입에 적용하면 해당 타입이 가변적이지만 안정적인 변경만 발생한다고 컴파일러에 알립니다. 이는 @StableMarker의 요구사항을 따릅니다.
- 함수나 프로퍼티에 적용하면 동일한 입력값에 대해 항상 동일한 결과를 반환함을 보장합니다. 단, 매개변수는 @Stable, @Immutable 또는 기본(primitive) 타입이어야 합니다.
Compose Runtime은 @Stable 타입의 값이 이전과 동일하면 recomposition을 생략하여 성능을 최적화합니다. 주로 private한 가변 상태를 가지지만 외부적으로는 불변처럼 동작하는 경우에 사용됩니다. 다만, 안정성을 보장할 수 없는 경우에는 사용을 피해야 하며, 잘못 적용하면 런타임 오류를 초래할 수 있습니다.
'Android' 카테고리의 다른 글
Compose 컴파일러 (0) | 2025.03.09 |
---|---|
Composable 함수 정리 (0) | 2025.03.03 |
상태를 어디에 호이스팅할 것인가 (0) | 2025.02.17 |