Compose 성능

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

Jetpack Compose의 목표는 처음부터 우수한 성능을 제공하는 것입니다. 이 페이지에서는 최고의 성능을 위해 앱을 작성하고 구성하는 방법과 피해야 할 패턴에 관해 설명합니다.

이 내용을 읽기 전에 Compose 이해에서 핵심 Compose 개념을 숙지하는 것이 좋습니다.

적절하게 앱 구성

앱의 성능이 좋지 않으면 구성 문제가 있을 수 있습니다. 먼저 다음 구성 옵션을 확인하는 것이 좋습니다.

출시 모드에서 빌드 및 R8 사용

성능 문제를 발견하면 앱을 출시 모드로 실행해 보세요. 디버그 모드는 여러 문제를 발견하는 데 유용하지만 상당한 성능 비용이 발생하며 성능을 저하할 수 있는 다른 코드 문제를 발견하기가 어려워질 수 있습니다. 또한 R8 컴파일러를 사용하여 앱에서 불필요한 코드를 삭제해야 합니다. 기본적으로 출시 모드에서 빌드하면 자동으로 R8 컴파일러가 사용됩니다.

기준 프로필 사용

Compose는 Android 플랫폼의 일부가 아닌 라이브러리로 배포됩니다. 이 접근 방식을 사용하면 Compose를 자주 업데이트하고 이전 Android 버전을 지원할 수 있습니다. 그러나 Compose를 라이브러리로 배포하면 비용이 발생합니다. Android 플랫폼 코드는 이미 컴파일되어 기기에 설치되어 있습니다. 반면 라이브러리는 앱이 실행될 때 로드되고 기능이 필요할 때 just-in-time 방식으로 해석되어야 합니다. 이로 인해 시작 시 그리고 라이브러리 기능을 처음 사용할 때마다 앱의 속도가 느려질 수 있습니다.

기준 프로필을 정의하여 성능을 개선할 수 있습니다. 이러한 프로필은 중요한 사용자 여정에 필요한 클래스와 메서드를 정의하고 앱의 APK와 함께 배포됩니다. 앱 설치 중에 ART는 중요한 코드를 ahead-of-time 방식으로 컴파일하므로 앱이 실행될 때 사용할 수 있습니다.

적절한 기준 프로필을 정의하는 것이 어려울 수도 있으므로 기본적으로 Compose는 기준 프로필과 함께 제공됩니다. 이러한 이점을 위해 별도의 작업을 하지 않아도 될 수도 있습니다. 그러나 직접 프로필을 정의하려는 경우 앱의 성능을 실제로 향상하지 않는 프로필을 생성할 수도 있습니다. 프로필을 테스트하여 도움이 되는지 확인해야 합니다. 이를 위한 좋은 방법은 앱의 Macrobenchmark 테스트를 작성하여 기준 프로필을 작성하고 수정할 때 테스트 결과를 확인하는 것입니다. Compose UI의 Macrobenchmark 테스트를 작성하는 방법에 관한 예는 Macrobenchmark Compose 샘플을 참고하세요.

출시 모드, R8 및 기준 프로필의 효과에 관한 자세한 내용은 블로그 게시물 Compose 성능을 항상 출시에서 테스트해야 하는 이유는 무엇인가요?를 참고하세요.

3가지 Compose 단계가 성능에 미치는 영향

Jetpack Compose 단계에서 설명한 대로 Compose에서 프레임을 업데이트하면 다음 세 단계를 거칩니다.

  • 컴포지션: Compose가 표시할 항목을 결정합니다. 구성 가능한 함수를 실행하고 UI 트리를 빌드합니다.
  • 레이아웃: Compose가 UI 트리에 있는 각 요소의 크기와 배치를 결정합니다.
  • 그리기: Compose가 실제로 개별 UI 요소를 렌더링합니다.

Compose는 필요하지 않으면 이러한 단계를 지능적으로 건너뛸 수 있습니다. 예를 들어 단일 그래픽 요소가 크기가 같은 두 아이콘 간에 전환된다고 가정해 보겠습니다. 이 요소는 크기가 변경되지 않고 UI 트리의 요소가 추가되거나 삭제되지 않으므로 Compose는 컴포지션 단계와 레이아웃 단계를 건너뛰고 이 요소 하나만 다시 그릴 수 있습니다.

