Jetpack Compose의 실제 성능 문제 해결

1. 시작하기 전에

이 Codelab에서는 Compose 앱의 런타임 성능을 개선하는 방법을 알아봅니다. 과학적 접근 방식을 따라 성능을 측정하고 디버그하며 최적화합니다. 다양한 작업을 나타내는 화면이 여러 개 포함된 샘플 앱에서 시스템 추적을 통해 여러 성능 문제를 조사하고 성능이 좋지 못한 런타임 코드를 변경합니다. 화면은 각각 다르게 빌드되었고 다음을 포함합니다.

  • 첫 번째 화면은 2열 목록으로, 이미지 항목이 있고 항목 위에 일부 태그가 있습니다. 여기서 무거운 컴포저블을 최적화합니다.

8afabbbbbfc1d506.gif

  • 두 번째와 세 번째 화면에는 자주 재구성되는 상태가 포함되어 있습니다. 여기서 불필요한 리컴포지션을 삭제하여 성능을 최적화합니다.

f0ccf14d1c240032.gif 51dc23231ebd5f1a.gif

  • 마지막 화면에는 불안정한 항목이 포함되어 있습니다. 여기서 다양한 기법으로 항목을 안정화합니다.

127f2e4a2fc1a381.gif

기본 요건

학습할 내용

필요한 항목

2. 설정

시작하려면 다음 단계를 따르세요.

  1. GitHub 저장소를 클론합니다.
$ git clone https://github.com/android/codelab-android-compose.git

또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.

  1. 다음 브랜치가 포함된 PerformanceCodelab 프로젝트를 엽니다.
  • main: 이 프로젝트의 시작 코드가 포함되어 있으며 여기서 코드를 변경하여 Codelab을 완료합니다.
  • end: 이 Codelab의 솔루션 코드가 포함되어 있습니다.

main 브랜치로 시작하고 자기 속도에 맞게 Codelab을 차근차근 따라가는 것이 좋습니다.

  1. 솔루션 코드를 확인하고 싶다면 다음 명령어를 실행합니다.
$ git clone -b end https://github.com/android/codelab-android-compose.git

또는 솔루션 코드를 다운로드할 수 있습니다.

선택사항: 이 Codelab에서 사용된 시스템 트레이스

Codelab을 진행하며 시스템 트레이스를 캡처하는 벤치마크를 여러 개 실행합니다.

이러한 벤치마크를 실행할 수 없다면 대신 다운로드할 수 있는 시스템 트레이스 목록은 다음과 같습니다.

3. 성능 문제를 해결하는 방법

느리고 성능이 좋지 못한 UI는 앱을 탐색하며 쉽게 눈으로 확인할 수도 있습니다. 하지만 가정에 기반하여 코드 수정을 성급하게 시작하기 전에 코드 성능을 측정하여 코드 변경으로 차이가 생기는지 파악해야 합니다.

개발 중에 앱의 debuggable 빌드를 사용하면 필요한 만큼 성능을 발휘하지 못하는 부분을 발견하게 되고 이 문제를 바로 해결하고 싶을 수 있습니다. 하지만 debuggable 앱의 성능은 사용자에게 표시되는 부분을 나타내는 것이 아니므로 실제로 문제가 있는지 non-debuggable 앱으로 확인하는 것이 중요합니다. debuggable 앱에서는 모든 코드가 런타임에서 해석되어야 합니다.

Compose의 성능을 고려할 때 특정 기능을 구현하는 데 따라야 하는 엄격한 규칙은 없습니다. 다음 사항을 성급하게 실행하면 안 됩니다.

  • 코드에 숨어 있는 불안정한 매개변수를 모두 추적하고 수정하지 마세요.
  • 해당 컴포저블의 리컴포지션을 유발하는 애니메이션을 삭제하지 마세요.
  • 직감에 따라 읽기 어려운 최적화를 실행하지 마세요.

이러한 모든 수정 작업은 사용 가능한 도구를 사용하여 성능 문제를 해결할 수 있도록 정보에 입각한 방식으로 실행해야 합니다.

성능 문제를 다룰 때는 다음과 같은 과학적 접근 방식을 따라야 합니다.

  1. 측정을 통해 초기 성능을 설정합니다.
  2. 문제를 일으키는 원인을 확인합니다.
  3. 확인한 내용에 따라 코드를 수정합니다.
  4. 성능을 측정하고 초기 성능과 비교합니다.
  5. 반복합니다.

구조화된 방법을 따르지 않으면 성능을 개선하는 변경사항도 있지만 성능을 저하하는 변경사항도 있어서 결국 동일한 결과를 얻게 될 수 있습니다.

Compose로 앱 성능을 개선하는 방법에 관한 다음 동영상을 시청해 보세요. 성능 문제를 해결하는 여정을 따라가고 성능을 개선하는 방법에 관한 도움말도 알려줍니다.

기준 프로필 생성

성능 문제를 조사하기 전에 앱의 기준 프로필을 생성하세요. Android 6(API 수준 23) 이상에서 앱은 런타임에 해석되고 설치 시 JIT(Just-In-Time) 컴파일 및 AOT(Ahead-Of-Time) 컴파일된 코드를 실행합니다. 해석되고 JIT 컴파일된 코드는 AOT보다 느리게 실행되지만 디스크와 메모리 공간을 더 적게 차지합니다. 따라서 모든 코드를 AOT 컴파일해서는 안 됩니다.

기준 프로필을 구현하면 앱 시작 시간이 30% 개선되고 런타임 시 JIT 모드에서 실행되는 코드를 8분의 1로 줄일 수 있습니다. Now in Android 샘플 앱에 기반한 다음 이미지를 참고하세요.

b51455a2ca65ea8.png

기준 프로필에 관한 자세한 내용은 다음 리소스를 참고하세요.

성능 측정

성능을 측정하려면 Jetpack Macrobenchmark를 사용하여 벤치마크를 설정하고 작성하는 것이 좋습니다. Macrobenchmark는 사용자가 하듯이 앱과 상호작용하면서 앱 성능을 모니터링하는 계측 테스트입니다. 즉, 테스트 코드로 앱 코드를 오염시키지 않으므로 신뢰할 수 있는 성능 정보를 제공합니다.

이 Codelab에서는 이미 코드베이스를 설정하고 벤치마크를 작성하여 성능 문제 해결에만 집중할 수 있도록 했습니다. 프로젝트에서 Macrobenchmark를 설정하고 사용하는 방법을 잘 모르겠다면 다음 리소스를 참고하세요.

