안정성 문제 해결

성능 문제를 일으키는 불안정한 클래스가 있는 경우 Compose에서 이를 비교하거나 인스턴스화하는 방식을 최적화해야 합니다. 이 문서에서는 이를 위해 사용할 수 있는 몇 가지 기법을 간략하게 설명합니다.

강력한 건너뛰기 사용 설정

강력한 건너뛰기 모드는 Kotlin 2.0.20부터 기본적으로 사용 설정됩니다. 강력한 건너뛰기 모드를 사용하면 인스턴스 동등성 (===)을 사용하여 불안정한 매개변수를 비교하여 불안정한 매개변수가 있는 컴포저블을 건너뛸 수 있지만 안정적인 매개변수는 구조적 동등성(Object.equals())을 사용하여 계속 비교됩니다.

2.0.20보다 낮은 Kotlin 버전을 사용하는 경우 먼저 강력한 건너뛰기 모드를 사용 설정합니다. 강력한 건너뛰기 모드가 이미 사용 설정되어 있고 컴포저블이 여전히 불필요하게 재구성되는 경우 리컴포지션 패스마다 불안정한 매개변수의 새 인스턴스가 할당되는지 확인합니다.

자세한 내용은 강력한 건너뛰기를 참고하세요.

클래스를 변경 불가능하게 만들기

불안정한 클래스를 완전히 변경 불가능하게 만들어 컴파일러가 안정성을 자동으로 추론하도록 할 수도 있습니다.

  • 변경 불가: 해당 유형의 인스턴스가 생성된 후에는 속성 값을 변경할 수 없고 모든 메서드가 참조 투명한 유형을 나타냅니다.
    • 클래스의 모든 속성이 var가 아닌 val이고 불변 유형인지 확인합니다.
    • String, Int, Float와 같은 기본 유형은 항상 변경할 수 없습니다.
    • Compose 컴파일러로 빌드된 모듈에서 val 기본 유형 또는 String 속성이 있는 플랫 data class 유형은 이미 안정적인 것으로 추론되고 자동으로 .equals()와 비교됩니다. @Immutable로 주석을 달 필요가 없습니다.
    • 불변성이 불가능한 경우 변경 가능한 속성에 Compose 상태를 사용해야 합니다.
  • Stable: 변경 가능한 유형을 나타냅니다. 유형의 공개 속성 또는 메서드 동작이 이전 호출과 다른 결과를 생성하는 경우와 시점을 Compose 런타임은 인식하지 못합니다.

컬렉션 및 안정성

Compose에서 클래스를 불안정하다고 간주하는 일반적인 이유는 컬렉션입니다. 안정성 문제 진단 페이지에 설명된 대로 Compose 컴파일러는 List, Map, Set와 같은 컬렉션 인터페이스가 실제로 변경 불가능한지 완전히 확신할 수 없으므로 불안정으로 표시합니다.

안정성 문제 진단 가이드의 다음 클래스를 살펴보세요.

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

강력한 건너뛰기 모드를 사용 설정하면 (Kotlin 2.0.20 이상의 기본값) 컬렉션을 포함하는 불안정한 컬렉션과 클래스는 동일한 참조가 전달될 때마다 인스턴스 동등성 (===)을 사용하여 이미 리컴포지션을 건너뜁니다.

kotlin.collections.List, Set 또는 Map를 Kotlinx Immutable Collections (ImmutableList, ImmutableSet, ImmutableMap)로 바꾸거나 컬렉션 보유 모델에 @Immutable, @Stable 또는 stability_config.conf 규칙을 추가하기 전에 주의해야 합니다. 강력한 건너뛰기 모드에서 표준 List는 참조 동등성 (===)을 사용하여 건너뛰는 반면, ImmutableList 및 주석이 달린 컬렉션 모델은 모든 재구성 패스에서 요소별 .equals() 비교를 실행합니다.

대규모 컬렉션이나 지연 레이아웃 (LazyColumn, LazyRow, LazyGrid)의 경우 모든 항목에서 .equals()를 확인하는 데 재구성보다 더 오래 걸릴 수 있습니다. ImmutableList 속성을 data class에서 하위 컴포저블로 전달하면 이 비용이 두 배가 됩니다.

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

컬렉션에 구조적 동등성을 강제하는 대신, Compose가 인스턴스 동등성 (===)을 사용하지 않고 건너뛸 수 있도록 여러 방출에 걸쳐 컬렉션의 인스턴스 참조를 유지하세요(예: ViewModel에서 또는 remember를 사용하여 변환을 캐싱). 컬렉션의 인스턴스 참조를 유지할 수 없고 벤치마크에서 구조적 동등성 검사가 재구성보다 빠르다고 확인된 경우에만 컬렉션 보유 모델에 ImmutableList 또는 안정성 주석을 사용하는 것을 고려하세요. 2.0.20보다 낮은 Kotlin 버전을 사용하는 경우 먼저 강력한 건너뛰기 모드를 사용 설정합니다.

