MacroBenchmark & Baselin Profile을 사용한 성능 개선 여정 - 1편에선 android 프로젝트의 컴파일 및 JIT/AOT컴파일 개념부터 시작하여 성능 측정 지표의 종류를 개념적으로 알아봤었고, BaselineProfile + Perfetto의 활용 및 지표를 객관적으로 측정하여 부분적으로 개선했다.
이번 글 주제는 '이미지 최적화'와 'Compose Frame Rendering 3단계에 기반한 최적화'에 관해 작성한다.
이미지를 로딩할 땐, 아래와 같은 방식으로 로딩이 가능하다.
- Rester이미지 : png, jpg 등, pixel기반의 이미지
- Vector이미지 : .xml, .kt 등, pixel과 무관하게 점,선,면 등 수학 공식으로 만들어진 이미지
Rester이미지는 보통 painterResource
사용 및 Main Thread를 점유하며 로딩이 이뤄져 frame rendering성능에 악영향을 미칠 수 있다. 따라서 고화질의 Rester이미지를 로딩하려거든 coil등 비동이 이미지 라이브러리 사용이 권장된다.
하지만 composable환경에서 Vector이미지는 Vector Image Tree
를 구축하여 그려지는데, 이를 통해 Main Thread의 리소스를 더 아낄 수 있다.
아래는 위 원리에 입각하여 문제를 해결해가는 과정이다.
최적화 전 | 최적화 후 |
---|---|
![]() | ![]() |
위는 UI는 12개의 증권사들을 LazyColumn
으로 보여주는 단순한 UI이다. 하지만 '최적화 전'을 보면 알겠지만 해당 화면 진입 전, 버튼을 클릭 시, frame이 약 2초간 멈추는걸 확인할 수 있다. 진입 후엔 리스트가 뜨기는 하지만 스크롤할 때, UI가 매우 버벅이는것 또한 확인 가능하다.
증권사 로고들은 앱이 보유한 리소스로써 네트워크 로딩 뿐만 아니라 페이징 작업도 없다. 문제를 잡기 위해 아래 코드를 살펴보아도, unstable
한 타입으로 인한 Recomposition
문제도 찾을 수 없다.
LazyColumn {
items(
items = uiState.stockAppList,
key = { it.code }
) { stockApp ->
StockAppItem(
uiState = uiState,
stockApp = stockApp,
onClickedStockApp = { onClickedStockApp(stockApp) }
)
}
}
@Composable
private fun StockAppItem(
modifier: Modifier = Modifier,
uiState: StockSettingUiState,
stockApp: StockEnum,
onClickedStockApp: () -> Unit = {}
onClickedStockApp: () -> Unit = {}
) { ... }
따라서 Recomposition
으로 인한 버벅거림이 아니라면, Frame Rendering문제가 아닐까란 가설을 세웠다. 그 후, 해당 UI에 Benchmark측정을 해보니, Frame Rendering의 문제임이 밝혀졌다.
Benchmark측정 결과, P99즉, 99%의 frame이 최대 900ms이내로 로딩되며 1%는 그 이상 걸릴 수 있다
고 나와있다. 이는 거진 1초라고 봐도 무방한 수치이다.
UI가 제대로 렌더링되기 위해서 1초에 60 frame이 rendering돼야 한다. 이는 즉, 1 frame당 16ms정도가 나와야 UI가 버벅거리지 않음을 의미한다. 하지만 1frame의 rendering시간이 1000ms라는 것은 60배나 좋지 않은 성능임을 증명한다. 따라서 문제 해결 방향은 frame rendering개선이다.
정상이라면 1 frame의 rendering시간은 16ms이나 현재는 900ms이다.
그럼 UI의 어느 부분이 문제인 걸까? 문제지점을 알아내기 위해, LazyColumn내, item의 내부 코드 중, Image
를 단순 Box
로 변경 후, 성능을 측정했다.
[before]
Image(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(dimensionResource(id = R.dimen.dimen_40dp))
.border(
shape = CircleShape,
border = BorderStroke(
width = dimensionResource(id = R.dimen.dimen_01dp),
color = colorResource(id = R.color.gray_F2F3F5)
)
),
painter = painterResource(id = imgRes),
contentDescription = null
)
[after]
Box(
modifier = Modifier
.size(40.dp)
.background(Color.Magenta)
)
변경된 UI | 성능 측정 결과 |
---|---|
![]() | ![]() |
그 결과, P99가 908.6ms에서 13.8ms로 개선된걸 확인할 수 있다. 이를 통해 2가지를 알 수 있다.
- 병목 구간은
Image
composable함수이다.- 이미지 로딩 방식은 LazyColumn의 스크롤 성능에 영향을 준다.
Image
컴포저블은 painterResource()
를 활용하여 ImageBitmap
을 Main Thread에서 로드하고 있었다. 따라서 coil을 사용하여 이미지를 비동기로 로드했으며, 그 결과, 이미지 렌더링 성능이 908.6ms -> 26.0ms
로 극적으로 개선됐다.
ImageBitmap + 동기적 로딩 | ImageBitmap + 비동기적 로딩 |
---|---|
![]() | ![]() |
위 벤치마크에 대한 perfetto는 어떻게 나왔을까? perfetto를 통해 성능을 유의미하게 측정하기 위해선 trace()
메서드의 key값 지정이 필요하다. 따라서 현재 측정하고자 하는 UI에 StockAppItem::StockImage
라는 key값을 지정했다.
trace("StockAppItem::StockImage") {
Image(
modifier = Modifier
.align(Alignment.CenterVertically)
.border(
shape = CircleShape,
border = BorderStroke(
width = dimensionResource(id = R.dimen.dimen_01dp),
color = colorResource(id = R.color.gray_F2F3F5)
)
),
painter = rememberAsyncImagePainter(
model = imageRequest
),
contentDescription = null
)
}
key값 지정 완료 후, perfetto가 출력됐다. 이제 이를 통해 SQL문 실행을 통해 위, StockAppItem::StockImage
의 로딩 시간을 측정했으며, 그 결과는 약 590μs로 나왔다.
⭐⭐⭐교훈
용량이 큰ImageBitmap
을painterResource()
를 활용해 로드할 경우, frame 버벅임이 일어난다. 따라서 coil 등과 같은 비동기 이미지 로딩을 고려하자.
하지만 더 최적화할 여지는 없을까?
참고 : 안드로이드 공식 홈페이지
Android공식 홈페이지에 따르면 ImageBitmap
보다 ImageVector
사용을 권장하고 있다. 따라서 이미지를 비동기로 로드하는 방식은 유지하며, 로드하는 이미지를 ImageVector
로 변경하면 좀 더 로드 성능이 빠를거란 생각이 들었다.
ImageBitmap + 비동기적 로딩 | ImageVector(.xml) + 비동기적 로딩 |
---|---|
![]() | ![]() |
Benchmark측정 결과, 차이가 극명하게 나진 않았다. 다만, perfetto를 통해 렌더링 속도를 확인했을 땐로드를 확인했을 땐 402μs초가 나왔다. 즉, 기존보다 200μs단축된 것이다. (해당 방식으로, 벤치마크를 3번 측정 결과, frameOverrunMs
가 16.4ms, 26.8ms, 23.0ms로 기록하며 변동 심해 결과 확인이 어려웠으나, perfetto는 400μs로 일정하게 나왔다. 따라서 제대로 된 성능 측정을 위해선 perfetto로 확인이 필요하다.)
첫 번째 측정 결과 |
---|
![]() |
두 번째 측정 결과 |
---|
![]() |
세 번째 측정 결과 |
---|
![]() |
Vector이미지는 .xml
과 .kt
타입으로 로드할 수 있다. 만약 두 이미지가 동일한 .svg
확장자로부터 생성됐다면, .kt
타입으로 이미지를 로드하는 것이 유리하다. 왜 그럴까?
그 이유는 Compose환경에서 vector이미지를 로딩하려면 Vector Image Tree
를 그려야 하는데, (Composable함수 구성을 위해 Layout Node Tree
를 그리는 것과 유사) .xml
로 이미지 로딩을 위해선 Vector Image Tree
로 표현할 수 있는 객체인 ImageVector
로 변환하는 작업이 필요하기 때문이다. 바로 이 과정에서 Main Thread를 Blocking하는 연산 비용이 들어가게 된다. 아래는 .xml
이미지의 로딩 과정이다.
loadVectorResource()
호출 및ImageVector
생성rememberVectorPainter()
호출 및VectorPainter
생성VectorPainter
를 사용하여Image
Composable함수 호출 및 이미지 로딩
하지만 .kt
타입을 로드할 땐? 위의 1번 과정 없이 ImageVector
를 바로 주입받는걸 확인할 수 있으며, 이를 통해 Vector Image Tree
를 바로 구성할 수 있게 되었다. 즉, .kt
타입의 이미지 렌더링 속도가 더 빠른 이유는 .xml
타입의 이미지처럼 ImageVector
를 생성하는 작업이 없기 때문이며, 이로 인해 ImageVector
타입을 바로 사용하고 Vector Image Tree
를 바로 구성할 수 있기 때문이다. 따라서 아래의 순서를 따른다.
rememberVectorPainter()
호출 및VectorPainter
생성VectorPainter
를 사용하여Image
Composable함수 호출 및 이미지 로딩
이러한 이유로 기존 .xml
타입의 이미지를 .kt
타입의 이미지로 교체하였다. 측정 및 perfetto확인 결과, 약 300µs에서 80µs로 성능 개선을 확인할 수 있다.
Vector이미지(.xml) |
---|
![]() |
Vector이미지(.kt) |
---|
![]() |
그 외, 우린 UI를 구축할 때, compose의 기본 API로 UI를 구축하곤 한다. 대표적인 예로, RadioButton
등이 있다.
RadioButton
의 파라미터 중, colors
라는 녀석이 있다. 이녀석은 기본 값으로 Material Design
즉, 디자인 시스템에 따라 UI색깔을 정해줄 수 있는 장점이 있다.
하지만, 디자인 시스템이 갖추어져있지 않은 경우, 해당 API은 rendering 성능 자체에선 불리할 수 있다는 점을 알아야 한다. 아래 사진은 하나의 item에서 RadioButton이 차지하는 렌더링 시간으로, 하나의 item의 rendering시간에서 약 1/4를 차지하고 있으며 평균 300µs로 측정된다. 해당 UI는 크게 복잡한 부분이 없기에, 필자는 rendering 시간이 너무 길다고 느껴졌다.
trace("stockAppRadioButton") {
RadioButton(
selected = uiState.selectedStockApp == stockApp,
colors = RadioButtonDefaults.colors().copy(
selectedColor = colorResource(id = R.color.gray_091E42),
unselectedColor = colorResource(id = R.color.gray_B3BAC5)
),
onClick = { onClickedStockApp() }
)
}
이 또한 더 줄일 수 없을까? 가능하다. 더더욱 기초적인 UI요소인 Box
를 활용하면 frame rendering시간 단축이 더 가능하다.
@Composable
fun BaseRadioButton(
selectedState: Boolean,
onClickedStockApp: () -> Unit
) {
val borderState by rememberUpdatedState(
when (selectedState) {
true -> BorderStroke(
width = 8.dp,
color = colorResource(R.color.gray_091E42)
)
false -> BorderStroke(
width = 2.dp,
color = colorResource(id = R.color.gray_B3BAC5)
)
}
)
Box(
modifier = Modifier
.size(24.dp)
.border(
shape = CircleShape,
border = borderState
)
.noRippledClickable(
onClick = onClickedStockApp
)
)
}
custom하게 BaseRadioButton
구축 후, 측정을 통해 렌더링 시간이 평균 300µs에서 100µs로 대폭 개선되었다. 이를 통한 교훈은 compose에서 제공해주는 API는 상황에따라 무거울 수 있으니, custom하게 구현하는게 좋다는 것이다.
[composable함수의 frame rendering 3단계]
- Composition
- Layout(1. Measure 2. Place)
- Drawing
Layout
/Drawing
단계때 잘못된 state
값의 읽기/변경 작업은 frame rendering과정을 불필요하게 1번 더 반복시킬 수 있다. 그 이유는 frame rendering과정은 단방향으로만 진행되며 역행할 수 없기 때문이다. 예를 들어, Modifier.layout { ... }
함수에서 imageHeightPx
값을 변경했고 이를 composable함수에 주입한다고 가정하자. 이때, composable함수의 파라미터는 Composition
단계 때 상태값을 읽어들이므로 Composition
단계를 한번 더 거쳐야만 한다. 하지만 Layout
단계에선 Composition
단계로 역행할 수 없다. 따라서 frame rendering과정이 1번 더 반복된다.
아래의 UI는 스크롤 움직임에 따라 TradingDetailTopBar
의 글씨크기가 달라진다. 즉, 스크롤에 따라 0.0f ~ 1.0f사이의 state
값을 수신받고, 그에 따라 fontSize
크기를 변경해주면서 글자 크기를 변경해주고 있다.
하지만 아래 코드는 문제가 존재한다. 바로, fontSize
는 Composition
단계 때 상태값을 읽어들인다는 점이다. 따라서 0.0f ~ 1.0f사이의 상태값이 변경될때마다 무수히 많은 Recomposition
이 발생한다. 아래는 LayoutInspector
를 통해 Recomposition
이 TradingDetailTopBar
에서부터 발생하는걸 보여주고 있다.
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = tradingDetailProfileVo.stock,
style = TypoSubhead3.copy(
fontSize = 12.sp * (1 - scrollProgressState)
)
)
문제를 어떻게 해결해야할까? 우선 요구사항 먼저 생각해보자. TopAppBar쪽 글자 크기는 scrollState
의 값 즉, 0.0f ~ 1.0f값에 따라 글자 크기를 점진적으로 변경해주는 것이다. 따라서 이럴 땐, Composition
단계 때 상태값을 읽는 fontSize
를 활용하는 게 아니라, Layout
단계 때 상태값을 읽는 Modifier.layout { ... }
를 활용하는것도 문제 해결의 1가지 방법일 수 있다. 따라서 이를 활용해 코드 작성 결과, 최 하위 composable함수인 Text
에서 Recomposition
이 skip되는걸 확인할 수 있다.
val heightToPx by rememberUpdatedState(
((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt()
)
Text(
modifier = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(
Constraints.fixedHeight(height = heightToPx)
)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
.align(Alignment.CenterHorizontally),
text = tradingDetailProfileVo.stock,
style = TypoSubhead3.copy(
color = colorResource(id = R.color.gray_091E42),
fontSize = 12.sp
)
)
하지만 개발자라면 위의 Modifier.layout { ... }
함수를 추출하고싶은 욕망이 든다. 따라서 아래와 같이 추출을 시도한다. 테스트를 해보면 어떤 결과가 나올까?
fun Modifier.heightWithNoComposition(height: Int): Modifier =
this.layout { measurable, _ ->
val constraints = Constraints.fixedHeight(
height = height
)
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
val heightToPx by rememberUpdatedState(
((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt()
)
Text(
modifier = Modifier
.heightWithNoComposition(heightToPx)
.align(Alignment.CenterHorizontally),
text = tradingDetailProfileVo.stock,
style = TypoSubhead3.copy(
fontSize = 12.sp
)
)
Recomposition
을 건너뛰던 코드가 단순 함수 추출로 인해 Recomposition
을 다시 유발하는 신기한 상황이 벌어졌다. 왜 그런걸까?
첫 번째 이유는, scrollState
상태 변수가 최 상위 TradingDetailTopBar
에 주입되어, Recomposition
을 유발됐기 때문이다. (compose runtime은 composable함수 파라미터에 상태값이 변경되는 순간 Recomposition
을 예약함)
두 번째 이유는, 최 상위 TradingDetailTopBar
의 Recomposition
으로 하위 composable함수들이 skippable한지 검사를 한다. 이때, 주입받은 상태변수 scrollState
가 Composition
을 유발하고 이 녀석이 주입 된 Modifier
를 통해 Text
함수가 재구성되는 것이다.
그렇다면 이를 어떻게 해결할까?
위의 원인에 나와있는걸 고쳐주면 된다. 첫 번째 이유에선 최 상위 composable함수인 TradingDetailTopBar
에 Float
타입의 scrollState
가 주입되고 있다. 이를 Lambda
타입으로 바꿔줌으로써 상태값 변경에 따른 Recomposition
유발을 피할 수 있다.
@Composable
fun TradingDetailTopBar(
// scrollProgressState: Float,
scrollProgressStateProvider: () -> Float,
)
두 번째 이유에선 scrollState
를 주입받은 Modifier.heightWithNoComposition
의 파라미터도 모두 Lambda
타입으로 바꿔준다.
fun Modifier.heightWithNoComposition(
// height: Float,
heightProvider: () -> Float
): Modifier
위와같이 변경 후, scrollState
를 주입받는 Text
composable함수는 아래와 같이 호출된다.
val heightToPx by rememberUpdatedState(
{ ((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt() }
)
Text(
modifier = Modifier
.heightWithNoComposition(heightToPx)
.align(Alignment.CenterHorizontally),
text = tradingDetailProfileVo.stock,
style = TypoSubhead3.copy(
fontSize = 12.sp
)
)
즉, composable함수의 파라미터 타입을 Lambda
로 바꾸고, 이를 Layout
단계 때, 읽음으로써 Recomposition
을 skip하게된다.
글 잘 봤습니다!
현재 perfetto로 성능 개선 중에 있는데 큰 도움이 되고 있습니다:)