본문 바로가기

Android/Tech

[Jetpack Compose] Stability Configuration File 은 어떻게 동작하는가

Unsplash, Rock Staar.

동기

Jetpack Compose 를 활용한 개발 도중, StableMarker 를 잘 작성해왔음에도 불구하고 화면 렌더링 시의 Janky Frame 이 발생하는 것을 확인했습니다. Jetpack Compose 외부 라이브러리가 그 원인이었는데, 내부 데이터 클래스에 StableMarker 가 작성되어 있지 않았기 때문입니다. 

 

이를 해결하기 위해 Stability Configuration File 을 정의 및 적용 하였고, 이에 대한 문제를 해결할 수 있었습니다. 동작 과정에 의문이 생겼고, 이에 대해 학습했습니다.


 

 

[Jetpack Compose] 불필요한 Recomposition 을 줄여 앱 퍼포먼스 개선하기

동기 Jetpack Compose 를 활용하여 개발 중인 앱 에는 드래그 앤 드랍과 같은 유저 인터랙션이 존재합니다. 다만 문제가 좀 있었습니다. 드래그 앤 드랍 시 화면이 버벅거린다는 점이었고, 이는 매우

blothhundr.tistory.com

@Stability, @Immutable 을 통한 Jetpack Compose 최적화에 관해서는 위 포스트에 자세히 기술하였습니다.

 

짧게 정리하자면, 특정 타입이 불변하다면 불필요한 Recomposition 을 건너 뛰어 렌더링을 최적화할 수 있도록 고안된 Annotation 입니다.

 

StableMarker 를 잘 작성한다면 불필요한 Recomposition 을 스킵할 수 있기 때문에, StableMarker 를 잘 붙이면 되는 것은 맞지만, 그럼에도 불구하고 Stability Configuration File 이 필요한 이유는, 외부 라이브러리에 선언된 데이터 클래스에는 StableMarker 를 붙일 수 없기 때문입니다. 


Stability Configuration File

빌드 타임에 Compose 가 컴파일러 플러그인을 통해 안정성 여부를 판단하는데, 이 때 데이터 클래스 등에 직접 작성한 StableMarker 를 확인합니다. 이에 대해 Compose Compiler 는 해당 데이터 클래스는 불변하는 것으로 판단하고 이에 대한 Recomposition 을 건너뛰게 됩니다.

 

적용 방법은 간단한데, 별도의 stability_config.conf 파일을 프로젝트 루트에 생성하고, 해당 파일의 경로를 Gradle 에 작성해주면 됩니다.

 

android {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P",
            "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${project.rootDir}/compose/stability_config.conf"
        )
    }
}
com.example.stabilityconf.StableUser

 

클래스 단위로 stable 을 작성할 수도 있고, 패키지 단위로 stable 을 작성할 수도 있습니다. 와일드카드 문법을 통해 패키지에 존재하는 모든 클래스를 stable 하게 처리할 수도 있습니다.

 

개인적으로는 클래스 단위에 적용하는 것을 선호하는데, 잘못된 안정성 설정은 의도한 Recomposition 이 발생하지 않게 될 수도 있기 때문입니다. 


어떻게 적용되는가?

 

kotlin/plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/StabilityConfigParser.kt

The Kotlin Programming Language. . Contribute to JetBrains/kotlin development by creating an account on GitHub.

github.com

Stability Config File 을 파싱하는 Parser 클래스 소스 코드입니다. 자세히 보실 분은 확인해보시면 좋겠습니다.

init {
	val matchers: MutableSet<FqNameMatcher> = mutableSetOf()

	lines.forEachIndexed { index, line ->
			val l = line.trim()
		if (!l.startsWith(COMMENT_DELIMITER) && !l.isBlank()) {
			if (l.contains(COMMENT_DELIMITER)) { // com.foo.bar //comment
				error(
					errorMessage(
						line,
						index,
						"Comments are only supported at the start of a line."
					)
				)
			}
			try {
				matchers.add(FqNameMatcher(l))
			} catch (exception: IllegalStateException) {
				error(
					errorMessage(line, index, exception.message ?: "")
				)
			}
		}
	}

	stableTypeMatchers = matchers.toSet()
}

 

매우 간단한데, 한 줄 씩 읽어서 유효성 검사 이후에 필드에 선언된 Set 에 추가합니다. 이 Set 은 아래 깃헙에 작성된 코드에서 사용됨을 알 수 있습니다.

 

 

kotlin/plugins/compose/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/Stability.kt at master ·

The Kotlin Programming Language. . Contribute to JetBrains/kotlin development by creating an account on GitHub.

github.com

 