Macrobenchmark를 사용하면 다음 컴파일 모드 중 하나를 선택할 수 있습니다.

  • None: 컴파일 상태를 재설정하고 JIT 모드에서 모든 항목을 실행합니다.
  • Partial: 기준 프로필이나 준비 반복을 사용하여 앱을 사전 컴파일하고 JIT 모드에서 실행합니다.
  • Full: 전체 앱 코드를 사전 컴파일하므로 JIT 모드에서 실행되는 코드가 없습니다.

이 Codelab에서는 벤치마크에 CompilationMode.Full() 모드만 사용합니다. 앱의 컴파일 상태가 아닌 코드에서 변경된 내용에만 관심이 있기 때문입니다. 이 접근 방식을 사용하면 JIT 모드에서 실행되는 코드로 발생할 수 있는 변동을 줄일 수 있으며 이는 맞춤 기준 프로필을 구현할 때 줄여야 하는 것입니다. Full 모드는 앱 시작에 부정적인 영향을 미칠 수 있으므로 앱 시작을 측정하는 벤치마크에는 사용하지 마세요. 런타임 성능 개선을 측정하는 벤치마크에만 사용하세요.

성능 개선을 완료하고 사용자가 앱을 설치할 때 어떻게 실행되는지 성능을 확인하려면 기준 프로필을 사용하는 CompilationMode.Partial() 모드를 사용합니다.

다음 섹션에서는 트레이스를 읽고 성능 문제를 찾는 방법을 알아봅니다.

4. 시스템 추적으로 성능 분석

앱의 debuggable 빌드를 사용하면 컴포지션 개수가 포함된 Layout Inspector를 사용하여 언제 항목이 너무 자주 재구성되는지 빠르게 파악할 수 있습니다.

b7edfea340674732.gif

하지만 이는 전반적인 성능 조사의 일부분일 뿐입니다. 프록시 측정값만 얻고 이러한 컴포저블이 렌더링되는 데 소요되는 실제 시간은 얻지 못하기 때문입니다. 이는 총 지속 시간이 1밀리초보다 적은 경우 항목의 재구성 횟수가 N이라면 별로 중요하지 않을 수 있습니다. 하지만 항목이 한 번이나 두 번만 구성되고 시간이 100밀리초 걸린다면 중요해집니다. 컴포저블은 한 번만 구성될 수도 있지만 여기에 시간이 너무 오래 걸려 화면이 느려지는 경우가 많습니다.

성능 문제를 안정적으로 조사하고 앱이 실행하는 작업과 예상보다 작업 시간이 오래 걸리는지에 관한 유용한 정보를 얻으려면 컴포지션 추적과 함께 시스템 추적을 사용하면 됩니다.

시스템 추적은 앱에서 발생하는 모든 일에 관한 타이밍 정보를 제공합니다. 앱에 오버헤드를 추가하지 않으므로 성능에 미치는 부정적인 영향을 걱정하지 않고 프로덕션 앱에 유지할 수 있습니다.

컴포지션 추적 설정

Compose는 항목이 재구성되거나 지연 레이아웃이 항목을 미리 가져오는 경우 등 런타임 단계에 관한 일부 정보를 자동으로 채웁니다. 하지만 문제가 될 수 있는 섹션을 실제로 파악하는 데는 정보가 충분하지 않습니다. 트레이스 중에 구성된 모든 단일 컴포저블의 이름을 제공하는 컴포지션 추적을 설정하여 정보의 양을 늘릴 수 있습니다. 이렇게 하면 맞춤 trace("label") 섹션을 많이 추가하지 않고도 성능 문제 조사를 시작할 수 있습니다.

컴포지션 추적을 사용 설정하려면 다음 단계를 따르세요.

  1. runtime-tracing 종속 항목을 :app 모듈에 추가합니다.
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-beta01")

이 시점에 Android 스튜디오 프로파일러를 사용하여 시스템 트레이스를 기록할 수 있으며 여기에는 모든 정보가 포함되지만 성능 측정 및 시스템 트레이스 기록에는 Macrobenchmark를 사용합니다.

  1. :measure 모듈에 종속 항목을 추가하여 Macrobenchmark를 사용한 컴포지션 추적을 사용 설정합니다.
implementation("androidx.tracing:tracing-perfetto:1.0.0")
implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
  1. androidx.benchmark.fullTracing.enable=true 계측 인수를 :measure 모듈의 build.gradle 파일에 추가합니다.
defaultConfig {
    // ...
    testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
}

터미널에서 사용하는 방법 등 컴포지션 추적을 설정하는 방법에 관한 자세한 내용은 문서를 참고하세요.

Macrobenchmark로 초기 성능 캡처

시스템 트레이스 파일은 다양한 방법으로 가져올 수 있습니다. 예를 들어 Android 스튜디오 프로파일러를 사용하여 기록하거나 기기에서 캡처하거나 Macrobenchmark를 사용하여 기록된 시스템 트레이스를 가져올 수 있습니다. 이 Codelab에서는 Macrobenchmark 라이브러리에서 가져온 트레이스를 사용합니다.

이 프로젝트에는 :measure 모듈에 벤치마크가 포함되어 있으며 이를 실행하여 성능 측정값을 가져올 수 있습니다. 이 프로젝트의 벤치마크는 이 Codelab 진행 시 시간을 절약하기 위해 한 번만 반복하도록 설정되어 있습니다. 실제 앱에서는 출력 변동이 심한 경우 최소 10번 반복하는 것이 좋습니다.

초기 성능을 캡처하려면 첫 번째 작업 화면의 화면을 스크롤하는 AccelerateHeavyScreenBenchmark 테스트를 사용하고 다음 단계를 따르세요.

  1. AccelerateHeavyScreenBenchmark.kt 파일을 엽니다.
  2. 벤치마크 클래스 옆의 여백 작업을 사용하여 벤치마크를 실행합니다.

e93fb1dc8a9edf4b.png

이 벤치마크는 작업 1 화면을 스크롤하고 프레임 시간과 맞춤 트레이스 섹션을

캡처합니다.

8afabbbbbfc1d506.gif

벤치마크가 종료되면 Android 스튜디오 출력 창에 결과가 표시됩니다.

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0

출력에서 중요한 측정항목은 다음과 같습니다.

  • frameDurationCpuMs: 프레임을 렌더링하는 데 드는 시간을 알려줍니다. 짧을수록 좋습니다.
  • frameOverrunMs: GPU 작업을 비롯해 프레임 제한을 초과한 시간을 알려줍니다. 음수가 좋습니다. 시간이 남아있기 때문입니다.

다른 측정항목(예: ImagePlaceholderMs 측정항목)은 맞춤 트레이스 섹션을 사용하고, 트레이스 파일에 있는 이러한 모든 섹션의 합산 시간과 ImagePlaceholderCount 측정항목에서 발생한 횟수를 출력합니다.

