도구 및 실적

Compose에서 더 나은 성과 달성하기

스피커

정보

강연 소개

Jetpack Compose를 사용하면 고품질 사용자 인터페이스를 쉽게 디자인할 수 있지만 UI 성능에 영향을 미치는 문제를 추적하기는 어려울 수 있습니다. 이러한 문제는 시작 지연 시간, 스크롤 버벅거림 또는 끊기는 애니메이션으로 나타나는 프레임 드롭과 같이 사용자가 알아차릴 수 있는 문제를 일으킬 수 있습니다.

이 세션에서는 Perfetto와 (재)컴포지션 추적에 중점을 두고 UI 성능에 영향을 미치는 요소를 파악하는 도구를 소개합니다. 그런 다음, 사용 중일 수 있는 특정 안티패턴이 어떻게 은밀하게 비용을 발생시키는지, 코드에서 이를 어떻게 찾을 수 있는지, 어떤 긍정적인 패턴으로 대체할 수 있는지 살펴보겠습니다. 이 강연을 마치면 성능을 고려하여 Compose에서 생각하는 방법과 앱에서 성능 최적화를 프로파일링, 분석, 확인하는 방법을 이해할 수 있습니다.

요약

  • Perfetto 트레이스는 기기 내 활동을 기록하고 기본 스레드를 따라 컴포지션 및 레이아웃과 같은 Compose 슬라이스를 표시합니다. 이를 사용하여 프레임 타임라인을 검사하고 단계 기간을 추적합니다.
  • 코루틴 실행 및 취소를 포함하여 컴포저블 내부의 모든 작업에는 비용이 발생합니다. 부작용 API를 감사하고 동일한 키를 가진 효과를 병합하여 컴포지션 오버헤드를 줄이는 것이 좋습니다.
  • 컴포지션 트레이싱은 구성 가능한 함수에 주석을 달고, 리컴포지션 트레이싱 (1.13 이상)은 리컴포지션을 일으키는 상태 읽기 및 쓰기를 추적합니다. Perfetto 트레이스 내에서 직접 예기치 않은 작업을 진단하는 데 도움이 됩니다.
  • ComputedState (Compose 1.13 이상)는 원치 않는 무효화를 방지하며, 일반적으로 쓰기 후 읽기 무효화 중에 DerivedState보다 빠릅니다. DerivedState는 비용이 많이 드는 계산이나 할당이 캐싱의 이점을 누릴 때 유용합니다.
  • 컴포저블을 건너뛰면 유지관리 비용과 매개변수 비교 오버헤드가 발생합니다. 안정성을 강제하는 대신 강력한 건너뛰기 및 객체 ID 동등성에 의존하거나 무효화를 완전히 방지합니다.

특기

리소스

Gemini Notebook

주제

  • jetpack-compose
  • android-performance
  • perfetto
  • 벤치마킹
  • 코루틴
  • composition-tracing
  • android-cli

스크립트

소개

  • 안녕하세요.

  • 안녕하세요.

  • 즐거운 휴식 되셨기를 바랍니다.

  • 커피도 마시고, 수분도 보충하고, 다들 준비되셨나요?

  • 오늘은 조금 더 자세히 살펴보겠습니다.

  • 네, 여기 있습니다.

  • 오늘은 조금 더 자세히 살펴보겠습니다.

  • 저는 안드레이 시코프입니다. 오늘 앤드루 베일리와 함께 무대에 섰습니다.

  • 저희는 Compose 컴파일러 런타임팀에서 일하고 있으며 성능을 비롯한 다양한 업무를 담당하고 있습니다.

  • 오늘 강연의 주제는 성능에 관한 질문에 답하는 것입니다. Jetpack Compose가 Android 개발을 위한 기본 UI 도구 키트로 자리 잡았기 때문입니다.

  • 이제 많은 분들이 Compose UI의 성능을 최대한 높이는 방법을 궁금해하십니다.

  • 그래서 오늘은 앞서 말씀드린 것처럼 심층 분석을 진행할 예정입니다.

  • 또한 Compose에서 더 나은 성과를 거둘 수 있는 몇 가지 도구도 소개하겠습니다.

  • 먼저 성능이 왜 중요한지 이야기해 보겠습니다.

  • 품질과 우수한 앱을 만드는 것에 대해 생각할 때 보통 애니메이션을 생각하고, 풍부한 기능 세트를 생각하고, 성능은 때때로 간과되지만, 사용자의 인식은 실제로 앱의 성능에 달려 있습니다.

  • 따라서 애니메이션이 끊기면 인식이 즉시 저하됩니다.

  • 따라서 오늘 언급할 모든 측정항목과 수치는 실제로 사용자가 앱에 대해 어떻게 느끼는지, 모든 애니메이션과 화면에서 일어나는 모든 일에 대해 어떻게 느끼는지를 나타내기 위해 선택한 대리 측정항목입니다.

  • 따라서 기본적으로 앱을 최적화하는 것은 더 나은 환경을 위해 최적화하는 것입니다.

  • 오늘 여러 주제를 심도 있게 다루는 만큼 이러한 마음가짐을 가지시기 바랍니다.

  • 이제 앱의 성능을 살펴볼 때 세 가지를 빠르게 수행해 보시기 바랍니다.

  • 먼저, 성능을 측정할 때는 출시 모드에서 측정하므로 디버그 가능한 정보가 많지 않고 이로 인해 앱이 느려집니다.

  • 두 번째는 R8 또는 다른 축소 도구를 사용 설정하는 것입니다.

  • 올해에는 코루틴 성능을 두 배로 개선하는 최적화 기능을 도입했으며 그 외에도 다양한 작업을 진행하고 있습니다.

  • Compose를 최적화할 때는 R8을 많이 사용합니다.

  • 마지막으로, 특히 시작 측정항목의 경우 기준 프로필을 사용해야 합니다.

  • 따라서 시작 시 로드하는 클래스는 앱이 시작될 때 미리 컴파일되고 미리 로드됩니다.

  • 보시다시피 일반적인 디버그 가능 앱 빌드와 비교하면 6배 정도 차이가 납니다.

  • 이러한 모든 요소를 측정하면 코드 내에서 수행하는 다른 성과와 결합됩니다.

  • 이 모든 작업을 수행했는데도 앱이 여전히 약간 지연되거나 성능에 다른 문제가 있는 경우 이 성능 루프를 사용해야 합니다.

  • 특정 시나리오에 대한 측정항목을 살펴보고 싶습니다.

  • 예를 들어 앱을 로드하는 데 시간이 너무 오래 걸린다고 가정해 보겠습니다. 이 경우 앱을 재설정하고 일반 런처에서와 같이 앱을 시작한 다음 앱이 첫 번째 데이터 화면에 도달하는 데 걸리는 시간을 확인하는 것이 좋습니다.

  • 또는 앱이 약간 끊기는 경우 스크롤하여 이 스크롤이 반복 가능하고 안정적인지 확인합니다.

  • 이 슬라이드에서 안정적인 환경은 매우 중요합니다. 나중에 최적화를 진행할 때 측정항목이 개선되는지 확인하고 최적화가 실제로 의도한 대로 작동하는지 확인할 수 있기 때문입니다.

  • 벤치마크는 이미 많은 데이터를 캡처하고, 기기에 노이즈가 많지 않도록 여러 번 반복하며, 도움이 되는 다양한 기기 설정을 수행하므로 이 작업에 매우 적합합니다.

  • 또한 벤치마크는 기본적으로 반복 가능한 시나리오이며, 이것이 바로 우리가 여기서 달성하고자 하는 목표입니다.

  • 하지만 원한다면 여러 스크립트를 사용해도 됩니다.

  • 마지막으로, 프로파일링하고 앱에서 무언가를 개선하고 데이터를 살펴본 다음 다시 반복하고, 앱이 어려움을 겪는 다른 시나리오 측정항목으로 다시 반복할 수 있습니다.

