아이템의 순서 변경, 추가, 삭제 시 전체 목록이 다시 그려지는 것을 막고 변경된 아이템만 갱신하기.
LazyColumn {
items(
items = friends,
key = { friend -> friend.id } // 고유한 ID를 키로 설정
) { friend ->
FriendItem(friend = friend)
}
}
헤더, 배너, 일반 아이템 등 여러 레이아웃이 섞여 있을 때 RecyclerView의 ViewType처럼 컴포지션 구조 재사용 효율을 극대화하기.
LazyColumn {
items(
items = feedItems,
key = { it.id },
contentType = { it.type } // "HEADER", "POST", "AD" 등 타입별 분리
) { item ->
when (item) {
is FeedItem.Header -> HeaderView(item)
is FeedItem.Post -> PostView(item)
is FeedItem.Ad -> AdBannerView(item)
}
}
}
코틀린 기본 List 대신 ImmutableList를 전달하여 부모가 재구성되어도 자식 컴포저블이 온전히 스킵되도록 만든다.
List 내부 요소인 Friend 클래스에 var 프로퍼티가 있거나 Unstable 필드가 포함되어 있다면 FriendListUiState 전체가 다시 불안정해진다고 함. Friend 역시 불변 데이터 클래스여야 한다.
// 안정적인 UI 상태 모델
@Stable
data class FriendListUiState(
val friends: ImmutableList<Friend> // kotlinx.collections.immutable
)
@Composable
fun FriendItem(friend: Friend) {
Text(text = friend.name)
}
아이템 루프 안에서 매번 새로운 람다 객체를 생성해 전달하면 리컴포지션 스킵이 깨짐. 아이템 내부에서 ID를 넘기거나 메서드 레퍼런스를 활용합니다.
// 안 좋은 예: 매번 람다 객체 생성
onClick = { viewModel.onSelect(friend.id) }
// 좋은 예: 아이템 컴포저블 내부에서 클릭 시 ID를 전달하도록 위임
@Composable
fun FriendItem(
friend: Friend,
onSelect: (Long) -> Unit
) {
Row(modifier = Modifier.clickable { onSelect(friend.id) }) {
Text(text = friend.name)
}
}
다음의 방법도 있던데 remember를 사용해서 리컴포지션때 람다 객체가 매번 생성되는걸 방지하려고 한 것같다. (https://medium.com/@sivavishnu0705/trailing-lambdas-in-kotlin-the-interview-question-that-separates-syntax-from-understanding-128cb6650de5)
val onUserClick = remember(user.id) { { viewModel.select(user) } }
UserRow(user, onClick = onUserClick)
스크롤 1픽셀 단위의 빈번한 상태 변경 하는 경우에는 불필요한 전체 리컴포지션을 차단하고, 조건 결과(Boolean)가 변경될 때만 리컴포지션을 발생시키기.
val listState = rememberLazyListState()
// 1픽셀마다 실행되지 않고, 임계값을 넘는 순간(true/false 변경 시)에만 recompose
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
if (showScrollToTop) {
ScrollToTopButton(onClick = { /* scrollToItem(0) */ })
}
스크롤 변화에 따른 투명도, 위치 변경 시 Composition 단계를 건너뛰고 Layout/Draw 단계에서 직접 연산하도록 처리합니다.
// Composition을 건너뛰고 Draw/Layout 단계에서만 렌더링
Box(
modifier = Modifier.graphicsLayer {
alpha = if (isScrolling) 0.5f else 1.0f
translationY = scrollOffsetPx.toFloat()
}
)
컴포즈의 3단계 렌더링 파이프라인 덕분에 '리컴포지션(Composition)'을 아예 거치지 않고도 화면의 투명도나 위치를 바꿀 수 있다고 한다!
1. Composition (컴포지션)
"무엇을 그릴지 결정"
↓
2. Layout (레이아웃)
"어디에, 얼마나 크게 배치할지"
↓
3. Draw (그리기)
"실제 화면 픽셀에 그리기"
// scrollAlpha 값이 바뀔 때마다 Composable 함수 본문이 다시 실행됨 (1 -> 2 -> 3단계 모두 실행)
Box(modifier = Modifier.alpha(scrollAlpha))
// scrollOffset이 바뀔 때마다 전체 Composable이 리컴포지션됨
Box(modifier = Modifier.offset(y = scrollOffset.dp))
// 1단계, 2단계를 건너뛰고 오직 3단계(Draw)에서 GPU 렌더링 값만 갱신
Box(
modifier = Modifier.graphicsLayer {
alpha = scrollAlpha // 람다 내부에서 읽으므로 Draw 단계에서만 평가됨
}
)
// Layout 단계에서만 위치를 계산하거나
Box(modifier = Modifier.offset { IntOffset(0, scrollOffset) })
// 또는 Draw 단계에서 그래픽 레이어 좌표만 이동 (가장 빠름)
Box(modifier = Modifier.graphicsLayer { translationY = scrollOffset.toFloat() })
상태(State) 값을 Composable 함수의 본문(인자 전달부)에서 직접 읽으면 컴포즈는 "UI 구조가 바뀌었을 수 있다"고 보고 함수 전체를 다시 실행(Recomposition)하게 된다.
반면, graphicsLayer { ... }나 offset { ... }처럼 람다 블록 안에서 상태를 읽으면, 컴포즈가 이를 감지하여 Composition 단계를 건너뛰고 해당 람다가 실행되는 단계(Layout/Draw)만 타겟팅해서 갱신하기 때문에 프레임 드랍 없이 60fps/120fps의 부드러운 스크롤 애니메이션을 유지할 수 있다고 한다...!
날짜 포맷팅이나 문자열 가공 등 무거운 연산은 remember로 캐싱하고, 이미지는 지정된 크기로 비동기 다운샘플링하여 메인 스레드 병목을 없애기.
@Composable
fun FriendItem(friend: Friend) {
// 날짜 포맷팅 등 비용이 드는 연산 캐싱
val formattedDate = remember(friend.updatedAt) {
DateTimeFormatter.ofPattern("yyyy-MM-dd").format(friend.updatedAt)
}
Row {
// Coil 비동기 이미지 로딩 및 고정 크기 다운샘플링
AsyncImage(
model = friend.profileUrl,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Text(text = "$formattedDate - ${friend.name}")
}
}
key값 설정하면 충분할 줄 알았는데 방법이 많구나