이러한 측정항목은 모두 코드베이스의 변경사항으로 성능이 개선되는지 파악하는 데 도움이 될 수 있습니다.

트레이스 파일 읽기

Android 스튜디오 또는 웹 기반 도구 Perfetto에서 시스템 트레이스를 읽을 수 있습니다.

Android 스튜디오 프로파일러는 트레이스를 빠르게 열고 앱 프로세스를 표시하는 데 좋고 Perfetto는 강력한 SQL 쿼리 등으로 시스템에서 실행되는 모든 프로세스에 관한 좀 더 심층적인 조사 기능을 제공합니다. 이 Codelab에서는 Perfetto를 사용하여 시스템 트레이스를 분석합니다.

  1. Perfetto 웹사이트를 열면 도구의 대시보드가 로드됩니다.
  2. [module]/outputs/connected_android_test_additional_output/benchmarkRelease/connected/[device]/ 폴더에 저장되어 있는 호스팅 파일 시스템에서 Macrobenchmark로 캡처한 시스템 트레이스를 찾습니다. 벤치마크가 반복될 때마다 별도의 트레이스 파일이 기록되며 각각 앱과의 동일한 상호작용이 포함되어 있습니다.

51589f24d9da28be.png

  1. AccelerateHeavyScreenBenchmark_...iter000...perfetto-trace 파일을 Perfetto UI로 드래그하고 트레이스 파일이 로드될 때까지 기다립니다.
  2. 선택사항: 벤치마크를 실행하여 트레이스 파일을 생성할 수 없다면 아래 트레이스 파일을 다운로드한 후 Perfetto로 드래그합니다.

547507cdf63ae73.gif

  1. com.compose.performance라는 앱 프로세스를 찾습니다. 일반적으로 포그라운드 앱은 하드웨어 정보 레인과 두 개의 시스템 레인 아래에 있습니다.
  2. 앱 프로세스 이름이 있는 드롭다운 메뉴를 엽니다. 앱에서 실행되는 스레드 목록이 표시됩니다. 다음 단계에서 필요하므로 트레이스 파일은 계속 열어 둡니다.

582b71388fa7e8b.gif

앱의 성능 문제를 찾으려면 앱 스레드 목록 상단의 예상 및 실제 타임라인을 활용하면 됩니다.

1bd6170d6642427e.png

예상 타임라인은 앱에서 생성하는 프레임이 유연하고 성능이 우수한 UI(여기서는 16ms, 600µs(1000ms / 60))를 표시할 것으로 시스템이 예상하는 시점을 알려줍니다. 실제 타임라인은 GPU 작업을 비롯해 앱에서 생성한 프레임의 실제 지속 시간을 보여줍니다.

다양하게 표시되는 색상은 다음을 나타냅니다.

  • 녹색 프레임: 제때 생성된 프레임입니다.
  • 빨간색 프레임: 예상보다 오래 걸린 버벅거리는 프레임입니다. 이러한 프레임에서 완료된 작업을 조사하여 성능 문제를 예방해야 합니다.
  • 연한 녹색 프레임: 시간 제한 내에 프레임이 생성되었지만 늦게 표시되어 입력 지연 시간이 늘어났습니다.
  • 노란색 프레임: 프레임이 버벅거렸지만 앱이 그 이유는 아니었습니다.

UI가 앱에서 렌더링될 때는 기기에서 예상하는 프레임 생성 시간보다 변경사항이 더 빠르게 적용되어야 합니다. 디스플레이 화면 재생 빈도가 60Hz라고 하면 일반적으로 이는 대략 16.6ms였지만 최신 Android 기기의 경우 디스플레이 화면 재생 빈도가 90Hz 이상이므로 약 11ms 이하일 수 있습니다. 다양한 화면 재생 빈도로 인해 프레임마다 다를 수도 있습니다.

예를 들어 UI가 16개 항목으로 구성된 경우 프레임 건너뛰기를 방지하려면 각 항목 생성에는 약 1ms가 걸립니다. 하지만 동영상 플레이어와 같이 항목이 하나뿐이라면 버벅거림 없이 구성하는 데 최대 16ms까지 걸릴 수 있습니다.

시스템 추적 호출 차트 이해

다음 이미지는 리컴포지션을 보여주는 간소화된 버전의 시스템 트레이스 예입니다.

8f16db803ca19a7d.png

위에서 아래로 있는 각 막대는 그 아래에 있는 막대의 총시간이며 막대는 또한 호출된 함수 코드의 섹션에 상응합니다. Compose는 컴포지션 계층 구조에서 recompose를 호출합니다. 첫 번째 컴포저블은 MaterialTheme입니다. MaterialTheme 안에는 테마 설정 정보를 제공하는 컴포지션 로컬이 있습니다. 여기에서 HomeScreen 컴포저블이 호출됩니다. 홈 화면 컴포저블은 컴포지션의 일부로 MyImageMyButton 컴포저블을 호출합니다.

시스템 트레이스의 간격은 추적되지 않은 코드가 실행되는 데서 비롯합니다. 시스템 트레이스는 추적용으로 표시된 코드만 표시하기 때문입니다. 실행되는 코드는 MyImage가 호출된 후 그러나 MyButton이 호출되기 전에 발생하고 간격 크기가 조정되는 시간을 차지합니다.

다음 섹션에서는 이전 단계에서 가져온 트레이스를 분석합니다.

5. 무거운 컴포저블 가속화

앱 성능을 최적화하려고 할 때 처음으로 해야 할 작업은 기본 스레드에서 무거운 컴포저블이나 장기 실행 작업을 찾는 것입니다. 장기 실행 작업은 UI의 복잡성과 UI를 구성하는 데 드는 시간에 따라 의미가 달라질 수 있습니다.

따라서 프레임이 누락되면 너무 오래 걸리는 컴포저블을 찾아 기본 스레드를 오프로드하거나 기본 스레드에서 실행하는 작업의 일부를 건너뛰어 속도를 높여야 합니다.

AccelerateHeavyScreenBenchmark 테스트에서 가져온 트레이스를 분석하려면 다음 단계를 따르세요.

  1. 이전 단계에서 가져온 시스템 트레이스를 엽니다.
  2. 데이터가 로드된 후 UI 초기화가 포함된 첫 번째 긴 프레임을 확대합니다. 프레임의 콘텐츠는 다음 이미지와 같습니다.

838787b87b14bbaf.png

트레이스를 보면 하나의 프레임 안에서 많은 일이 일어나는데 이는 Choreographer#doFrame 섹션 아래에서 확인할 수 있습니다. 이미지를 보면 큰 이미지를 로드하는 ImagePlaceholder 섹션이 포함된 컴포저블에서 작업의 가장 큰 청크가 비롯됩니다.