Perfetto 트레이스를 사용한 프로파일링

  • 데이터를 살펴볼 때 오늘 소개하는 첫 번째 도구이자 여러분이 정말 잘 알고 있어야 하는 도구는 Perfetto trace입니다.

  • Perfetto 트레이스는 트레이스를 기록하는 동안 기기에서 발생하는 모든 것을 기록합니다. 예를 들어 Macrobenchmark 실행 중에 또는 기록 중인 일부 스크립트 중에 기록합니다.

  • Android 성능을 개선하는 모든 엔지니어에게 큰 도움이 됩니다.

  • 저는 요즘 하루 업무 시간의 대부분을 Perfetto 트레이스를 살펴보는 데 보냅니다.

  • 다음과 같이 표시됩니다.

  • 이는 엄청난 양의 데이터입니다.

  • 앞서 말씀드렸듯이 기기에서 발생하는 모든 것을 캡처합니다.

  • 따라서 처음에는 앱이 정확히 어디에 있는지 파악하는 데 어려움을 겪을 수 있으므로 이 편리한 검색창을 사용하는 것이 좋습니다.

  • Compose를 검색하고 앱 패키지를 검색하는 등의 작업을 수행합니다.

  • 익숙한 것들에 집중해 보세요.

  • 기본을 다지고 앱 프로세스를 파악하면 프레임 시간 선과 앱의 여러 작업에 걸리는 시간을 확인할 수 있습니다.

  • 그중 하나를 분리하면 다음과 같이 표시됩니다.

  • 첫 번째는 기본 스레드 작업입니다.

  • 여기에서 Compose는 대부분의 작업을 실행합니다.

  • 다음은 RenderThread입니다.

  • 여기에서 캔버스 명령어를 처리하고 GPU로 인코딩합니다.

  • 그런 다음 GPU가 작업을 수행하고 시스템이 이를 합성합니다.

  • 오늘은 기본 스레드만 살펴보겠습니다.

  • Compose가 작업을 실행하는 곳이기 때문입니다.

  • Compose에 관해 이야기하고 있으므로 Compose에만 집중하는 것이 합리적입니다.

  • 기본 스레드에서 해당 프레임을 분리하면 이제 Compose 슬라이스가 표시되기 시작합니다.

  • 몇 가지를 선택해 봤습니다.

  • 컴포지션, 측정, 레이아웃, 그리기와 같습니다.

  • Compose 단계에 관해 들어본 적이 있다면 이전에 성능을 살펴볼 때 이를 확인했을 것입니다.

  • Perfetto 트레이스에 개별 슬라이스가 있으므로 각 단계에 걸리는 시간과 각 프레임에서 실행되는 단계를 쉽게 확인할 수 있습니다.

  • 지금까지 이 작업 중 일부를 최적화하는 방법에 대해 길게 이야기했는데, 적절한 단계에서 적절한 작업을 수행해야 합니다.

  • 예를 들어 상자의 색상을 업데이트하는 애니메이션이 있는 경우 그리기 단계만 무효화하고 컴포지션과 레이아웃 등은 무효화하지 않아야 합니다.

  • 이것이 성능의 기본입니다. 오늘은 좀 더 깊이 있게 살펴보겠습니다. 특히 컴포지션에 초점을 맞추겠습니다.

  • 이제 앤드루가 이 내용을 설명해 드리겠습니다.

  • 안드레이님, 감사합니다.

  • 안드레이가 언급했듯이, 저희는 이전에 성능과 관련하여 역방향 읽기를 피하고, 컴포지션 수명 주기의 올바른 단계로 상태 읽기를 지연하고, 최적화하고, 컴포저블이 예상대로 무효화되도록 하여 모든 프레임에서 너무 많은 작업을 수행하지 않도록 하는 등의 최적화에 대해 많은 시간을 할애하여 이야기했습니다.

  • 이것은 정말 좋은 출발점이지만, 우리는 이미 이에 대한 많은 자료를 가지고 있으며, 우리는 그 대화를 조금 더 발전시키고 싶습니다.

  • 따라서 오늘은 이러한 최적화를 수행한 후 컴포지션에 남아 있는 작업을 처리하고, 컴포저블 자체가 수행하는 작업을 처리해 보겠습니다.

  • Compose 코드 내의 모든 항목과 성능 측면에서 앱과 함께 제공하는 모든 항목에는 비용이 수반됩니다.

  • 앱에서 제공하는 코드는 모두 비용이 들며, 상태를 읽고 컴포지션 내에서 부작용과 같은 항목을 구성하는 것과 같은 사소한 작업도 실질적인 비용이 발생합니다. 특히 컴포저블이 실제로 하는 일을 분석하고 앱 자체에서 더 많은 성능을 끌어내기 시작할 때는 이러한 비용을 염두에 두는 것이 중요합니다.

  • 가장 미시적인 규모에서 실제로 어떤 모습인지 보여주기 위해 코드의 어느 곳에서나, 특히 컴포저블 내부에서 수행할 수 있는 가장 작은 작업 중 일부를 소개합니다. 이 해상도에서는 이러한 작업의 대략적인 비용을 나노초 단위로 생각할 수 있습니다.

  • 실적을 개선하려면 두 가지 옵션이 있습니다.

  • 비용이 적게 드는 작업을 아주 많이 삭제하거나 비용이 많이 드는 작업을 적게 삭제할 수 있습니다.

  • 특히 이 차트에서 코루틴은 이러한 작업 중 비용이 더 많이 드는 편에 속하며, 이러한 인스턴스를 몇 개 삭제하면 애플리케이션에서 더 가시적인 이점을 더 빠르게 확인할 수 있으므로 어디서부터 살펴봐야 할지 보여주는 좋은 예가 됩니다.

  • 코루틴과 관련해 염두에 두어야 할 또 다른 사항은 슬라이드에 나와 있듯이 코루틴을 실행하는 데 비용이 들 뿐만 아니라 코루틴을 취소하는 데도 비용이 든다는 것입니다.

  • 취소 비용은 콘텐츠를 삭제하고 애플리케이션 내의 다른 영역으로 이동할 때 주로 발생하므로 특히 주의해야 합니다.

  • 다른 화면으로 이동하는 것은 프레임 시간을 최대한 활용하고 보유한 리소스를 최대한 활용해야 하는 많은 애플리케이션에서 매우 비용이 많이 드는 작업입니다.

  • 따라서 이 두 가지 비용을 모두 염두에 두는 것이 중요합니다.

  • 이 모든 것은 무엇을 보든 약 1,600만 나노초의 규모로 합산됩니다.

  • 따라서 여기에는 조금씩 줄여야 하는 예산이 있으며 코루틴은 시작하기에 좋은 예입니다.