Stable 또는 Immutable 주석 달기

저렴한 .equals() 검사를 통해 작은 클래스의 안정성 문제를 해결하는 한 가지 방법은 @Stable 또는 @Immutable를 사용하여 불안정한 클래스에 주석을 추가하는 것입니다.

클래스에 주석을 추가하면 컴파일러가 클래스에 관해 추론하는 내용을 재정의합니다. 이는 Kotlin의 !! 연산자와 유사합니다. 이러한 주석을 사용하는 방법에 매우 주의해야 합니다. 컴파일러 동작을 재정의하면 예상대로 컴포저블이 재구성되지 않는 등 예기치 않은 버그가 발생할 수 있습니다.

강력한 건너뛰기 모드를 사용 설정한 경우 @Stable 또는 @Immutable를 추가할지 결정할 때 다음 가이드라인을 따르세요.

  • 이미 안정적인 것으로 추론된 클래스에 주석을 달지 않음: Compose 컴파일러로 빌드된 모듈에서 안정적인 유형의 val 속성이 있는 클래스는 이미 안정적인 것으로 추론됩니다.
  • 인스턴스가 다시 생성될 때 저렴한 .equals() 검사를 사용하여 클래스에만 주석을 추가: 컴파일러가 안정성을 추론할 수 없고 (예: Compose가 아닌 모듈의 인터페이스 또는 클래스) 그리고 데이터 소스가 클래스가 작고 저렴한 .equals() 검사를 가지고 있는 경우 모든 방출에서 변경되지 않은 항목에 대해 새 객체 인스턴스를 할당하는 경우 @Stable 또는 @Immutable를 추가합니다.
  • 컬렉션이 포함된 모델에 주의: 대규모 컬렉션 (List, Set, Map)을 보유하는 데이터 모델에 @Stable 또는 @Immutable를 추가하지 마세요. 특히 지연 레이아웃 (LazyColumn, LazyRow, LazyGrid, Pager)에 사용되는 모델의 경우 더욱 그렇습니다. 이러한 모델에 주석을 달지 않으면 Compose는 모든 요소에서 구조적 .equals() 검사를 실행하는 대신 인스턴스 동등성 (===)을 사용하여 모델을 비교할 수 있습니다.

다음 스니펫은 컴파일러가 안정성을 추론할 수 없는 모듈에서 정의될 때 변경 불가능으로 주석이 지정된 플랫 데이터 클래스 (컬렉션 속성 없음)의 최소한의 예를 제공합니다.

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

@Immutable 또는 @Stable 주석을 사용하는지 여부에 관계없이 Compose 컴파일러는 SnackSummary 클래스를 안정적인 것으로 표시하고 Object.equals()를 사용하여 비교합니다.

컬렉션의 주석이 달린 클래스

List<Snack> 유형의 매개변수를 포함하는 컴포저블을 생각해 보세요.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

Snack가 안정적이거나 @Immutable로 주석이 지정되어 있어도 Compose 컴파일러는 List가 인터페이스이므로 HighlightedSnacks의 snacks 매개변수를 unstable로 표시합니다.

강력한 건너뛰기 모드를 사용 설정하면 HighlightedSnacks는 여전히 skippable로 표시되며 동일한 List 인스턴스가 전달될 때 인스턴스 동등성 (===)을 사용하여 리컴포지션을 건너뜁니다. List<Snack>를 @Immutable 래퍼 클래스로 래핑하거나, List<Snack>를 ImmutableList<Snack>로 바꾸거나, kotlin.collections.*를 안정성 구성 파일에 추가하여 snacks 매개변수를 stable로 만들 수 있습니다.

하지만 이렇게 하면 Compose가 재구성 패스마다 요소별 .equals() 비교를 실행합니다. 목록의 인스턴스 참조를 보존할 수 없고 벤치마크에서 이점이 있는 경우에만 이러한 접근 방식을 사용하세요.

안정성 구성 파일

Compose 컴파일러 1.5.5부터는 안정적인 것으로 간주할 클래스의 구성 파일을 컴파일 시간에 제공할 수 있습니다. 이렇게 하면 LocalDateTime와 같은 표준 라이브러리 클래스처럼 개발자가 제어하지 않는 클래스를 안정적인 것으로 간주할 수 있습니다.