기본 스레드에 큰 이미지를 로드하지 마세요

Coil이나 Glide와 같은 편의 라이브러리 중 하나를 사용하여 네트워크에서 비동기로 이미지를 로드하는 것이 당연할 수 있지만 앱에 로컬로 있는 큰 이미지를 표시해야 한다면 어떻게 해야 할까요?

리소스에서 이미지를 로드하는 일반적인 painterResource 컴포저블 함수는 컴포지션 중에 기본 스레드에 이미지를 로드합니다. 즉, 이미지가 크면 일부 작업으로 기본 스레드가 차단될 수 있습니다.

여기서는 문제를 비동기 이미지 자리표시자의 일부로 볼 수 있습니다. painterResource 컴포저블은 로드하는 데 대략 23ms가 걸리는 자리표시자 이미지를 로드합니다.

c83d22c3870655a7.jpeg

이 문제는 다음 방법을 비롯하여 여러 방법으로 개선할 수 있습니다.

  • 이미지를 비동기로 로드합니다.
  • 더 빠르게 로드되도록 이미지를 작게 만듭니다.
  • 필요한 크기에 따라 조정되는 벡터 드로어블을 사용합니다.

이 성능 문제를 해결하려면 다음 단계를 따르세요.

  1. AccelerateHeavyScreen.kt 파일로 이동합니다.
  2. 이미지를 로드하는 imagePlaceholder() 컴포저블을 찾습니다. 자리표시자 이미지 크기는 1600x1600px이며 표시되는 내용에 비해 너무 큽니다.

53b34f358f2ff74.jpeg

  1. 드로어블을 R.drawable.placeholder_vector로 변경합니다.
@Composable
fun imagePlaceholder() =
    trace("ImagePlaceholder") { painterResource(R.drawable.placeholder_vector) }
  1. AccelerateHeavyScreenBenchmark 테스트를 다시 실행하여 앱을 다시 빌드하고 시스템 트레이스를 다시 가져옵니다.
  2. 시스템 트레이스를 Perfetto 대시보드로 드래그합니다.

또는 트레이스를 다운로드할 수 있습니다.

  1. 개선된 부분을 바로 보여주는 ImagePlaceholder 트레이스 섹션을 검색합니다.

abac4ae93d599864.png

  1. ImagePlaceholder 함수가 더 이상 기본 스레드를 그렇게 많이 차단하지 않음을 확인합니다.

8e76941fca0ae63c.jpeg

실제 앱의 대체 솔루션에서는 자리표시자 이미지가 문제를 유발하는 것이 아니라 일부 아트워크일 수 있습니다. 이 경우 컴포저블을 비동기로 로드하는 CoilrememberAsyncImage 컴포저블을 사용할 수 있습니다. 이 솔루션은 자리표시자가 로드될 때까지 빈 공간을 표시하므로 이러한 종류의 이미지를 위한 자리표시자가 있어야 할 수 있습니다.

여전히 성능이 좋지 못한 다른 부분도 있으며 다음 단계에서 알아봅니다.

6. 무거운 작업을 백그라운드 스레드로 오프로드

추가 문제에 관해 동일한 항목을 계속 조사하면 이름이 binder transaction인 섹션이 표시되며 각각 1ms 정도 걸립니다.

5c08376b3824f33a.png

binder transaction 섹션은 프로세스와 일부 시스템 프로세스 간에 프로세스 간 통신이 발생했음을 보여줍니다. 이는 시스템에서 정보를 가져오는 일반적인 방법입니다(예: 시스템 서비스 가져오기).

이러한 트랜잭션은 시스템과 통신하는 여러 API에 포함되어 있습니다. 예를 들어 getSystemService를 사용하여 시스템 서비스를 가져오거나 broadcast receiver를 등록하거나 ConnectivityManager를 요청하는 경우입니다.

안타깝게도 이러한 트랜잭션은 요청 내용에 관한 정보를 많이 제공하지 않습니다. 따라서 언급된 API 사용에 관한 코드를 분석한 후 맞춤 trace 섹션을 추가하여 문제가 되는 부분인지 확인해야 합니다.

바인더 트랜잭션을 개선하려면 다음 단계를 따르세요.

  1. AccelerateHeavyScreen.kt 파일을 엽니다.
  2. PublishedText 컴포저블을 찾습니다. 이 컴포저블은 현재 시간대로 날짜 시간 형식을 지정하고 시간대 변경사항을 추적하는 BroadcastReceiver 객체를 등록합니다. 기본 시스템 시간대가 초깃값인 currentTimeZone 상태 변수와 시간대 변경사항을 위한 broadcast receiver를 등록하는 DisposableEffect가 포함됩니다. 끝으로 이 컴포저블은 Text. DisposableEffect를 사용하여 형식이 지정된 날짜 시간을 보여주는데 이 시나리오에서는 좋은 선택입니다. onDispose 람다에서 완료된 broadcast receiver를 등록 취소하는 방법이 필요하기 때문입니다. 하지만 문제가 되는 부분은 DisposableEffect 블록 내 코드가 기본 스레드를 차단한다는 것입니다.
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        // TODO Codelab task: Wrap with a custom trace section
        context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))

        onDispose { context.unregisterReceiver(receiver) }
    }

    Text(
        text = published.format(currentTimeZone),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. trace 호출로 context.registerReceiver를 래핑하여 이것이 모든 binder transactions를 유발하는 것인지 확인합니다.
trace("PublishDate.registerReceiver") {
    context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}

일반적으로 기본 스레드에서 이 정도로 오래 실행되는 코드는 많은 문제를 일으키지 않을 수 있지만 이 트랜잭션은 화면에 표시되는 모든 항목마다 실행되므로 문제가 될 수도 있습니다. 화면에 항목이 여섯 개 표시된다고 가정하면 첫 번째 프레임으로 구성되어야 합니다. 이러한 호출에만 12ms가 걸릴 수 있고 이는 한 프레임의 전체 기한에 가깝습니다.

이 문제를 해결하려면 브로드캐스트 등록을 다른 스레드로 오프로드해야 하는데 코루틴을 사용하면 됩니다.

  1. 컴포저블 수명 주기 val scope = rememberCoroutineScope()에 연결된 범위를 가져옵니다.
  2. 효과 내에서 Dispatchers.Main이 아닌 디스패처에서 코루틴을 실행합니다. 예를 들어 여기서는 Dispatchers.IO입니다. 이렇게 하면 브로캐스트 등록으로 기본 스레드가 차단되지 않고 실제 상태 currentTimeZone은 기본 스레드에 유지됩니다.
val scope = rememberCoroutineScope()

DisposableEffect(Unit) {
    val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            currentTimeZone = TimeZone.currentSystemDefault()
        }
    }

    // launch the coroutine on Dispatchers.IO
    scope.launch(Dispatchers.IO) {
        trace("PublishDate.registerReceiver") {
            context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
        }
    }

    onDispose { context.unregisterReceiver(receiver) }
}