부작용 관리

  • Compose에서 코루틴은 효과 API와 함께 가장 자주 표시됩니다.

  • SideEffects와 함께 사용할 수 있는 다양한 API가 있으며, 높은 기능성부터 짧은 지연 시간까지 다양한 특성을 가지고 있습니다.

  • 이 스펙트럼에서 가장 기능적인 측면에는 LaunchedEffect가 있습니다. 여기에는 비동기 작업에 필요한 모든 것, 특히 애니메이션과 같이 코루틴 범위가 필요한 모든 것이 포함됩니다.

  • 하지만 이 기능이 필요하지 않고 더 저렴한 API로 작업을 처리할 수 있다면 사용 사례의 요구사항에 맞는 경우 대신 사용하는 것이 좋습니다.

  • 실제 모습을 보여드리기 위해 여기서는 다양한 SideEffect와 이러한 효과가 나타날 수 있는 시점을 보여주는 미디어 플레이어 컴포저블을 사용했습니다.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Asynchronous work and animations
        LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
    }
  • LaunchedEffect로 시작할 수 있습니다.

  • 이 예에서는 미디어 플레이어 내에서 데이터를 비동기식으로 로드하는 데 사용됩니다.

  • 코루틴 범위가 필요하거나 일반적으로 코루틴이 필요하거나 정지 호출을 해야 하는 경우 LaunchedEffect를 사용합니다.

  • 여전히 비용이 많이 들고 코루틴과 관련된 비용이 있지만, 이를 활용하고 정당화하는 한 작업에 적합한 API를 사용하세요.

  • 하지만 LaunchedEffect를 사용하고 있지만 코루틴을 활용하지 않는 상황이라면 동일한 작업을 더 적은 비용으로 수행할 수 있는 다른 API를 고려하는 것이 좋습니다.

  • 이를 위해 스펙트럼을 따라 내려가면 DisposableEffect와 같은 다른 부작용의 예를 볼 수 있습니다.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val activity = LocalActivity.current
    
        // Setup and teardown
        DisposableEffect(Unit) {
            activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
            onDispose {
                activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
            }
        }
    }
  • DisposableEffect는 효과와 관련된 설정 및 해제 프로세스가 있는 경우에 유용합니다.

  • 여기 동일한 미디어 플레이어 예시에서 컴포저블이 있을 때 활동의 방향을 가로 모드로 잠근 다음 나중에 사라지면 방향 잠금을 해제하는 예시가 있습니다.

  • LaunchedEffect 및 DisposableEffect의 일회성 효과를 원하지만 코루틴이 필요하지 않고 삭제 효과가 없는 경우 Compose 1.12에서 도입된 키가 있는 새로운 SideEffect API를 사용할 수도 있습니다.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // One-shot events
        SideEffect(Unit) { mediaPlayer.play() }
    }
  • 이는 다른 두 API의 대안으로 적합합니다. 편의상 LaunchedEffect를 사용하거나 일회성 효과를 위해 DisposableEffect를 사용한 경우 키 API를 사용하여 SideEffect로 이동하거나 이전하면 오버헤드가 줄고 성능이 개선된 상태에서 동일한 작업을 수행할 수 있습니다. 특히 전체 앱 기간에 걸쳐 여러 번 이전하는 경우에 그렇습니다.

  • 마지막으로 가장 저렴한 API는 기본 SideEffect API입니다.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Synchronize the outside world to the state of composition
        SideEffect { mediaPlayer.playbackRate = playbackRate }
    }
  • 이는 외부 상태를 컴포지션에서 진행 중인 작업과 동기화하려는 경우에 가장 적합합니다.

  • 미디어 플레이어 예시를 다시 살펴보겠습니다. 컴포지션에서 수신하는 매개변수와 재생 상태를 동기화하는 또 다른 예시입니다.

  • 한 가지 유의할 점은 부작용 사용을 감사하고 비용이 적게 드는 부작용으로 이전하기로 결정한 경우, 앞서 설명한 다른 동작과 사용 사례 외에도 부작용의 순서 보장에도 차이가 있다는 점입니다.

  • 모든 일회성 효과가 먼저 실행되고, 키 지정 여부와 관계없이 일반 부작용이 실행된 다음, 프레임이 완료되면 디스패치가 포함되어 있으므로 모든 실행된 효과가 실행됩니다.

  • 실행된 효과의 타이밍에 의존하는 경우 이전할 때 이 점을 염두에 두세요.

  • 마이그레이션 시에는 코드를 테스트하여 이러한 타이밍 보장에 의존하지 않는지, 이후에도 코드가 계속 작동하는지 확인하세요.

  • 부작용 자체의 복잡성을 줄일 수 없는 경우 부작용을 병합하여 부작용 수를 줄일 수도 있습니다.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) { viewModel.loadCart() }
        LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
        LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
    
        CartContent(...)
    }
  • 이는 동일한 키를 사용하는 효과가 여러 개 있는 경우 특히 고려해야 할 사항입니다.

  • 동시에 실행할 수 있으므로 병합을 고려하기에 적합합니다.

  • 이 예시에서는 여러 LaunchedEffect가 각각 일부 뷰 모델에 비동기 호출을 하고 있습니다.

  • 이를 단일 LaunchedEffect로 대체하고 그 안에 다른 실행 호출을 넣으면 실행 중인 코루틴 수는 동일하지만 컴포지션에서 작업을 가져와 코루틴에 넣기 때문에 오버헤드가 줄어듭니다.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) {
            launch { viewModel.loadCart() }
            launch { viewModel.loadPaymentMethods() }
            launch { viewModel.loadDeliveryAddresses() }
        }
    
        CartContent(...)
    }
  • 따라서 이와 같은 상황에서는 가능한 경우 효과의 수를 줄이기 위해 마이그레이션을 고려하는 것이 좋습니다.

  • 효과와 코루틴은 애플리케이션 내에서 감사할 수 있는 한 가지 예일 뿐이며, 모든 컴포저블과 모든 애플리케이션은 서로 다르며 내부에 서로 다른 기회가 있습니다.

  • 애플리케이션과 관련된 내용을 이해하려면 안드레이가 소개할 다음 도구인 컴포지션 추적에 관해 이야기해야 합니다.