구성 파일은 행당 클래스 하나가 있는 일반 텍스트 파일입니다. 주석, 단일 및 이중 와일드 카드가 지원됩니다.

구성 예시:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

이 기능을 사용 설정하려면 구성 파일의 경로를 Compose 컴파일러 Gradle 플러그인 구성의 composeCompiler 옵션 블록에 전달합니다.

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Compose 컴파일러는 프로젝트의 각 모듈에서 개별적으로 실행되므로 필요한 경우 모듈마다 다른 구성을 제공할 수 있습니다. 또는 프로젝트의 루트 수준에 하나의 구성을 두고 해당 경로를 각 모듈에 전달합니다.

다중 모듈

또 다른 일반적인 문제는 다중 모듈 아키텍처와 관련이 있습니다. Compose 컴파일러는 참조하는 모든 비원시 유형이 명시적으로 안정적인 것으로 표시되거나 Compose 컴파일러로 빌드된 모듈에 있는 경우에만 클래스가 안정적인지 추론할 수 있습니다.

데이터 레이어가 UI 레이어와 별도의 모듈에 있는 경우(권장되는 접근 방식) 이 문제가 발생할 수 있습니다. 강력한 건너뛰기 모드에서 Compose가 아닌 모듈의 클래스는 불안정으로 처리되며 동일한 인스턴스가 전달될 때 인스턴스 동등성 (===)을 사용하여 리컴포지션을 건너뜁니다. 그러나 데이터 레이어가 모든 방출에서 동일한 값을 가진 작고 평평한 모델의 새 인스턴스를 자주 할당하는 경우 구조적 동등성 (Object.equals())을 선택할 수 있습니다.

솔루션

이 문제를 해결하려면 다음 방법 중 하나를 사용하면 됩니다.

  1. 저렴한 .equals() 검사가 포함된 작고 평평한 클래스를 컴파일러 구성 파일에 추가합니다.
  2. 데이터 레이어 모듈에서 Compose 컴파일러를 사용 설정하거나 적절한 경우 클래스에 @Stable 또는 @Immutable 태그를 지정합니다.
    • 여기에는 데이터 레이어에 Compose 종속 항목을 추가하는 작업이 포함됩니다. 하지만 이는 Compose 런타임의 종속 항목일 뿐이며 Compose-UI의 종속 항목은 아닙니다.
  3. UI 모듈 내에서 데이터 레이어 클래스를 UI별 래퍼 클래스로 래핑합니다.

Compose 컴파일러를 사용하지 않는 외부 라이브러리를 사용하는 경우에도 동일한 문제가 발생합니다.

모든 컴포저블을 건너뛸 수 있는 것은 아님

안정성 문제를 해결할 때 모든 컴포저블을 건너뛸 수 있게 만들거나 모든 매개변수에 구조적 동등성을 강제하려고 해서는 안 됩니다. 컴포저블을 건너뛰는 데는 비용이 듭니다. 건너뛸 수 있는 컴포저블의 모든 매개변수는 기억된 것처럼 Compose 런타임에 의해 저장되며 Compose는 함수가 재구성될 때마다 각 매개변수를 이전 값과 비교하여 건너뛰어야 하는지 결정해야 합니다. 모든 곳에서 건너뛰기를 강제하려고 하면 절약하는 것보다 더 많은 오버헤드를 유발하는 조기 최적화가 발생할 수 있습니다.

구조적 안정성을 강제하는 것이 실질적인 이점이 없고 성능이나 유지관리성을 저하시킬 수 있는 상황이 많습니다. 예를 들면 다음과 같습니다.

  • 자주 재구성되지 않거나 전혀 재구성되지 않는 컴포저블입니다.
  • 자체적으로 건너뛸 수 있는 컴포저블만 호출하는 컴포저블입니다.
  • 매개변수가 많은 컴포저블 또는 비용이 많이 드는 .equals() 구현이 있는 컬렉션 모델 이 경우 매개변수가 변경되었는지 확인하는 비용이 저렴한 리컴포지션 비용보다 클 수 있습니다.

재구성 횟수에만 지나치게 집중하지 마세요. 컴포저블을 가볍게 만들어 실행하고, 빠르게 변화하는 상태 읽기를 레이아웃 또는 그리기 단계로 지연시키고, 벤치마크로 변경사항을 확인하는 데 집중하세요. 컴포저블을 건너뛸 수 있는 경우 약간의 오버헤드가 추가되는데, 이는 가치가 없을 수 있습니다. 다시 시작 가능하거나 건너뛸 수 있는 것이 가치보다 오버헤드가 더 크다고 판단되는 경우 컴포저블에 다시 시작할 수 없음 또는 건너뛸 수 없음으로 주석을 달 수도 있습니다.