이를 최적화하는 단계가 하나 더 있습니다. broadcast receiver는 목록의 항목마다 필요하지 않고 하나만 필요합니다. 호이스팅을 사용하면 됩니다.

호이스팅하여 시간대 매개변수를 컴포저블 트리 아래로 전달하거나, UI의 여러 곳에서 사용되지 않는 점을 고려하여 컴포지션 로컬을 사용할 수 있습니다.

이 Codelab에서는 broadcast receiver를 컴포저블 트리의 일부로 유지합니다. 하지만 실제 앱에서는 데이터 레이어로 분리하여 UI 코드의 오염을 방지하는 것이 좋을 수 있습니다.

  1. 기본 시스템 시간대로 컴포지션 로컬 정의
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }
  1. content 람다를 사용하여 현재 시간대를 제공하는 ProvideCurrentTimeZone 컴포저블을 업데이트합니다.
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    var currentTimeZone = TODO()

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. DisposableEffectPublishedText 컴포저블에서 새 함수로 이동하여 거기에 호이스팅하고 currentTimeZone을 상태 및 부작용으로 대체합니다.
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        scope.launch(Dispatchers.IO) {
            trace("PublishDate.registerReceiver") {
                context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
            }
        }

        onDispose { context.unregisterReceiver(receiver) }
    }

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. 컴포지션 로컬이 유효해야 하는 컴포저블을 ProvideCurrentTimeZone으로 래핑합니다. 다음 스니펫과 같이 전체 AccelerateHeavyScreen을 래핑해도 됩니다.
@Composable
fun AccelerateHeavyScreen(items: List<HeavyItem>, modifier: Modifier = Modifier) {
    // TODO: Codelab task: Wrap this with timezone provider
    ProvideCurrentTimeZone {
        Box(
            modifier = modifier
                .fillMaxSize()
                .padding(24.dp)
        ) {
            ScreenContent(items = items)

            if (items.isEmpty()) {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
        }
    }
}
  1. 기본 형식 지정 기능만 포함하도록 PublishedText 컴포저블을 변경하고 LocalTimeZone.current를 통해 현재 컴포지션 로컬 값을 읽습니다.
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    Text(
        text = published.format(LocalTimeZone.current),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. 벤치마크를 다시 실행하면 앱이 빌드됩니다.

또는 코드가 수정된 시스템 트레이스를 다운로드해도 됩니다.

  1. 트레이스 파일을 Perfetto 대시보드로 드래그합니다. binder transactions 섹션이 모두 기본 스레드에서 사라졌습니다.
  2. 이전 단계와 유사한 섹션 이름을 검색합니다. 코루틴(DefaultDispatch)에서 생성된 나머지 스레드 중 하나에서 확인할 수 있습니다.

87feee260f900a76.png

7. 불필요한 하위 컴포지션 삭제

무거운 코드를 기본 스레드에서 이동했으므로 이제 컴포지션이 차단되지 않습니다. 하지만 여전히 개선의 여지가 있습니다. 각 항목에서 LazyRow 컴포저블 형식으로 불필요한 오버헤드를 삭제할 수 있습니다.

예에서 각 항목에는 태그 행이 포함되어 있습니다(다음 이미지에 강조 표시되어 있음).

e821c86604d3e670.png

이 행은 LazyRow 컴포저블로 구현됩니다. 이렇게 작성하는 것이 쉽기 때문입니다. 항목을 LazyRow 컴포저블로 전달하면 나머지는 알아서 처리됩니다.

@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    // TODO: remove unnecessary lazy layout
    LazyRow(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        items(tags) { ItemTag(it) }
    }
}

문제는 Lazy 레이아웃이 제한된 크기보다 훨씬 많은 항목이 있는 레이아웃에서 뛰어나지만 비용이 추가로 발생하며 이는 지연 컴포지션이 필요하지 않은 경우 불필요하다는 점입니다.

SubcomposeLayout 컴포저블을 사용하는 Lazy 컴포저블의 특성을 고려할 때 항상 여러 작업 청크로 표시됩니다. 먼저 컨테이너가 표시되고 두 번째 작업 청크로 현재 화면에 표시된 항목이 표시됩니다. 시스템 트레이스에서 compose:lazylist:prefetch 트레이스를 확인할 수도 있으며 이는 추가 항목이 표시 영역으로 들어가므로 사전에 준비되도록 미리 가져오는 것을 나타냅니다.

b3dc3662b5885a2e.jpeg

대략적으로 걸리는 시간을 파악하려면 동일한 트레이스 파일을 엽니다. 상위 항목에서 분리된 섹션이 있습니다. 각 항목은 구성 중인 실제 항목과 태그 항목으로 구성됩니다. 이렇게 하면 각 항목의 컴포지션 시간이 약 2.5ms가 되며 이는 표시되는 항목 수를 곱할 경우 또 다른 큰 작업 청크가 됩니다.

a204721c80497e0f.jpeg

이 문제를 해결하려면 다음 단계를 따르세요.

  1. AccelerateHeavyScreen.kt 파일로 이동하여 ItemTags 컴포저블을 찾습니다.
  2. LazyRow 구현을 tags 목록을 반복하는 Row 컴포저블로 변경합니다(다음 스니펫 참고).
@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth()
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        tags.forEach { ItemTag(it) }
    }
}
  1. 벤치마크를 다시 실행하면 앱이 빌드됩니다.
  2. 선택사항: 코드가 수정된 시스템 추적을 다운로드합니다.

  1. ItemTag 섹션을 찾아 시간이 덜 걸리는지, 동일한 Compose:recompose 루트 섹션을 사용하는지 확인합니다.

219cd2e961defd1.jpeg

SubcomposeLayout 컴포저블(예: BoxWithConstraints 컴포저블)을 사용하는 다른 컨테이너에서도 유사한 상황이 발생할 수 있습니다. Compose:recompose 섹션 전반에 걸쳐 항목 생성을 포괄할 수 있으며 이는 버벅거리는 프레임으로 직접 표시되지는 않지만 사용자에게는 표시될 수 있습니다. 가능하면 각 항목에서 BoxWithConstraints 컴포저블은 사용하지 마세요. 사용 가능한 공간에 따라 다른 UI를 구성할 때만 필요할 수 있기 때문입니다.

이 섹션에서는 너무 오래 걸리는 컴포지션을 수정하는 방법을 알아봤습니다.

8. 초기 벤치마크와 결과 비교