컴포지션 및 리컴포지션 추적

  • 좋습니다.

  • 컴포지션 추적은 이전 슬라이드에서 살펴본 Perfetto 프레임을 충실하게 재현한 것으로, 다양한 단계의 타이밍 정보를 제공하지만 실제로 어떤 일이 일어나고 있는지에 대한 세부정보는 많이 제공하지 않습니다.

  • 내용

  • 네, 컴포지션 내에서 어떤 컴포저블 함수가 실행되는지입니다.

  • 컴포지션 추적이 이 작업을 도와줍니다.

  • 따라서 Compose 컴파일러에 기본적으로 모든 함수에 주석을 달고 Perfetto 트레이스에 표시하는 마커를 도입하여 각 함수에 걸리는 시간을 확인할 수 있습니다.

  • 이를 사용하여 재구성되는 정확한 함수를 파악할 수 있습니다.

  • 하지만 약간의 오버헤드가 있으므로 가장 정확한 실적 데이터를 캡처하려면 일반 Perfetto를 사용하거나 Perfetto 캡처 없이 실행하고 다른 측정항목을 사용하는 것이 좋습니다.

  • 하지만 컴포지션 추적은 각 개별 컴포저블에 걸리는 시간을 파악하려는 경우에 매우 유용합니다.

  • 때로는 재구성이 발생하는 이유를 파악해야 할 수도 있습니다. 이 문제는 다음 버전의 Compose Tracing 런타임 트레이싱(1.13)에서 해결하려고 합니다.

  • 이제 재구성 추적 기능이 도입되어 재구성을 초래한 모든 상태 읽기 및 상태 쓰기에 주석을 추가합니다.

  • 그러면 Perfetto 트레이스에 다음과 같은 내용이 표시됩니다. 각 상태 쓰기와 쓰기 및 읽기가 발생하는 호출 스택이 표시되므로 이러한 상태가 액세스되거나 수정된 코드의 실제 줄과 일치시킬 수 있습니다.

  • 자세한 내용은 이 링크를 참고하세요.

  • 여기에는 컴포지션 추적과 리컴포지션 추적에 관한 모든 문서가 포함되어 있습니다.

  • 이제 앤드루가 상태 쓰기를 방지하기 위해 이를 더 효율적으로 사용하는 방법을 설명하겠습니다.