if (canInferStability(declaration) || declaration.isExternalStableType()) {
	val fqName = declaration.fqNameWhenAvailable?.toString() ?: ""
	val typeParameters = declaration.typeParameters
	val stability: Stability
	val mask: Int
	if (KnownStableConstructs.stableTypes.contains(fqName)) {
		mask = KnownStableConstructs.stableTypes[fqName] ?: 0
		stability = Stability.Stable
	} else if (declaration.isExternalStableType()) {
		mask = externalTypeMatcherCollection
		.maskForName(declaration.fqNameWhenAvailable) ?: 0
		stability = Stability.Stable
	} else if (declaration.isInterface && declaration.isInCurrentModule()) {
		// trying to avoid extracting stability bitmask for interfaces in current module
		// to support incremental compilation
		return Stability.Unknown(declaration)
	} else {
		val bitmask = declaration.stabilityParamBitmask() ?: return Stability.Unstable

		val knownStableMask =
		if (typeParameters.size < 32) 0b1 shl typeParameters.size else 0
		val isKnownStable = bitmask and knownStableMask != 0
		mask = bitmask and knownStableMask.inv()

		// supporting incremental compilation, where declaration stubs can be
		// in the same module, so we need to use already inferred values
		stability = if (isKnownStable && declaration.isInCurrentModule()) {
			Stability.Stable
		} else {
			Stability.Runtime(declaration)
		}
	}
		return when {
		mask == 0 || typeParameters.isEmpty() -> stability
		else -> stability + Stability.Combined(
			typeParameters.mapIndexedNotNull { index, irTypeParameter ->
			if (index >= 32) return@mapIndexedNotNull null
			if (mask and (0b1 shl index) != 0) {
				val sub = substitutions[irTypeParameter.symbol]
				if (sub != null)
				stabilityOf(sub, substitutions, analyzing)
				else
				Stability.Parameter(irTypeParameter)
			} else null
		}
		)
	}
}

 

안정성 추론이 가능한 선언, 외부에서 선언된 안정적 타입인지 확인을 하고, 이에 따라 Stable 처리 해주는 코드입니다.

 

else if (declaration.isExternalStableType()) {
    mask = externalTypeMatcherCollection
        .maskForName(declaration.fqNameWhenAvailable) ?: 0
    stability = Stability.Stable
}

 

이 부분을 자세히 보면 나오는데, declaration.fqNameWhenAvailable 을 참조, Stability Configuration File 에서 매핑된 마스크(타입 파라미터별 안정성 추적을 위해 비트마스크를 이용하므로 비트마스크의 마스크)를 찾아서 가져옵니다. 이후 해당 마스크를 적용하여 stability 를 Stable 로 처리합니다.

 

전체 흐름을 요약하자면 다음과 같습니다.

 

  1. stability_config.conf 정의
  2. build.gradle.kts - kotlinOptions - freeCompilerArgs 로 경로 전달
  3. Compose Compiler Plugin 작동
  4. ComposeConfiguration.kt -> stabilityConfigurationPath 로 전달 받음
  5. StabilityConfigParser.parse() 로 파싱
  6. StabilityConifg 객체 생성
  7. StabilityInferencer 에서 stability 분석 시 우선적으로 참조
  8. IR Lowering 단계에서 recomposition skip 등 최적화 반영

테스트

data class StableUser(
    val list: List<Int>,
    val name: String,
    val dynamicValue: Int,
)

data class UnstableUser(
    val list: List<Int>,
    val name: String,
    val dynamicValue: Int,
)

 

인터페이스인 List<T> 를 포함시켜 두 데이터 클래스 모두 의도적으로 Unstable 한 상태가 되도록 구성하였습니다. 

이후 이전에 작성했던 stability_config.conf 파일을 적용시키면, StableUser 만 Stable 한 상태가 되어야 합니다.

Layout Inspector, 위에서 아래로 적용 전, 후

 

적용 전의 경우, StableUserInfo 및 UnstableUserInfo 모두 발생하지 않는 Recomposition 을 건너뛰지 않습니다. 앞서 기술한 바와 같이, 프로퍼티에 Unstable 한 타입 List<T> 를 갖고 있기 때문입니다.

 

적용 후의 경우, StableUser 데이터 클래스는 Unstable 하지만 Stability Configuration File 에서 이를 Stable 하다고 인식하도록 강제로 처리해두었기 때문에, 실제로는 Unstable 한 상태이지만 발생하는 Recomposition 을 건너뛰고 있습니다.


다소 공수가 동반되지만, 부드러운 UI 렌더링을 위해서는 필수적인 최적화 과정입니다.

사실 소스 내부에 직접 작성하는 데이터 클래스들의 경우, StableMarker 를 작성해주는 것이 훨씬 간편한 해결 방법입니다만, 그러지 못하는 경우에 사용하면 꽤 큰 효과를 볼 수 있습니다.

 

한창 알고리즘 공부를 하던 시절, 비트마스크는 개인적으로 어려워했던 알고리즘 카테고리였습니다. 공부를 하면서도 비트마스크는 도대체 어디에 쓰일까 싶었는데... Compiler 같은 경우엔 성능이 최우선시 되어야 하다 보니, 이러한 플래깅에 사용되고 있었습니다. 역시 배움에는 다 쓸모가 있습니다.