성능을 위해 화면을 최적화하는 작업을 마쳤으므로 벤치마크 결과를 초기 결과와 비교해야 합니다.

  1. Android 스튜디오 실행 창 667294bf641c8fc2.png에서 Test History를 엽니다.
  2. 변경사항이 없는 초기 벤치마크와 관련된 가장 오래된 실행을 선택하고 frameDurationCpuMsframeOverrunMs 측정항목을 비교합니다. 다음 표와 비슷한 결과가 표시됩니다.

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0
  1. 최적화가 모두 적용된 벤치마크와 관련된 최신 실행을 선택합니다. 다음 표와 비슷한 결과가 표시됩니다.

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min   2.9,   median   2.9,   max   2.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.4,   median   3.4,   max   3.4
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.1,   median   1.1,   max   1.1
frameDurationCpuMs                  P50    4.3,   P90    7.7,   P95    8.8,   P99   33.1
frameOverrunMs                      P50  -11.4,   P90   -8.3,   P95   -7.3,   P99   41.8
Traces: Iteration 0

특히 frameOverrunMs 행을 보면 모든 백분위수가 개선된 것을 확인할 수 있습니다.

P50

P90

P95

P99

-4.2

-3.5

-3.2

74.9

-11.4

-8.3

-7.3

41.8

개선사항

171%

137%

128%

44%

다음 섹션에서는 너무 자주 발생하는 컴포지션을 수정하는 방법을 알아봅니다.

9. 불필요한 리컴포지션 방지

Compose에는 3단계가 있습니다.

  • 컴포지션: 표시할 내용을 컴포저블 트리를 빌드하여 결정합니다.
  • 레이아웃: 이 트리를 사용해 화면에서 컴포저블을 표시할 위치를 결정합니다.
  • 그리기: 화면에 컴포저블을 그립니다.

이 단계의 순서는 일반적으로 동일하므로 데이터가 컴포지션에서 레이아웃, 그리기로 한 방향으로 흘러 UI 프레임을 생성할 수 있습니다.

2147ae29192a1556.png

BoxWithConstraints, 지연 레이아웃(예: LazyColumn 또는 LazyVerticalGrid), SubcomposeLayout 컴포저블을 기반으로 하는 모든 레이아웃은 중요한 예외입니다. 여기서 하위 요소의 컴포지션은 상위 요소의 레이아웃 단계를 따릅니다.

일반적으로 컴포지션은 실행할 때 가장 비용이 많이 드는 단계입니다. 할 작업이 가장 많고 다른 관련 없는 컴포저블이 재구성될 수도 있기 때문입니다.

대다수 프레임에는 세 단계가 모두 포함되지만 Compose에서는 실행할 작업이 없는 경우 단계를 완전히 건너뛸 수 있습니다. 이 기능을 활용하여 앱 성능을 향상할 수 있습니다.

람다 수정자로 컴포지션 단계 연기

컴포저블 함수는 컴포지션 단계에서 실행됩니다. 코드를 다른 시간에 실행하려면 람다 함수로 제공하면 됩니다.

방법은 다음과 같습니다.

  1. PhasesComposeLogo.kt 파일을 엽니다.
  2. 앱에서 작업 2 화면으로 이동합니다. 화면 가장자리에서 로고가 튀는 것을 볼 수 있습니다.
  3. Layout Inspector를 열고 리컴포지션 횟수를 검사합니다. 리컴포지션 횟수가 급증하는 것을 확인할 수 있습니다.

a9e52e8ccf0d31c1.png

  1. 선택사항: PhasesComposeLogoBenchmark.kt 파일을 찾아 실행하여 시스템 트레이스를 가져오고 모든 프레임에서 발생하는 PhasesComposeLogo 트레이스 섹션의 컴포지션을 확인합니다. 리컴포지션이 동일한 이름의 반복되는 섹션으로 트레이스에 표시됩니다.

4b6e72578c89b2c1.jpeg 7036a895a31138d3.png

  1. 필요한 경우 프로파일러와 Layout Inspector를 닫고 코드로 돌아갑니다. PhaseComposeLogo 컴포저블이 다음과 같이 표시됩니다.
@Composable
fun PhasesComposeLogo() = trace("PhasesComposeLogo") {
    val logo = painterResource(id = R.drawable.compose_logo)
    var size by remember { mutableStateOf(IntSize.Zero) }
    val logoPosition by logoPosition(size = size, logoSize = logo.intrinsicSize)

    Box(
        modifier = Modifier
            .fillMaxSize()
            .onPlaced {
                size = it.size
            }
    ) {
        with(LocalDensity.current) {
            Image(
                painter = logo,
                contentDescription = "logo",
                modifier = Modifier.offset(logoPosition.x.toDp(), logoPosition.y.toDp())
            )
        }
    }
}

logoPosition 컴포저블은 프레임마다 상태를 변경하는 로직을 포함하며 다음과 같이 표시됩니다.

@Composable
fun logoPosition(size: IntSize, logoSize: Size): State<IntOffset> =
    produceState(initialValue = IntOffset.Zero, size, logoSize) {
        if (size == IntSize.Zero) {
            this.value = IntOffset.Zero
            return@produceState
        }

        var xDirection = 1
        var yDirection = 1

        while (true) {
            withFrameMillis {
                value += IntOffset(x = MOVE_SPEED * xDirection, y = MOVE_SPEED * yDirection)

                if (value.x <= 0 || value.x >= size.width - logoSize.width) {
                    xDirection *= -1
                }

                if (value.y <= 0 || value.y >= size.height - logoSize.height) {
                    yDirection *= -1
                }
            }
        }
    }

Modifier.offset(x.dp, y.dp) 수정자를 사용하여 PhasesComposeLogo 컴포저블에서 상태를 읽습니다. 즉, 컴포지션에서 읽습니다.

이 수정자로 인해 이 애니메이션의 모든 프레임에서 앱이 재구성됩니다. 여기서는 람다 기반 Offset 수정자라는 간단한 대안이 있습니다.

  1. IntOffset 객체를 반환하는 람다를 허용하는 Modifier.offset 수정자를 사용하도록 Image 컴포저블을 업데이합니다(다음 스니펫 참고).
Image(
  painter = logo,
  contentDescription = "logo",
  modifier = Modifier.offset { IntOffset(logoPosition.x,  logoPosition.y) }
)
  1. 앱을 다시 실행하고 Layout Inspector를 확인합니다. 애니메이션이 더 이상 리컴포지션을 생성하지 않습니다.

특히 스크롤 중에 화면의 레이아웃을 조정하기 위해 재구성할 필요는 없습니다. 버벅거리는 프레임이 발생합니다. 스크롤 중에 발생하는 리컴포지션은 거의 항상 불필요하므로 피해야 합니다.