상태 최적화 및 무효화

  • 안드레이님, 감사합니다.

  • 이 조사에서 찾은 결과를 바탕으로, 컴포지션에 애플리케이션에 유용하지 않고 처음부터 피할 수 있었던 무효화가 있음을 발견할 수 있습니다.

  • 예를 들어 가장 일반적인 예는 가시성 또는 스크롤 진행률에 따라 가시성이 달라지는 상단으로 스크롤 버튼입니다.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton = scrollState.value > 0
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • 여기서는 컴포지션 내에서 스크롤 상태 값을 읽고 있습니다. 이 상태는 매우 자주 변경되는 상태로, 프레임당 한 번씩 변경될 수 있습니다. 이로 인해 많은 리컴포지션이 발생할 수 있습니다. 스크롤 상태 자체는 변경되지만 여기서 중요한 실제 조건은 자주 변경되지 않으므로 이러한 리컴포지션은 방지할 수 있습니다.

  • 이것은 애플리케이션에 중요하지 않은 상태 변경에 대한 무효화를 방지할 수 있는 계산된 상태와 같은 것을 사용하는 것이 좋은 예입니다.

  • 이 예에서는 계산 자체를 계산된 상태 내에 래핑했습니다. 이 상태를 읽을 때 스크롤 위치 자체를 기반으로 무효화하는 대신 전체 조건을 기반으로 이 컴포저블을 조건부로 무효화하고 다른 상태 자체에서 파생합니다.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • 이 패턴을 이전에 본 적이 있다면 파생 상태와 함께 사용되는 것을 보았을 가능성이 높습니다.

  • ComputedState는 Compose 1.13에 새로 추가되었으며 ComputedState와 DerivedState는 모두 계산이 변경될 때 조건부로 무효화한다는 유사한 목표를 가지고 있지만, 이를 실행하는 방식에는 매우 미묘한 차이가 있습니다.

  • Compose 초기부터 제공된 DerivedState는 계산한 값을 캐시하고 종속 항목이 변경될 때까지 재사용합니다.

  • ComputedState를 사용하면 값을 읽을 때마다 값이 계산됩니다.

  • 캐싱은 유용할 수 있지만 실제로는 많은 상황에서 캐싱이 결과를 읽는 것보다 비용이 더 많이 듭니다.

  • 이러한 상황이 발생하는 방식을 이해하기 위해 애플리케이션 내에서 이러한 상태가 사용되는 방식의 예를 살펴보고 각 상태가 실제로 어떤 역할을 하는지 알아보겠습니다.

  • 여기에는 맨 위로 스크롤 버튼에서 했던 것과 거의 동일한 상태가 있습니다. 다른 상태를 기반으로 하는 조건이 있고 DerivedState와 ComputedState로 모두 있습니다.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    // First read
    derivedState.value
    computedState.value
    
    // Second read
    derivedState.value
    computedState.value
  • 또한 상태를 두 번 읽고 두 번 모두 동일한 값을 얻지만, 내부에서 진행되는 작업은 두 경우에 따라 다릅니다.

  • Perfetto와 같은 도구에서 실제로 표시되는 트레이스를 단순화하기 위해, 이 두 상태를 모두 읽을 때 실제로 어떤 일이 발생하는지 살펴보겠습니다.

  • 여기서는 computedState.value를 호출하는 것으로 시작합니다.

  • 계산 람다를 실행하여 상태 값을 읽고, 읽기가 발생했음을 알리는 약간의 유지보수 작업도 수행합니다.

  • 이는 Compose의 모든 스냅샷 상태에 적용됩니다.

  • 두 번째 읽기는 첫 번째 읽기와 정확히 동일하게 작동하며 전체적으로 동일한 성능 특성을 갖습니다.

  • 이에 비해 DerivedState의 첫 번째 읽기는 비슷한 작업을 수행합니다. 여전히 계산 람다를 호출해야 하고 다른 알림을 수행하지만 캐시 관리 오버헤드가 약간 있습니다. 확인할 캐시가 없기 때문에 처음에는 약간 단락됩니다.

  • 따라서 캐싱 자체를 약간 유지 관리한 다음 약간 더 많은 시간 내에 동일한 작업을 수행합니다.

  • 하지만 DerivedState의 두 번째 읽기는 ComputedState의 두 번째 읽기보다 실제로 더 빠릅니다. 둘 다 매우 간단한 상태인데도 말이죠.

  • 이전 슬라이드에서 말씀드렸듯이 스냅샷 읽기 비용은 경우에 따라 상당할 수 있으며 실제로 캐시에서 읽는 것이 읽기 자체를 실행하는 것보다 빠릅니다. 이 때문에 DerivedState의 두 번째 읽기 자체에서 약간의 이점이 있습니다.

  • 하지만 실제로 흥미로운 시나리오는 상태를 무효화해야 할 때 발생합니다.

  • 이를 쓰기 후 읽기 시나리오라고 하며, 계산에 사용되는 상태 중 하나를 업데이트하면 계산을 다시 실행해야 합니다.

  • 코드에서 이 작업은 a를 수정하고, 증가시킨 다음, 값을 다시 읽거나 재구성하는 것과 같습니다.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    ...
    
    // Read-after-write
    a++
    derivedState.value
    computedState.value
  • 실제 다이어그램에서 이 부분은 ComputedState와 완전히 동일합니다.

  • 관리할 캐시가 없으므로 첫 번째, 두 번째, 세 번째 등 어떤 읽기든 정확히 동일하게 보입니다.

  • 하지만 DerivedState는 앞으로 해야 할 일이 산더미 같습니다.

  • 캐시가 무효임을 확인하기 위해 매우 비용이 많이 드는 경로를 거친 후 계산 자체를 업데이트하고 호출합니다. 그런 다음 종속 항목 추적을 실행하고 많은 관찰을 관리하여 캐시 값을 다시 쓰고 이러한 모든 값이 실제로 변경되었음을 이해하고 유지 관리를 직접 관리해야 합니다.

  • 이 모든 것이 합쳐져 DerivedState를 무효화하는 비용이 ComputedState에 비해 매우 비쌉니다.

  • 이러한 주장을 수치로 표현하면 ComputedState는 일반적으로 첫 번째 읽기에서 약 200ns 더 빠릅니다.

  • ComputedState는 특정 벤치마킹 조건에서 Pixel 6에서 약 350ns만큼 손실됩니다.

  • 따라서 두 번째 읽기에서는 ComputedState가 DerivedState에 비해 약간 뒤처집니다.

  • 하지만 실제로 중요한 경우, 즉 무효화하는 경우와 프레임에서 실제로 작업해야 하는 경우 ComputedState가 DerivedState보다 거의 두 배 이상 빠릅니다.

  • 따라서 상태를 사용하는 용도에 따라 ComputedState가 가장 먼저 선택할 수 있는 좋은 옵션이 될 수 있습니다.

  • 일반적으로 DerivedState와 ComputedState를 사용할 때는 여전히 결정을 내려야 합니다.

  • ComputedState는 지원 중단되지 않으며, 둘 다 계속 사용됩니다.

  • 일반적으로 어떤 것을 선택해야 할지 잘 모르겠다면 계산된 상태가 좋은 기본값입니다.

  • 파생 상태는 실제로 캐싱을 활용할 때 가장 유용합니다. 즉, 복잡한 알고리즘, 대규모 할당, 캐싱의 이점을 실제로 누리는 내부의 많은 상태 읽기, 사소하지 않은 작업, 객체 동등성 및 객체 ID 동등성과 같은 항목에 따라 달라지는 작업 등 사소하지 않은 작업을 수행하는 경우 차이를 보완하고 파생 상태가 제공하는 캐싱의 이점을 누릴 수 있습니다.

