https://developer.android.com/develop/ui/compose/performance
Compose가 프레임을 업데이트할 때의 단계
Compose는 필요하지 않은 경우 각 단계를 건너뛸 수 있다.
Baseline Profiles은 포함된 코드 경로에 대한 해석 및 JIT(Just-In-Time) 컴파일 단계를 방지하여 첫 번째 실행보다 코드 실행 속도를 향상시킨다. 앱이나 라이브러리에 기본 프로필을 제공하면 ART(Android 런타임)를 활성화하여 AOT(Ahead-of-Time) 컴파일을 통해 포함된 코드 경로를 최적화하여 모든 새 앱 설치 및 모든 앱 업데이트에 대한 성능 향상을 제공할 수 있다
프로필은 중요한 사용자 여정에 필요한 클래스와 메소드를 정의하고 앱의 APK/AAB와 함께 배포.
-> 앱 설치 중
에 ART는 앱이 실행될 때 사용할 수 있도록 이 중요한 코드 AOT를 컴파일
-> Compose와 함께 제공되는 Baseline Profiles에는 Compose 라이브러리 내의 코드에 대한 최적화만
포함
Macrobenchmark를 사용하여 Baseline Profiles을 만들 수 있다
모든 매개변수가 val
키워드로 정의된 Primitive 타입
data class Contact(val name: String, val number: String)
@Composable
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
Row(modifier) {
ContactDetails(contact)
ToggleButton(selected, onToggled = { selected = !selected })
}
}
ToggleButton 선택시 상태변경 모습
ContactRow
내에서 코드를 recomposition해야 하는지 평가ContactDetails
의 파라미터가 Contact
유형임을 확인Contact
는 Immutable data 클래스이므로 Compose는 ContactDetails
의 파라미터가 변경되지 않았음을 확인ContactDetails
를 건너뛰고 recomposition skipToggleButton
의 파라미터가 변경되었으며 Compose는 이 컴포넌트를 recompositiondata class Contact(var name: String, var number: String)
속성이 변경되면 Compose가 인식하지 못함 -> Compose가 Compose 상태 객체의 변경사항만 추적하기 때문
불안정한 클래스의 recomposition을 건너뛰지 않음
-> ContactRow는 selected가 변경될 때마다 recomposition 발생
Compose 컴파일러가 코드에서 실행되면 각 함수/타입을 태그로 표시. 이 태그는 Compose가 recomposition 중에 함수/타입을 처리하는 방식을 반영
skippable/restartable로 표시
Immutable/Stable로 표시
String
, Int
, Float
)var
파라미터와 같은 불안정성의 명확한 원인을 확인해야 함List, Set
, Map
)를 불안정한 것으로 간주. -> 불변성을 보장할 수 없기 때문
@Immutable
또는 @Stable
로 annotation 추가불필요하거나 과도한 recomposition시 앱의 stability 디버그
Layout Inspector를 사용하여 어떤 composable이 recomposition 되는지 확인
Compose가 컴포너트를 recomposition 하거나 skipped count를 표시
Compose 컴파일러는 검사를 위해 stability 추론 결과를 출력
컴파일러 보고서는 기본 비활성화로 설정
되어 있음
root build.gradle에 스크립트 추가 필요
composable stability을 디버깅 실행
# 정확한 결과를 보장하려면 항상 릴리스 빌드에서 실행
./gradlew assembleRelease -PcomposeCompilerReports=true
JetSnack 의 출력 결과
<modulename>-classes.txt
: 이 모듈의 클래스 stability 보고서 (샘플)<modulename>-composables.txt
: 모듈에서 composable을 얼마나 restartable/skippable에 대한 보고서 (샘플)<modulename>-composables.csv
: CSV 버전의 컴포저블 보고서 (샘플)composables.txt
파일은 파라미터의의 stability, restartable, skippable 여부 등 특정 모듈의 composables.txt 함수를 설명
# 완전히 restartable, skippable 하며 stability
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SnackCollection(
stable snackCollection: SnackCollection
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
stable index: Int = @static 0
stable highlight: Boolean = @static true
)
# recomposition 중 skip 불가
# unstable 매개변수인 snacks 때문에 파라미터 변경이 없더라도 skip 하지 않는다
restartable 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 클래스 정의
data class Snack(
val id: Long,
val name: String,
val imageUrl: String,
val price: Long,
val tagline: String = "",
val tags: Set<String> = emptySet()
)
// Snack 클래스 보고서
// Snack 클래스는 tags 매개변수의 타입(Set<String>)으로 unstable으로 판단
unstable class Snack {
stable val id: Long
stable val name: String
stable val imageUrl: String
stable val price: Long
stable val tagline: String
unstable val tags: Set<String>
<runtime stability> = Unstable
}
강력 건너뛰기 모드를 사용하면 불안정한 파라미터가 있는 composable을 skip 할 수 있다
val
, immutable 타입인지 체크String, Int
및 Float
와 같은 Primitive 타입은 항상 변경 불가능Compose Compiler는 List, Map
, Set
과 같은 collection이 실제로 불변성인지 확신할 수 없으므로 unstable
로 표시
해결 방법 : Kotlinx Immutable Collections 사용
unstable 클래스에 @Stable
/@Immutable
annotation 추가
// Immutable으로 annotation 처리
@Immutable
data class Snack(
...
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
...
unstable snacks: List<Snack>
...
)
unstable한 collection 해결 방법
kotlin.collections.*
추가하여 Kotlin collection을 stable하도록 간주@Composable
private fun HighlightedSnacks(
...
snacks: ImmutableList<Snack>,
...
)
@Immutable
data class SnackCollection(
val snacks: List<Snack>
)
@Composable
private fun HighlightedSnacks(
index: Int,
snacks: SnackCollection,
onSnackClick: (Long) -> Unit,
modifier: Modifier = Modifier
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
stable index: Int
stable snacks: ImmutableList<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
위 방법들 중 하나를 선택한 결과
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
stable index: Int
stable snacks: ImmutableList<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
stable 하다고 Compiler에게 알려줄 방법 제공
Compose Compiler 1.5.5부터 지원
Configuration file 예시
// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider kotlin collections stable
kotlin.collections.*
// 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 compiler 옵션에 Configuration file path 전달
kotlinOptions {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=" +
"${project.absolutePath}/compose_compiler_config.conf"
)
}
클래스가 stable인지 추론 가능
아래 중 하나를 선택
@Stable
/@Immutable
로 지정
Compose compiler를 사용하지 않는 외부 라이브러리를 사용할 때도 동일한 문제가 발생
모든 composable을 skippable 타입으로 만들면, 더 많은 문제를 야기하는 초기 최적화가 발생함
skippable하다고 해서 실질적인 이점이 없고 코드를 관리하기 어려운 상황
restartable가 보다 높은 오버헤드라고 판단되는 경우 composable에 non-restartable annotation 대응도 가능
Compose compiler에서 사용할 수 있는 모드
composeCompiler {
enableStrongSkipping = true
}
skip 및 composable 함수에 대해 Compose compiler에서 적용하는 일부 stability 규칙을 완화
Strong skipping mode를 활성화 시
recomposition 중에 composable skip을 결정을 위해 파라미터의 이전 값과 비교
===
)Object.equals()
)모든 파라미터가 요구사항을 충족하면 Compose는 recomposition 중에 composable을 skip함
restartable하지만 non-skippable한 composable에는 @NonSkippableComposable
annotation 사용하면 됨
@NonSkippableComposable
@Composable
fun MyNonSkippableComposable {}
인스턴스 동등성(===
) 대신 객체 동등성(Object.equals()
)을 사용을 원하는 경우 클래스에 @Stable annotation 추가
Strong skipping mode는 composable 내에서 람다를 더 많이 memoization할 수 있게 함
// 예시 코드
@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
val lambda = {
use(unstableObject)
use(stableObject)
}
}
// Compiler 결과
@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
val lambda = remember(unstableObject, stableObject) {
{
use(unstableObject)
use(stableObject)
}
}
}
최적화로 인해 recomposition 중에 런타임이 건너뛰는 composable 수가 크게 증가
@DontMemoize annotation 사용 시 memoize하지 않도록 정의 가능
val lambda = @DontMemoize {
...
}
컴파일할 때 skippable composable은 non-skippable composable보다 많은 코드를 생성함
Strong skipping 사용 시, compiler는 거의 모든 composable을 skippable로 표시하고 remember{...}
에서 모든 람다를 래핑 함
-> 따라서 strong skipping mode를 사용 설정해도 APK 크기에 미치는 영향은 매우 적다.
layout Inspector를 사용하여 레이아웃을 검사하고 Recomposition 횟수
를 확인
Recomposition count : https://developer.android.com/develop/ui/compose/tooling/layout-inspector#recomposition-counts
Composition 추적을 사용하여 system trace에서 Composable 함수를 추적 가능
remember
를 사용하여 비용이 많이 드는 계산 최소화Composable 함수는 애니메이션의 모든 프레임만큼 자주 실행될 수 있다.
-> Composable의 본문에서 최대한 적은 계산
을 해야 한다.
-> remember
를 사용하여 계산 결과를 저장하여 해소. 계산이 한 번 실행되며 필요할 때마다 결과를 가져올 수 있다.
// 잘못된 코드
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// ContactsList가 recomposition될 때마다
// contacts 리스트가 변경되지 않았더라도 전체 리스트를 다시 정렬
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
// 올바른 코드
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
// LazyColumn 외부에서 정렬하고 정렬된 목록을 remember를 사용하여 저장
// ContactList가 처음 composition될 때 목록이 한 번 정렬
// contacts나 comparator가 변경되면 정렬된 목록이 다시 생성
// 그 외에는 Composable이 캐시된 정렬된 목록을 계속 사용
val sortedContacts = remember(contacts, comparator) {
contacts.sortedWith(comparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
가능하면 composable 외부로 계산을 모두 이동하는 것이 가장 좋다.
- ViewModel 혹은 이미 정렬된 목록을 composable에 입력으로 제공
Lazy layouts은 항목을 효율적으로 재사용하여 필요한 경우에만 다시 생성하거나 recomposion 한다.
-> recomposion을 위해 lazy layout을 최적화 필요
예시) 수정 시간을 기준으로 정렬된 메모 목록을 표시
// 잘못된 코드
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
// 가장 최근에 수정되었으므로 목록의 맨 위로 이동하고 다른 모든 메모는 한 스팟 아래로 이동
// Compose 변경되지 않은 항목이 목록에서 이동된다는 것을 인식하지 못함
// -> 이전 '항목 2'가 삭제되고 항목 3, 항목 4, 그리고 그 아래까지 새 항목이 만들어졌다고 판단
// -> 결과적으로 실제로 변경된 항목은 하나뿐이지만 목록의 모든 항목을 Recomposition함
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
// 올바른 코드
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
// 안정적인 키를 제공하여 불필요한 Recomposition 방지
items(
items = notes,
key = { note ->
// note의 안정적이고 고유한 키를 반환
note.id
}
) { note ->
NoteRow(note)
}
}
}
derivedStateOf
를 사용하여 recomposition 제한상태가 빠르게 변경되면 UI가 필요 이상으로 recomposition될 수 있다
listState
가 계속 변경된다 -> 계속 recomposition 됨// 잘못된 코드
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
// 올바른 코드
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
상태 읽기를 연기하면 Compose가 recomposition시 가능한 최소 코드를 다시 실행
스크롤 상태가 변경되면 Compose는 가장 가까운 상위 recomposition scope를 무효화
-> SnackDetail composable -> Box는 인라인 함수이므로 recomposition scope가 아님
// 가장 가까운 상위 recomposition scope : SnackDetail composable
@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)
) {
// ...
}
}
Title
에서 끌어올린 상태를 참조할 수 있지만 값은 실제로 필요한 Title
내부에서만 읽는다
-> Box는 recomposition 불필요해짐
// 가장 가까운 상위 recomposition scope : Title composable
@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)
) {
// ...
}
}
오프셋 변경으로 Modifier.offset(x: Dp, y: Dp)
에서 람다 버전의 Modifier.offset(offset: Density.() -> IntOffset) Modifier로 변경하면 레이아웃 단계에서 스크롤 상태를 읽음
자주 변경되는 상태 변수를 Modifier에 전달할 때는 가능하면 람다 버전의 Modifier를 사용해야 함
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(x = 0, y = scrollProvider()) }
) {
// ...
}
}
Box의 배경색은 두 색상 간에 빠르게 전환되므로, 이 상태는 매우 자주 변경된다
-> Composable이 background Modifier에서 이 상태를 읽음 -> 색상이 모든 프레임에서 변경되므로 Box는 모든 프레임에서 Recomposition이 됨
// animateColorBetween()이 두 색상을 교환하는 함수라고 가정
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.background(color)
)
람다 기반 Modifier인 drawBehind 사용으로 해결
-> draw 단계에서만 색상 상태를 읽으므로, composition 및 layout 단계를 완전히 건너뛸 수 있다.
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
}
)
Compose의 핵심 가정 : 이미 READ한 상태에서 WRITE하지 않는다
역방향 쓰기
라고 하며 모든 프레임에서 recomposition이 끝없이 발생할 수 있다.@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
// 클릭 시 recomposition을 유발
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
Text("$count")
count++ // 역방향 쓰기, 읽은 뒤 상태 쓰기
}
Composition에서 상태에 write하지 않음으로써 역방향 쓰기를 완전히 방지 가능.
onClick
과 같이 항상 이벤트에 대한 응답으로, 그리고 람다에서 상태에 write하면 된다
comments powered by Disqus
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024