기타 람다 수정자

Modifier.offset 수정자가 람다 버전의 유일한 수정자는 아닙니다. 다음 표에는 매번 재구성되는 일반적인 수정자가 나와 있으며 이는 자주 변경되는 상태 값을 전달할 때 지연된 대안으로 대체될 수 있습니다.

일반적인 수정자

지연된 대안

.background(color)

.drawBehind { drawRect(color) }

.offset(0.dp, y)

.offset { IntOffset(0, y.roundToPx()) }

.alpha(a).rotate(r).scale(s)

.graphicsLayer { alpha = a; rotationZ = r; scaleX = s; scaleY = s}

10. 맞춤 레이아웃으로 Compose 단계 연기

람다 기반 수정자를 사용하는 방법이 컴포지션의 무효화를 피하는 가장 쉬운 방법인 때가 많지만 필요한 작업을 실행하는 람다 기반 수정자가 없을 때도 있습니다. 이 경우 맞춤 레이아웃이나 Canvas 컴포저블을 직접 구현하여 그리기 단계로 바로 넘어갈 수 있습니다. 맞춤 레이아웃 내에서 실행된 Compose 상태 읽기는 레이아웃만 무효화하고 리컴포지션을 건너뜁니다. 일반 가이드라인에 따르면 레이아웃이나 크기만 조정하고 컴포저블을 추가하거나 삭제하지 않으려는 경우 컴포지션을 전혀 무효화하지 않고도 그 효과를 달성할 수 있는 때가 많습니다.

방법은 다음과 같습니다.

  1. PhasesAnimatedShape.kt 파일을 열고 앱을 실행합니다.
  2. 작업 3 화면으로 이동합니다. 이 화면에는 버튼을 클릭하면 크기가 변경되는 도형이 포함되어 있습니다. 크기 값은 animateDpAsState Compose Animation API로 애니메이션 처리됩니다.

51dc23231ebd5f1a.gif

  1. Layout Inspector를 엽니다.
  2. 크기 전환을 클릭합니다.
  3. 애니메이션의 프레임마다 도형이 재구성되는지 확인합니다.

63d597a98fca1133.png

MyShape 컴포저블은 size 객체를 상태 읽기인 매개변수로 사용합니다. 즉, size 객체가 변경되면 PhasesAnimatedShape 컴포저블(가장 가까운 리컴포지션 범위)이 재구성되고 그 후에 MyShape 컴포저블이 재구성됩니다. 입력이 변경되었기 때문입니다.

리컴포지션을 건너뛰려면 다음 단계를 따르세요.

  1. 크기 변경으로 MyShape 컴포저블이 바로 재구성되지 않도록 size 매개변수를 람다 함수로 변경합니다.