Compose의 건너뛰기 및 안정성

  • 또 다른 중요한 문제는 파생 상태와 계산된 상태 모두 컴포저블 자체를 무효화하지 않으려고 한다는 점입니다.

  • 이는 컴포저블을 건너뛰는 것과는 다릅니다. 컴포저블을 건너뛰는 것은 일반적으로 조건부로 무효화하거나 컴포저블 내에서 작업을 실행하지 않는 것을 생각할 때 이야기하는 것입니다.

  • 여기서 밝히고자 하는 사실은 Compose의 안정성과 건너뛰기에 관한 이야기가 많지만 이것이 전부는 아니며 항상 최적화에 적합한 것은 아니라는 점입니다.

  • 앞서 언급했듯이 컴포지션의 모든 항목에는 관련 비용이 있으며 여기에는 건너뛰기도 포함됩니다.

    // What you write
    @Composable fun ContactListItem(
        contact: Contact
    ) { ... }
    
    // What you get
    @Composable fun ContactListItem(
        contact: Contact,
        $composer: Composer?
        $changed: Int
    ) {
        val $dirty = $changed or
            $composer.changed(contact)
    
        if ($dirty) { /* Your function body */ }
        else $composer.skipToGroupEnd()
    }
  • 건너뛰기가 반드시 사소한 것은 아닙니다.

  • 우선 이를 사용 설정하기 위해 많은 유지보수 작업을 해야 합니다.

  • 모든 매개변수를 저장하고 초기 컴포지션에서 매개변수를 위한 공간을 찾은 다음 리컴포지션 시 해당 레코드를 업데이트하는 등의 작업을 해야 합니다.

  • 또한 컴포저블을 건너뛰어야 하는지 여부를 파악하기 위해 값을 이전 값과 비교해야 합니다.

  • 그런 다음 이 모든 것은 코드를 건너뛰는지 여부를 확인하는 if 검사의 기능입니다.

  • 여기에서 진행되는 작업에 따라 비용이 많이 들 수 있습니다.

  • 컴파일러 내부에는 최적화가 있습니다. 동일한 매개변수를 전달하면 동등성 검사를 재사용할 수 있지만, 이것이 전부는 아니며 이를 무효화하기가 매우 쉽습니다.

  • 건너뛰기가 제대로 작동하지 않는 예로, 화면에 구성할 모든 데이터를 포함하는 대규모 객체가 있는 스크롤 목록의 아주 전형적인 예를 살펴보겠습니다.

    data class ContactsModel(
        val header: String,
        val contacts: List<Contact>
    )
    
    data class Contact(...)
    
    @Composable
    fun ContactsScreen(model: ContactsModel) {
        Column {
            TopAppBar(title = { Text(model.header) })
            ContactsList(model.contacts)
        }
    }
    
    @Composable
    fun ContactsList(contacts: List<Contact>) {
        LazyColumn {
            items(contacts) { ContactListItem(it) }
        }
    }
    
    @Composable
    fun ContactListItem(contact: Contact) { ... }
  • 여기서 화면 모델에는 헤더 화면과 같은 항목과 연락처 목록이 포함되어 있으며, 이 목록은 몇 가지 컴포저블을 통해 전달되고, 결국 지연 목록을 호출한 다음 일부 항목 셀과 함께 화면에 표시됩니다.

  • 이 코드의 컴포지션 계층 구조는 다음과 같습니다. 다시 연락처 화면에서 시작하여 약간 펼쳐진 다음 항목을 호출하고 표시하는 다른 컴포저블이 있습니다.

  • 데이터가 전달되는 방식을 살펴보면 맨 위의 모델에서 시작합니다.

  • 이를 문자열로 분해한 다음 목록으로 분해합니다. 이 목록은 지연 목록의 항목 람다로 변환되고 지연 목록은 화면에 표시되는 모든 항목을 구성합니다.

  • 이 목록의 마지막 연락처를 수정하면 어떻게 되는지 살펴보겠습니다.

  • 우리가 원하는 것은 목록의 마지막 항목을 무효화하는 것이지만, 여기서는 그렇게 되지 않습니다.

  • 이 리컴포지션의 컴포지션 트레이스를 살펴보면 다음과 같이 표시됩니다. 연락처 화면에서 시작하여 결국 이 목록 항목으로 내려갑니다.

  • 이것은 이곳에 내려오는 꽤 비싼 방법입니다.

  • 또한 트레이스 내에서 아무 일도 일어나지 않는 것처럼 보이는 섹션도 있습니다.

  • 여기서는 명확하지 않지만 실제로 컴포저블이 건너뛸지 여부를 결정하는 곳입니다.

  • 이 예시에서는 지연 목록이 재구성되는 방식으로 인해 두 개의 컴포저블만 건너뜁니다.

  • 새 콘텐츠 람다를 제공하면 포함된 모든 항목이 무효화됩니다.

  • 따라서 변경된 연락처 외에 변경되지 않은 두 연락처도 무효화되며, 이 두 연락처만 건너뛰게 됩니다.

  • 다른 모든 항목은 이 등호 비교를 건너뛰고 실제로 실행해야 한다는 것을 인식합니다.

  • 객체가 안정적이면 이 비교는 실제로 비용이 많이 들 수 있습니다.

  • 모든 데이터 구조를 거치도록 강제하면 두 객체가 같은지 확인하는 데만 상당한 비용이 발생할 수 있습니다.

  • 더 나쁜 것은 구조 분해를 했기 때문에 목록에서 동일한 비교를 두 번 해야 한다는 것입니다.

  • 대신 컴파일러가 기본 동작으로 작동하도록 하고 강력한 건너뛰기를 사용하면 건너뛰기를 계속 사용할 수 있고 컴포저블을 조건부로 건너뛰는 검사를 계속 사용할 수 있지만 구조적 동등성 대신 객체 ID 동등성을 사용하는 훨씬 더 빠른 경로를 사용합니다.

  • 이렇게 하면 계산 속도가 훨씬 빨라집니다. 이 프레임에서 동일한 항목을 재구성하더라도, 실제로 빠르게 처리하는 것이 중요한 경우 이러한 컴포저블을 실제로 건너뛸 수 없다는 것을 파악하는 데 너무 많은 시간을 낭비하지 않습니다.

  • 또한 재구성 횟수를 직접 확인하는 대신 벤치마크 및 트레이스와 같은 도구를 사용하여 애플리케이션 내부에서 어떤 일이 일어나고 있는지 파악하는 것이 중요합니다.

  • 유용한 대리 지표가 될 수 있지만 애플리케이션을 최적화하는 과정에서 반드시 과도하게 집중하거나 과도하게 반영할 필요는 없습니다.

  • 컴포저블을 건너뛰려고 하지 말고 처음부터 무효화하지 않도록 하세요.