그러나 일부 코딩 실수로 인해 Compose가 안전하게 건너뛸 수 있는 단계를 파악하기 어려울 수 있습니다. 확실하지 않은 경우 Compose는 세 단계를 모두 실행하게 되며 이로 인해 UI가 필요 이상으로 느려질 수 있습니다. 따라서 성능 권장사항은 대부분 Compose가 필요하지 않은 단계를 건너뛰도록 돕는 데 중점을 둡니다.

몇 가지 광범위한 원칙을 따르면 일반적으로 성능을 개선할 수 있습니다.

첫째, 가능하면 구성 가능한 함수 외부로 계산을 이동합니다. 구성 가능한 함수는 UI가 변경될 때마다 다시 실행해야 할 수 있습니다. 컴포저블에 넣은 모든 코드는 잠재적으로 애니메이션의 모든 프레임에서 다시 실행됩니다. 따라서 컴포저블의 코드를 UI를 빌드하는 데 실제로 필요한 것으로만 제한해야 합니다.

둘째, 최대한 오랫동안 상태 읽기를 연기합니다. 상태 읽기를 하위 컴포저블 또는 이후 단계로 이동하면 재구성을 최소화하거나 컴포지션 단계를 완전히 건너뛸 수 있습니다. 자주 변경되는 상태의 상태 값 대신 람다 함수를 전달하고, 자주 변경되는 상태를 전달할 때 람다 기반 수정자를 기본으로 선택하여 이를 실행할 수 있습니다. 이 기법의 예는 최대한 읽기 연기 섹션을 참고하세요.

다음 섹션에서는 이러한 종류의 문제를 일으킬 수 있는 구체적인 코드 오류를 설명합니다. 여기서 다루는 구체적인 예가 기타 유사한 오류를 코드에서 발견하는 데 도움이 되기를 바랍니다.

도구를 사용해 문제 발견

성능 문제가 발생한 위치와 최적화를 시작할 코드를 파악하기는 쉽지 않습니다. 도구를 사용하면 문제 발생 위치를 좁힐 수 있습니다.

재구성 횟수 가져오기

Layout Inspector를 사용하여 컴포저블이 재구성되거나 건너뛰는 빈도를 확인할 수 있습니다.

Layout Inspector에 표시되는 재구성 횟수

자세한 내용은 도구 섹션을 참고하세요.

권장사항 준수

발생할 수 있는 일반적인 Compose 실수는 다음과 같습니다. 이러한 실수로 인해 코드가 잘 실행되는 것처럼 보일 수 있지만 UI 성능을 저하할 수 있습니다. 이 섹션에는 이러한 문제를 방지할 수 있는 권장사항이 나열되어 있습니다.

remember를 사용하여 비용이 많이 드는 계산 최소화

구성 가능한 함수는 애니메이션의 모든 프레임만큼 매우 자주 실행될 수 있습니다. 따라서 컴포저블의 본문에서 최대한 적은 계산을 실행해야 합니다.

중요한 기법은 remember를 사용하여 계산 결과를 저장하는 것입니다. 이렇게 하면 계산이 한 번 실행되고 필요할 때마다 결과를 가져올 수 있습니다.

예를 들어 다음은 정렬된 이름 목록을 표시하지만 단순하게 매우 비용이 많이 드는 방식으로 정렬을 실행하는 코드입니다.

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

여기서 문제는 ContactsList가 재구성될 때마다 목록이 변경되지 않았더라도 전체 연락처 목록이 완전히 다시 정렬된다는 점입니다. 사용자가 목록을 스크롤하면 새 행이 표시될 때마다 컴포저블이 재구성됩니다.

이 문제를 해결하려면 목록을 LazyColumn 외부에서 정렬하고 정렬된 목록을 remember를 사용하여 저장합니다.

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