@Composable
fun MyShape(
    size: () -> Dp,
    modifier: Modifier = Modifier
) {
  // ...
  1. 람다 함수를 사용하도록 PhasesAnimatedShape 컴포저블의 호출 사이트를 업데이트합니다.
MyShape(size = { size }, modifier = Modifier.align(Alignment.Center))

size 매개변수를 람다로 변경하면 상태 읽기가 지연됩니다. 이제 람다가 호출될 때 발생합니다.

  1. MyShape 컴포저블의 본문을 다음과 같이 변경합니다.
Box(
    modifier = modifier
        .background(color = Purple80, shape = CircleShape)
        .layout { measurable, _ ->
            val sizePx = size()
                .roundToPx()
                .coerceAtLeast(0)

            val constraints = Constraints.fixed(
                width = sizePx,
                height = sizePx,
            )

            val placeable = measurable.measure(constraints)
            layout(sizePx, sizePx) {
                placeable.place(0, 0)
            }
        }
)

layout 수정자 측정 람다의 첫째 줄에서 size 람다가 호출됩니다. 이는 layout 수정자 내부이므로 레이아웃만 무효화하고 컴포지션은 무효화하지 않습니다.

  1. 앱을 다시 실행하고 작업 3 화면으로 이동한 후 Layout Inspector를 엽니다.
  2. 크기 전환을 클릭하면 도형 크기가 전과 동일하게 애니메이션 처리되지만 MyShape 컴포저블은 재구성되지 않습니다.

11. 안정적인 클래스로 리컴포지션 방지

Compose에서는 모든 입력 매개변수가 안정적이고 이전 컴포지션에서 변경되지 않은 경우 컴포저블 실행을 건너뛸 수 있는 코드를 생성합니다. 변경 불가능하거나 Compose 엔진에서 값이 재구성 간에 변경되었는지 알 수 있으면 안정적인 유형입니다.

Compose 엔진에서 컴포저블이 안정적인지 알 수 없다면 불안정한 것으로 간주하고 리컴포지션을 건너뛰는 코드 로직을 생성하지 않습니다. 즉, 컴포저블이 매번 재구성됩니다. 이는 클래스가 프리미티브 유형이 아니고 다음 상황 중 하나가 발생할 때 발생할 수 있습니다.

  • 변경 가능한 클래스입니다. 예를 들어 변경 가능한 속성이 포함되어 있습니다.
  • Compose를 사용하지 않는 Gradle 모듈에서 정의된 클래스입니다. Compose 컴파일러 종속 항목이 없습니다.
  • 불안정한 속성이 포함된 클래스입니다.

이 동작은 성능 문제를 일으키는 일부 사례에서 바람직하지 않을 수 있으며 다음을 실행하면 변경될 수 있습니다.

  • 강력한 건너뛰기 모드를 사용 설정합니다.
  • 매개변수에 @Immutable 또는 @Stable 주석을 답니다.
  • 안정성 구성 파일에 클래스를 추가합니다.

안정성에 관한 자세한 내용은 문서를 참고하세요.

이 작업에서는 추가하거나 삭제하거나 확인할 수 있는 항목의 목록이 있으므로 리컴포지션이 불필요할 때 항목이 재구성되지 않도록 해야 합니다. 두 가지 유형의 항목이 있으며 매번 재생성되는 항목과 재생성되지 않는 항목 사이를 오갑니다.

매번 재생성되는 항목은 데이터가 로컬 데이터베이스(예: Room 또는 sqlDelight)나 원격 데이터 소스(예: API 요청이나 Firestore 항목)에서 비롯되고 변경이 있을 때마다 객체의 새 인스턴스를 반환하는 실제 사용 사례의 시뮬레이션으로 여기 나와 있습니다.

여러 컴포저블에 Modifier.recomposeHighlighter() 수정자가 연결되어 있으며 이는 GitHub 저장소에서 확인할 수 있습니다. 이 수정자는 컴포저블이 재구성될 때마다 경계를 표시하며 Layout Inspector의 임시 대체 솔루션 역할을 할 수 있습니다.

127f2e4a2fc1a381.gif

강력한 건너뛰기 모드 사용 설정

Jetpack Compose 컴파일러 1.5.4 이상에는 강력한 건너뛰기 모드를 사용 설정하는 옵션이 있습니다. 즉, 매개변수가 불안정한 컴포저블이라도 건너뛰기 코드를 생성할 수 있습니다. 이 모드는 프로젝트에서 건너뛸 수 없는 컴포저블의 양을 크게 줄일 것으로 예상되므로 코드 변경 없이 성능을 개선할 수 있습니다.

불안정한 매개변수의 경우 인스턴스 동등성으로 건너뛰기 로직을 비교합니다. 즉, 이전 사례와 같이 동일한 인스턴스를 컴포저블에 전달한 경우 매개변수를 건너뜁니다. 이에 반해 안정적인 매개변수는 구조적 동등성을 사용하여(Object.equals() 메서드 호출을 통해) 건너뛰기 로직을 결정합니다.

건너뛰기 로직 외에도 강력한 건너뛰기 모드는 컴포저블 함수 내에 있는 람다도 자동으로 기억합니다. 이 사실은 예를 들어 ViewModel 메서드를 호출하는 람다 함수를 래핑하기 위해 remember 호출이 필요하지 않음을 의미합니다.

강력한 건너뛰기 모드는 Gradle 모듈 단위로 사용 설정할 수 있습니다.

사용 설정하려면 다음 단계를 따르세요.

  1. build.gradle.kts 파일을 엽니다.
  2. 다음 스니펫을 사용하여 composeCompiler 블록을 업데이트합니다.
composeCompiler {
    // Not required in Kotlin 2.0 final release
    suppressKotlinVersionCompatibilityCheck = "2.0.0-RC1"

    // This settings enables strong-skipping mode for all module in this project.
    // As an effect, Compose can skip a composable even if it's unstable by comparing it's instance equality (===).
    enableExperimentalStrongSkippingMode = true
}

그러면 Gradle 모듈에 experimentalStrongSkipping 컴파일러 인수가 추가됩니다.

  1. b8a9619d159a7d8e.png Sync Project with Gradle Files를 클릭합니다.
  2. 프로젝트를 다시 빌드합니다.
  3. 작업 5 화면을 열면 구조적 동등성을 사용하는 항목이 EQU 아이콘으로 표시되며 항목 목록과 상호작용할 때 재구성되지 않습니다.

1de2fd2c42a1f04f.gif

하지만 다른 유형의 항목은 여전히 재구성됩니다. 이 문제는 다음 단계에서 해결합니다.

주석으로 안정성 수정

이전에 언급했듯이 강력한 건너뛰기 모드를 사용 설정하면 매개변수에 이전 컴포지션과 동일한 인스턴스가 있는 경우 컴포저블이 실행을 건너뜁니다. 하지만 변경될 때마다 불안정한 클래스의 새 인스턴스가 제공되는 상황에는 적용되지 않습니다.

여기에서는 StabilityItem 클래스가 불안정합니다. 불안정한 LocalDateTime 속성이 포함되어 있기 때문입니다.

이 클래스의 안정성 문제를 해결하려면 다음 단계를 따르세요.

  1. StabilityViewModel.kt 파일로 이동합니다.
  2. StabilityItem 클래스를 찾아 @Immutable 주석을 답니다.
// TODO Codelab task: make this class Stable
@Immutable
data class StabilityItem(
    val id: Int,
    val type: StabilityItemType,
    val name: String,
    val checked: Boolean,
    val created: LocalDateTime
)
  1. 앱을 다시 빌드합니다.
  2. 작업 5로 이동하면 재구성되는 목록 항목이 없습니다.

938aad77b78f7590.gif

이 클래스는 이제 구조적 동등성을 사용하여 이전 컴포지션에서 변경되었는지 확인하고 재구성하지 않습니다.

최신 변경 날짜를 참조하는 컴포저블이 여전히 있으며 이는 지금까지 실행한 작업과 상관없이 계속 재구성됩니다.

구성 파일로 안정성 문제 수정

이전 접근 방식은 코드베이스에 포함되는 클래스에 적합합니다. 하지만 서드 파티 라이브러리의 클래스나 표준 라이브러리 클래스 등 사용할 수 없는 클래스는 수정할 수 없습니다.

안정적인 것으로 간주될 클래스(가능한 와일드 카드 포함)를 사용하는 안정성 구성 파일을 사용 설정할 수 있습니다.

사용 설정하려면 다음 단계를 따르세요.

  1. build.gradle.kts 파일로 이동합니다.
  2. composeCompiler 블록에 stabilityConfigurationFile 옵션을 추가합니다.
composeCompiler {
    ...

    stabilityConfigurationFile = project.rootDir.resolve("stability_config.conf")
}
  1. 프로젝트를 Gradle 파일과 동기화합니다.
  2. 이 프로젝트의 루트 폴더에서 README.md 파일 옆에 있는 stability_config.conf 파일을 엽니다.
  3. 다음을 추가합니다.
// TODO Codelab task: Make a java.time.LocalDate class stable.
java.time.LocalDate
  1. 앱을 다시 빌드합니다. 날짜가 동일하게 유지되면 LocalDateTime 클래스로 인해 Latest change was YYYY-MM-DD 컴포저블이 재구성되지 않습니다.

332ab0b2c91617f2.gif

앱에서 패턴을 포함하도록 파일을 확장할 수 있으므로 안정적인 것으로 간주되어야 하는 클래스를 모두 작성할 필요는 없습니다. 따라서 여기서는 java.time.* 와일드 카드를 사용할 수 있으며 이는 Instant, LocalDateTime, ZoneId, Java 시간의 기타 클래스 등 패키지의 모든 클래스를 안정적인 것으로 간주합니다.

단계를 따르면 이 화면에서 재구성되는 항목은 없습니다. 단, 예상되는 동작인 추가하거나 상호작용한 항목은 예외입니다.

12. 축하합니다

축하합니다. Compose 앱의 성능을 최적화했습니다. 앱에서 발생할 수 있는 성능 문제의 일부만 표시하면서 잠재적인 다른 문제를 살펴보는 방법과 이를 수정하는 방법을 알아봤습니다.

다음 단계

앱의 기준 프로필을 생성하지 않았다면 생성하는 것이 좋습니다.

기준 프로필을 사용하여 앱 성능 개선 Codelab을 따를 수 있습니다. 벤치마크 설정에 관한 자세한 내용은 Macrobenchmark를 사용하여 앱 성능 검사 Codelab을 참고하세요.

자세히 알아보기