메서드 트레이스로 실행 분석

  • 이 예시에서 컴포지션 트레이스는 진행 중인 작업에 관한 많은 정보를 제공할 수 있지만 전체 그림을 보여주지는 않는다는 것을 확인했습니다.

  • 이를 위해 안드레이가 메서드 트레이스에 관해 설명해 주겠습니다.

  • 좋습니다.

  • 앤드루가 이미 말했듯이 컴포지션 추적은 구성 가능한 함수에만 주석을 달기 때문에 항상 모든 상황을 알려주지는 않습니다. Perfetto 추적을 이미 보강하는 컴포지션 추적에 빈 공간만 표시되는 경우가 있지만 여전히 그 안에서 일어나는 모든 상황을 보여주지는 않습니다.

  • Compose에서 수행하는 작업일 수도 있고 코드에 있는 작업일 수도 있습니다. 여기에서 컴포지션 트레이스를 보는 것만으로는 파악하기가 매우 어렵습니다.

  • 이제 메서드 트레이스를 가져와야 합니다.

  • Android 스튜디오의 프로파일러에 있는 항목과 같은 프로파일러의 항목이 있거나 ADB 명령어를 사용하여 트레이스를 가져올 수도 있습니다. 그러면 Java 환경에서 발생하는 모든 함수 호출이 제공됩니다.

  • Compose 컴파일러가 인코딩하는 모든 것, Compose 런타임이 실행하는 모든 것, 코드가 실행하는 모든 것이 포함되어 있으며, 정확히 무엇이 실행되고 있는지, 해당 공간에서 어떤 함수가 호출되었는지 추적하고 조사하는 데 매우 유용합니다.

  • 한 가지, 이 중간에 있는 항목을 보고 싶습니다.

  • 메서드 트레이싱은 오버헤드가 매우 높다는 점에 유의해야 합니다.

  • 함수 트리의 복잡성에 따라 오버헤드가 10~20배 더 많습니다. 모든 함수 호출이 조금씩 더해지기 때문입니다.

  • 이를 보여주기 위해 스크린샷을 살펴보겠습니다.

  • 이것이 앱 시작의 메서드 트레이스 모습입니다. '이걸 어떻게 이해해야 하지?'라고 생각할 수 있습니다.

  • 컴포지션 트레이스를 시작하면 일반적으로 여기에 있는 함수를 살펴봅니다.

  • 이것은 간소화된 컴포지션 트레이스와 같습니다. ScreenContent, 일부 버튼, 기타 함수가 있습니다.

  • ScreenContent에서 어떤 일이 일어나는지 파악하고 싶다고 가정해 보겠습니다.

  • Perfetto에는 검색창이 있습니다.

  • Perfetto 뷰어에서 메서드 트레이스를 로드하고 여기에서 ScreenContent와 동일한 함수를 검색하면 화면의 메서드 트레이스인 이와 같은 항목이 표시됩니다.

  • 여기에서 Composer.startGroup 및 그룹 통화가 표시되는 것을 볼 수 있습니다.

  • 이는 컴포지션이 실제로 마법을 부리도록 런타임과 컴파일러가 백그라운드에서 수행하는 작업이지만 여기서 흥미로운 호출은 Composer.changed입니다.

  • 이렇게 하면 컴포저블의 이전 호출과 현재 호출 간에 매개변수가 변경되었는지 확인할 수 있습니다.

  • 그리고 이 Composer.changed의 대부분은 이 ScreenModel.equals가 지배하고 있습니다.

  • ScreenModel은 일종의 대규모 데이터 클래스일 수 있으며, 리컴포지션 수를 줄이기 위해 안정적으로 만들었을 수 있습니다. 따라서 컴파일러 보고서에 표시되었을 수 있습니다.

  • 이 경우 안정적인 주석을 삭제하면 성능에 도움이 될 수 있습니다.

  • 따라서 이 문제를 찾아야 하며 컴포지션 메서드 트레이스가 이 문제를 찾는 데 가장 적합한 도구입니다.

  • 좋습니다.

  • 오늘 많은 내용을 살펴봤습니다. 이제 앤드류가 마무리하겠습니다.