이제 ContactList가 처음 구성될 때 목록이 한 번 정렬됩니다. 연락처나 비교 연산자가 변경되면 정렬된 목록이 다시 생성됩니다. 그 외에는 컴포저블이 캐시된 정렬 목록을 계속 사용할 수 있습니다.

지연 레이아웃 키 사용

지연 레이아웃은 항목을 지능적으로 재사용하기 위해 최선을 다하므로 필요한 경우에만 항목을 재생성하거나 재구성합니다. 하지만 최선의 결정을 내리도록 개발자가 도움을 줄 수 있습니다.

사용자 작업으로 인해 항목이 목록에서 이동한다고 가정해 보겠습니다. 예를 들어 수정 시간을 기준으로 정렬된 메모 목록을 표시하는데 가장 최근에 수정된 메모가 맨 위에 온다고 가정해 보세요.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

하지만 이 코드에는 문제가 있습니다. 하단 메모가 변경된다고 가정해 보세요. 이 메모는 이제 가장 최근에 수정되었으므로 목록의 맨 위로 이동하고 다른 모든 메모는 한 스팟 아래로 이동합니다.

여기서 문제는 개발자의 도움이 없으면 Compose가 변경되지 않은 항목이 목록에서 이동된다는 것을 인식하지 못한다는 점입니다. 대신 Compose는 이전 '항목 2'가 삭제되고 새 항목이 생성된 것으로 간주하며 항목 3과 항목 4, 나머지 항목의 경우에도 마찬가지입니다. 결과적으로 Compose는 실제로 변경된 항목은 하나뿐이지만 목록의 모든 항목을 재구성합니다.

이 문제를 해결하는 방법은 항목 키를 제공하는 것입니다. 각 항목에 안정적인 키를 제공하면 Compose가 불필요한 재구성을 피할 수 있습니다. 이 경우 Compose는 이제 스팟 3에 있는 항목이 스팟 2에 있던 항목과 동일하다는 것을 인식할 수 있습니다. 이 항목의 데이터는 변경되지 않았으므로 Compose는 항목을 재구성할 필요가 없습니다.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

derivedStateOf를 사용하여 재구성 제한

컴포지션에서 상태를 사용할 때의 한 가지 위험은 상태가 빠르게 변경되면 UI가 필요 이상으로 재구성될 수 있다는 점입니다. 예를 들어 스크롤 가능한 목록을 표시한다고 가정해 보겠습니다. 목록의 상태를 검사하여 목록에서 가장 먼저 표시되는 항목이 무엇인지 확인합니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

여기서 문제는 사용자가 목록을 스크롤하면 사용자가 손가락을 드래그할 때 listState가 계속 변경된다는 점입니다. 즉, 목록이 계속 재구성됩니다. 그러나 실제로는 이렇게 자주 재구성할 필요가 없습니다. 새 항목이 하단에 표시될 때까지 재구성하지 않아도 됩니다. 따라서 많은 추가 계산으로 인해 UI 성능이 저하됩니다.

해결 방법은 파생 상태를 사용하는 것입니다. 파생 상태를 사용하면 실제로 재구성을 트리거해야 하는 상태 변경을 Compose에 알릴 수 있습니다. 여기서는 첫 번째로 표시되는 항목이 변경될 때 중요한 사항을 지정합니다. 해당 상태 값이 변경되면 UI를 재구성해야 하지만 사용자가 아직 새 항목을 맨 위로 가져올 만큼 충분히 스크롤하지 않았다면 재구성하지 않아도 됩니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

최대한 오래 읽기 연기

상태 변수 읽기는 최대한 오래 연기해야 합니다. 상태 읽기를 연기하면 Compose가 재구성 시 가능한 최소 코드를 다시 실행하도록 할 수 있습니다. 예를 들어 UI에 컴포저블 트리에서 위로 높게 끌어올린 상태가 있고 개발자는 하위 컴포저블의 상태를 읽는 경우 람다 함수에서 상태 읽기를 래핑할 수 있습니다. 이렇게 하면 실제로 필요할 때만 읽기가 발생합니다. 이 접근 방식을 Jetsnack 샘플 앱에 적용한 방법을 확인할 수 있습니다. Jetsnack은 세부정보 화면에 접기 방식 툴바와 같은 효과를 구현합니다.

