Jetpack Compose가 하는 핵심적인 역할은 데이터를 UI로 변환하는 것이다. 이 과정에서 컴포지션, 레이아웃, 드로잉이라는 세 가지 주요 단계를 거치게 된다. 이러한 단계들은 일반적으로 단방향 데이터 흐름을 따르며, Compose는 성능 최적화를 위해 동일한 입력에 대한 반복 작업을 피하게 된다.
컴포지션 단계 : 화면에 무엇을 보여줘야 할 지 결정
레이아웃 단계 : 각 요소가 화면에 어디에 보여줘야 할 지 결정
그리기 단계 : 각 요소를 화면에 어떻게 그리는지 결정
1. 컴포지션 단계
컴포지션 단계에서는 화면에 무엇을 보여줄지 결정한다. Compose 런타임은 Composable 함수를 실행하고 UI를 나타내는 트리 구조를 출력한다. 이때 코드 상의 Composable 함수는 트리의 노드와 1:1로 매핑된다. 이러한 노드를 레이아웃 노드라고 한다.
컴포지션 단계에서는 상태 관리, 이벤트 핸들링, 데이터 바인딩 등의 로직 정보를 포함하여 UI Tree를 생성한다. 특히 중요한 점은 Composable 함수에 modifier가 설정되어 있다면, UI Tree에서 modifier의 값으로 래퍼 노드가 생성된다는 것이다. 여러 개의 modifier가 있을 경우 이러한 래퍼 노드들은 체인 형태로 연결된다.
2. 레이아웃 단계
레이아웃 단계는 컴포지션 단계에서 생성된 UI Tree를 입력으로 받아, 각 요소들의 위치를 결정한다. 이 단계에서는 트리를 탐색하면서 다음 세 가지 알고리즘을 순차적으로 적용한다.
- 자식 측정 : 현재 노드가 자식 노드들을 가지고 있다면, 먼저 자식들의 크기를 측정한다.
- 본인 크기 측정 : 자식들의 측정값을 기반으로 현재 노드의 크기를 결정한다.
- 자식 배치 : 결정된 크기를 바탕으로 자식 노드들을 현재 노드 내에서 상대적인 위치에 배치한다.
이러한 과정의 중요한 특징은 각 노드를 단 한 번만 방문한다는 것이다. 기존 안드로이드의 뷰 시스템과 비교했을 때, Compose는 전체 트리를 한 번만 탐색하면 되므로 성능상의 큰 이점을 얻을 수 있다. 뷰의 중첩이 깊어지더라도 각 뷰를 한 번만 방문하기 때문에 성능이 선형적으로 증가한다.
레이아웃 단계가 완료되면 각 레이아웃 노드에는 할당된 너비와 높이, 그리고 실제로 그려질 x, y 좌표가 저장된다. 즉, 레이아웃 단계는 트리를 탐색하면서 화면의 어떤 위치에 레이아웃 노드 또는 컴포저블 함수를 배치할지 결정하는 것이다.
3. 드로잉 단계
드로잉 단계에서는 레이아웃 단계와 마찬가지로 트리를 위에서 아래로 탐색하면서 각 노드를 순차적으로 그린다. 이미 레이아웃 단계에서 모든 노드의 위치가 결정되었기 때문에, 드로잉 단계에서는 위치를 변경하는 modifier를 제외하고는 정해진 위치에 따라 순차적으로 그리기 작업을 수행한다.
Modifier와 래퍼 노드의 관계
다시 한 번 컴포지션 단계로 돌아가보자.
컴포저블 함수에 modifier가 설정되어 있다면, UI Tree에서 modifier의 값으로 래퍼 노드가 생성된다. 즉, 레이아웃 노드를 감싸는 노드 = modifier의 노드 = 래퍼 노드라는 것이다.
여러 개의 modifier가 있을 경우, 이러한 래핑된 관계는 "체인"이라고 불린다. modifier 노드는 나머지 체인과 레이아웃 노드를 순차적으로 래핑하는 구조를 가지고 있다.
레이아웃 단계에서의 Modifier 처리
레이아웃 단계에서 레이아웃 노드가 방문될 때는 원본 레이아웃뿐만 아니라 래핑되어 있는 모든 modifier 노드들이 함께 방문된다. 특히 주목할 점은 modifier가 위치나 크기 정보를 포함할 때이다. 이 경우 레이아웃 노드의 원래 레이아웃 값을 사용하는 것이 아니라, 래핑된 래퍼 노드의 값을 가져와서 오버라이드한다. 이 오버라이드된 값을 통해 레이아웃 단계에서 최종적인 위치와 너비가 설정된다.
최적화 관점
즉, modifier의 정보를 바탕으로 레이아웃 노드들이 생성된다는 사실을 기억하고 이 부분을 최적화 하는 것이 스마트 리컴포지션의 기법 중 하나이다. modifier의 적절한 사용과 구조화는 성능 최적화에 직접적인 영향을 미칠 수 있다.
단계적 상태 읽기
위에서 언급한 바와 같이 Compose에는 세 개의 주요 단계가 있고 Compose는 각 단계 내에서 읽은 상태를 추적한다.
1. 컴포지션 단계에서의 상태 읽기
@Composable 함수나 람다 블록 내의 상태 읽기는 컴포지션 및 잠재적으로 이후 단계에 영향을 미친다. 상태 값이 변경되면 Recomposer는 이 상태 값을 읽는 모든 구성 가능한 함수의 재실행을 예약한다.
컴포지션 단계에서 상태를 읽으면 해당 값이 변경될 때 리컴포지션이 발생하고, 이는 레이아웃과 드로잉 단계에도 영향을 미칠 수 있다.
var padding by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
// padding 상태는 컴포지션 단계에서 읽힘
// padding이 변경되면 리컴포지션이 발생
modifier = Modifier.padding(padding)
)
2. 레이아웃 단계에서의 상태 읽기
레이아웃 단계는 측정과 배치라는 두 단계로 구성된다. 이 단계에서 상태를 읽으면 상태 값이 변경될 때 레이아웃 단계부터 다시 시작된다.
var offsetX by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// offsetX 상태는 레이아웃의 배치 단계에서 읽힘
// offsetX가 변경되면 레이아웃부터 다시 시작
IntOffset(offsetX.roundToPx(), 0)
}
)
3. 드로잉 단계에서의 상태 읽기
드로잉 단계에서 상태를 읽으면 상태 값이 변경될 때 드로잉 단계만 다시 실행된다.
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
// color 상태는 드로잉 단계에서 읽힘
// color가 변경되면 드로잉만 다시 실행
drawRect(color)
}
위 이미지는 각 단계별로 상태를 읽을 수 있는 위치를 다음과 같이 나타내고 있다.
- Composition 단계
@Composable function
(컴포저블 함수)@Composable lambda blocks
(컴포저블 람다 블록)
- Layout 단계
Layout/measure lambda
(레이아웃/측정 람다)Modifier.offset {} blocks
(modifier 오프셋 블록)
- Drawing 단계
Canvas(), Modifier.drawBehinde
(캔버스, drawBehind modifier)Modifier.drawWithContent
(drawWithContent modifier)
결과적으로 단계적 상태 읽기의 핵심적인 내용은 "각 상태의 용도에 맞는 적절한 단계에서 읽어야 한다" 이다. 이렇게 해야 불필요한 리컴포지션이나 레이아웃 계산을 피할 수 있다.
상태 읽기 최적화 예시
각 단계에서의 상태 읽기는 해당 단계에 맞는 적절한 상태를 읽어야 한다.
- 컴포지션 단계에서 읽어야 하는 상태
- UI 구조에 영향을 미치는 상태 (어떤 컴포넌트를 보여줄지)
- 텍스트 내용
- 색상 테마
- 사용자 설정값 등
var isExpanded by remember { mutableStateOf(false) }
if (isExpanded) { // 컴포지션 단계에서 상태를 읽는 것이 적절
ExpandedContent()
} else {
CollapsedContent()
}
- 레이아웃 단계에서 읽어야 하는 상태
- 위치나 크기 계산에 필요한 상태
- 스크롤 위치
- 애니메이션 진행값 등
var offset by remember { mutableStateOf(0f) }
Modifier.offset { // 레이아웃 단계에서 상태를 읽는 것이 적절
IntOffset(offset.roundToInt(), 0)
}
- 드로잉 단계에서 읽어야 하는 상태
- 그리기 관련 속성 (색상, 투명도 등)
- 시각적 효과에 관련된 상태
비최적화된 스크롤 효과 구현
Box {
val listState = rememberLazyListState()
Image(
// 좋지 않은 구현!
Modifier.offset(
with(LocalDensity.current) {
// 컴포지션 단계에서 스크롤 상태를 읽음
(listState.firstVisibleItemScrollOffset / 2).toDp()
}
)
)
LazyColumn(state = listState) {
// ...
}
}
최적화된 스크롤 효과 구현
Box {
val listState = rememberLazyListState()
Image(
Modifier.offset {
// 레이아웃 단계에서 스크롤 상태를 읽음
IntOffset(0, listState.firstVisibleItemScrollOffset / 2)
}
)
LazyColumn(state = listState) {
// ...
}
}
최적화된 버전이 더 나은 이유는 다음과 같다
- 스크롤 상태를 레이아웃 단계에서 읽음으로써 불필요한 리컴포지션을 방지
- 프레임마다 변경되는 스크롤 값에 대해 컴포지션을 다시 실행하지 않음
- 필요한 단계(레이아웃, 드로잉)만 다시 실행됨
▼ 공식문서
https://developer.android.com/develop/ui/compose/phases?hl=ko
Jetpack Compose 단계 | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Jetpack Compose 단계 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 대부분의 다른 UI 도구 키트와 마찬가
developer.android.com
'Android' 카테고리의 다른 글
Hilt를 사용한 의존성 관리 (1) | 2024.12.05 |
---|---|
[Jetpack Compose] Compose의 Side Effect (부작용) (4) | 2024.09.10 |
[Jetpack Compose] Composable 수명 주기 (1) | 2024.09.09 |
[Jetpack Compose] Compose에서의 Statelss/Stateful (2) | 2024.09.06 |
[Jetpack Compose] Jetpack Compose의 이해 (0) | 2024.09.06 |