빠른 구성 가능한 디자인 및 성능 도구

  • 오늘 다룬 모든 내용을 마무리하고 개략적으로 요약하자면, 컴포저블을 설계하고 기존 앱을 최적화할 때 염두에 두어야 할 몇 가지 사항이 있습니다.

  • 우선, 건너뛰는 컴포저블보다 저렴한 컴포저블과 무효화하지 않는 컴포저블이 더 좋습니다.

  • 컴포저블을 설계할 때는 더 작고 비교하기 쉬운 인수를 고려하고 다른 함수 호출에 대한 비교를 재사용하여 최적화를 활용하는 것이 좋습니다.

  • 컴포저블 내에서 API를 사용할 때는 성능 또는 프레임 시간이 API 자체의 비용과 사용 빈도의 결과물이라는 점에 유의하세요.

  • 이러한 비용이 어떻게 합산되는지, 그리고 애플리케이션에 가장 적절하지 않은 시점에 발생할 수 있는 해체 비용이 있을 수 있다는 점에 유의하세요.

  • 또한 앱 자체를 최적화할 때는 모범적인 시민이 되는 것이 중요합니다.

  • 먼저 벤치마크를 통해 정보에 입각한 결정을 내리고, 앱을 프로파일링하여 코드의 올바른 영역을 타겟팅하고 있는지 확인하고, 벤치마크 및 프로파일링의 결과로 변경사항을 적용하고 예상한 결과를 확인하는지 검증합니다.

  • 또한 애플리케이션 내에서 유지보수성과 성능 간에 상충 관계가 있을 수 있다는 점에 유의하세요.

  • 따라서 유지보수성을 고려하여 과도한 최적화로 인해 어려움을 겪지 않도록 자신과 팀에 가장 적합한 방법을 선택하세요.

  • 또한 추가적인 도움이 필요하다면 이미 보유하고 있는 AI 에이전트를 활용할 수도 있습니다.

  • Android CLI의 일부로, 그리고 그곳에서 제공하는 기술의 일부로 Jetpack Compose 성능 기술을 출시했습니다. 이 기술에는 오늘 이야기한 많은 정보가 포함되어 있으며, 애플리케이션을 검토하고 감사하여 이러한 종류의 최적화를 위한 제안을 할 수 있습니다.

  • Android CLI를 사용하여 기존 에이전트에 설치할 수 있습니다.

    android skills add jetpack-compose-performance
  • Android CLI에 관한 자세한 내용은 developer.android.com에서 확인할 수 있습니다.

  • GitHub 저장소에서 스킬 자체에 대해 자세히 알아볼 수도 있습니다.

  • 안전하게 벤치마킹하고 즐겁게 작성하시기 바랍니다.

코드

코드 스니펫

'부작용 관리'의 09:34에 나오는 Kotlin 스니펫
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Asynchronous work and animations
    LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}
'부작용 관리'의 10:15에 나오는 Kotlin 스니펫
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val activity = LocalActivity.current

    // Setup and teardown
    DisposableEffect(Unit) {
        activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
        onDispose {
            activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
        }
    }
}
'부작용 관리'의 10:36에 나오는 Kotlin 스니펫
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // One-shot events
    SideEffect(Unit) { mediaPlayer.play() }
}
'부작용 관리'의 11:12에 있는 Kotlin 스니펫
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Synchronize the outside world to the state of composition
    SideEffect { mediaPlayer.playbackRate = playbackRate }
}
'부작용 관리'의 12:12에 나오는 Kotlin 스니펫
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) { viewModel.loadCart() }
    LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
    LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }

    CartContent(...)
}
'부작용 관리'의 12:36에 나오는 Kotlin 스니펫
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) {
        launch { viewModel.loadCart() }
        launch { viewModel.loadPaymentMethods() }
        launch { viewModel.loadDeliveryAddresses() }
    }

    CartContent(...)
}
'상태 최적화 및 무효화'의 15:12에 나오는 Kotlin 스니펫
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton = scrollState.value > 0
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
'상태 최적화 및 무효화'의 15:51에 나오는 Kotlin 스니펫
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
'상태 최적화 및 무효화'의 16:49에 나오는 Kotlin 스니펫
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

// First read
derivedState.value
computedState.value

// Second read
derivedState.value
computedState.value
'상태 최적화 및 무효화'의 18:35에 나오는 Kotlin 스니펫
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

...

// Read-after-write
a++
derivedState.value
computedState.value
'Compose의 건너뛰기 및 안정성'에서 21:07의 Kotlin 스니펫
// What you write
@Composable fun ContactListItem(
    contact: Contact
) { ... }

// What you get
@Composable fun ContactListItem(
    contact: Contact,
    $composer: Composer?
    $changed: Int
) {
    val $dirty = $changed or
        $composer.changed(contact)

    if ($dirty) { /* Your function body */ }
    else $composer.skipToGroupEnd()
}
'Compose의 건너뛰기 및 안정성'에서 21:53의 Kotlin 스니펫
data class ContactsModel(
    val header: String,
    val contacts: List<Contact>
)

data class Contact(...)

@Composable
fun ContactsScreen(model: ContactsModel) {
    Column {
        TopAppBar(title = { Text(model.header) })
        ContactsList(model.contacts)
    }
}

@Composable
fun ContactsList(contacts: List<Contact>) {
    LazyColumn {
        items(contacts) { ContactListItem(it) }
    }
}

@Composable
fun ContactListItem(contact: Contact) { ... }
'빠른 구성 가능한 디자인 및 성능 도구'의 29:40에 있는 셸 스니펫
android skills add jetpack-compose-performance