이 효과를 얻으려면 Title 컴포저블에서 Modifier를 사용하여 자체적으로 오프셋하기 위해 스크롤 오프셋을 알아야 합니다. 다음은 최적화가 실행되기 전 단순화된 버전의 Jetsnack 코드입니다.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

스크롤 상태가 변경되면 Compose는 가장 가까운 상위 재구성 범위를 찾아 무효화합니다. 여기서 가장 가까운 상위 요소는 Box 컴포저블입니다. 따라서 Compose는 Box를 재구성하고 Box 내부의 컴포저블도 모두 재구성합니다. 실제로 사용하는 상태만 읽도록 코드를 변경하면 재구성해야 하는 요소의 수를 줄일 수 있습니다.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

이제 스크롤 매개변수는 람다입니다. 즉, Title은 여전히 끌어올린 상태를 참조할 수 있지만 값은 실제로 필요한 Title 내부에서만 읽힙니다. 따라서 스크롤 값이 변경되면 가장 가까운 재구성 범위가 이제 Title 컴포저블입니다. Compose는 더 이상 전체 Box를 재구성할 필요가 없습니다.

이로써 많이 개선되었지만 더 개선할 수 있습니다. 단지 컴포저블을 다시 배치하거나 다시 그리기 위해 재구성을 발생시키는지 의심스러울 것입니다. 이 경우에는 레이아웃 단계에서 할 수 있는 Title 컴포저블의 오프셋을 변경하기만 하면 됩니다.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
      // ...
    }
}

이전에는 오프셋을 매개변수로 사용하는 Modifier.offset(x: Dp, y: Dp)을 코드에서 사용했습니다. 람다 버전의 수정자로 전환하면 함수가 레이아웃 단계에서 스크롤 상태를 읽도록 할 수 있습니다. 따라서 스크롤 상태가 변경되면 Compose는 컴포지션 단계를 완전히 건너뛰고 레이아웃 단계로 바로 이동할 수 있습니다. 자주 변경되는 상태 변수를 수정자에 전달하는 경우 가능하면 람다 버전의 수정자를 사용해야 합니다.

다음은 이 접근 방식의 또 다른 예입니다. 이 코드는 아직 최적화되지 않았습니다.

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

여기에서 상자의 배경색은 두 색상 간에 빠르게 전환됩니다. 따라서 이 상태는 매우 자주 변경됩니다. 그러면 컴포저블이 백그라운드 수정자에서 이 상태를 읽습니다. 결과적으로 상자는 모든 프레임에서 재구성되어야 합니다. 색상이 모든 프레임에서 변경되기 때문입니다.

이를 개선하기 위해 람다 기반 수정자(여기서는 drawBehind)를 사용할 수 있습니다. 즉, 그리기 단계에서만 색상 상태를 읽습니다. 따라서 Compose는 컴포지션 및 레이아웃 단계를 완전히 건너뛸 수 있습니다. 색상이 변경되면 Compose는 그리기 단계로 바로 이동합니다.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

역방향 쓰기 방지

Compose에는 개발자가 이미 읽힌 상태에 쓰지 않는다는 핵심 가정이 있습니다. 쓰는 경우 역방향 쓰기라고 하며 모든 프레임에서 재구성이 끝없이 발생할 수 있습니다.

다음 컴포저블은 이러한 종류의 실수를 보여주는 예입니다.

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

이 코드는 위 줄에서 읽은 후 컴포저블 끝에 개수를 업데이트합니다. 이 코드를 실행하면 재구성을 유발하는 버튼을 클릭한 후 개수가 무한 루프로 빠르게 증가하는 것을 확인할 수 있습니다. Compose가 이 컴포저블을 재구성하고 오래된 상태 읽기를 확인하므로 또 다른 재구성을 예약하기 때문입니다.

컴포지션에서 상태에 쓰지 않음으로써 역방향 쓰기를 완전히 방지할 수 있습니다. 가능하다면 이전의 onClick 예와 같이 이벤트에 대한 응답으로 그리고 람다에서 상태에 씁니다.