Macrobenchmark 설정 및 사용
목차
- 모듈 구조
- benchmark 모듈 설정
- 앱 측 변경사항
- 테스트 코드
- am start 명령어 extra 타입
- 측정 지표 해석
- 추가 Metric
- 테스트 실행 명령어
- 결과 수치 분석 사례
- Perfetto Trace 분석
- Baseline Profile
- nonMinifiedRelease와 minification
1. 모듈 구조
settings.gradle.kts → include(":benchmark") 추가
benchmark/
├── build.gradle.kts
├── src/main/AndroidManifest.xml
└── src/main/java/.../benchmark/StartupBenchmark.kt
2. benchmark 모듈 설정
benchmark/build.gradle.kts
plugins {
id("com.android.test")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.myapp.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 29
targetSdk = 36
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
}
buildTypes {
create("nonMinifiedRelease") {
isDebuggable = false
signingConfig = getByName("debug").signingConfig
matchingFallbacks.add("release")
}
}
}
dependencies {
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.benchmark.junit4)
implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.test.ext.junit)
}
androidComponents {
beforeVariants(selector().all()) { variant ->
variant.enable = variant.buildType == "nonMinifiedRelease"
}
}
benchmark/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:debuggable="false" />
</manifest>
gradle/libs.versions.toml 추가 항목
[versions]
benchmark = "1.3.4"
uiautomator = "2.3.0"
androidx-test-ext = "1.2.1"
[libraries]
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" }
3. 앱 측 변경사항
3-1. app/build.gradle.kts (convention plugin)
nonMinifiedRelease 빌드 타입 추가 및 벤치마크 시 APK rename 로직 제외
// nonMinifiedRelease 빌드타입에서는 onVariants 로직 skip
if (variant.buildType == "nonMinifiedRelease") return@onVariants
// nonMinifiedRelease 빌드타입 정의
create("nonMinifiedRelease") {
initWith(getByName("release"))
signingConfig = signingConfigs["childDebug"] // debug 서명 사용 (기기에 설치된 서명과 일치시키기 위함)
matchingFallbacks.add("release")
}
3-2. MainActivity.kt
벤치마크 실행 시 HomeScreen으로 직접 진입하기 위한 Boolean extra 처리
companion object {
const val EXTRA_BENCHMARK = "EXTRA_BENCHMARK"
}
private fun getBenchmarkDestination(): Any? =
intent.getBooleanExtra(EXTRA_BENCHMARK, false)
.let { isTest -> if (isTest) HomeRoute(isBenchmarkMode = true) else null }
setContent 내부에서
val startDestination = getBenchmarkDestination() ?: IntroRoute
3-3. HomeRoute (HomeNavigation.kt)
@Serializable
data class HomeRoute(
val isBenchmarkMode: Boolean = false,
)
fun NavGraphBuilder.homeScreen(...) {
composable<HomeRoute> { backStackEntry ->
val route = backStackEntry.toRoute<HomeRoute>()
HomeScreen(
isBenchmarkMode = route.isBenchmarkMode,
...
)
}
}
3-4. HomeScreen.kt
1) 시스템 팝업 체크 비활성화 — 벤치마크 실행 중 팝업이 측정을 방해하지 않도록
@Composable
fun HomeScreen(
isBenchmarkMode: Boolean = false,
...
) {
OnResumeCoroutineEffect {
if (!isBenchmarkMode && pendingPopupState.isEmpty()) {
// 시스템 팝업 체크
}
}
}
2) ReportDrawn() — timeToFullDisplayMs 측정을 위해 HomeContent가 렌더링된 시점을 시스템에 알림
@Composable
private fun HomeContent(...) {
// ...Column, Toolbar, Pager...
ReportDrawn() // HomeContent 진입 시점에 reportFullyDrawn() 호출
}
import androidx.activity.compose.ReportDrawn
4. 테스트 코드
StartupBenchmark.kt
@file:OptIn(ExperimentalMetricApi::class)
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.MemoryUsageMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private const val PACKAGE_NAME = "com.myapp.dev"
private const val MAIN_ACTIVITY = "com.myapp.MainActivity"
private const val EXTRA_BENCHMARK = "EXTRA_BENCHMARK"
private const val LAUNCH_TIMEOUT_MS = 10_000L
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
// IntroScreen 기준 앱 콜드 스타트 시간
@Test
fun startupCold() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.None(),
setupBlock = { pressHome() },
) {
device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
// IntroScreen 기준 앱 웜 스타트 시간
@Test
fun startupWarm() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.WARM,
compilationMode = CompilationMode.None(),
setupBlock = { pressHome() },
) {
device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
// HomeScreen 렌더링 완료까지의 소요 시간
// timeToInitialDisplayMs: 첫 프레임 시간
// timeToFullDisplayMs: ReportDrawn() 호출 시점 (HomeContent 렌더 완료)
@Test
fun homeScreenRenderTime() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.None(),
setupBlock = { pressHome() },
) {
device.executeShellCommand(
"am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
)
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
// HomeScreen에서 스와이프 인터랙션 시 프레임 성능
@Test
fun homeScreenScrollFrameTiming() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(
FrameTimingMetric(),
MemoryUsageMetric(MemoryUsageMetric.Mode.Max),
),
iterations = 5,
startupMode = StartupMode.WARM,
compilationMode = CompilationMode.None(),
setupBlock = {
pressHome()
device.executeShellCommand(
"am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
)
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
Thread.sleep(500)
},
) {
val cx = device.displayWidth / 2
val cy = device.displayHeight / 2
repeat(5) {
device.swipe(cx + 400, cy, cx - 400, cy, 80)
device.swipe(cx - 400, cy, cx + 400, cy, 80)
}
device.waitForIdle()
}
}
테스트별 역할 요약
| 테스트 |
Metric |
StartupMode |
목적 |
startupCold |
StartupTimingMetric |
COLD |
앱 최초 기동 시간 |
startupWarm |
StartupTimingMetric |
WARM |
백그라운드 복귀 시간 |
homeScreenRenderTime |
StartupTimingMetric |
COLD |
HomeScreen 렌더 완료 시간 |
homeScreenScrollFrameTiming |
FrameTimingMetric, MemoryUsageMetric |
WARM |
인터랙션 중 프레임 성능 + 메모리 |
StartupMode 차이
| StartupMode |
동작 |
용도 |
| COLD |
앱 프로세스 kill 후 측정 |
최초 실행 성능 |
| WARM |
프로세스 유지, 백그라운드 상태에서 복귀 |
재진입 성능 |
| HOT |
Activity만 종료, 프로세스 유지 |
화면 전환 성능 |
5. am start 명령어 extra 타입
| 타입 |
플래그 |
예시 |
| String |
--es |
--es KEY value |
| Boolean |
--ez |
--ez KEY true |
| Int |
--ei |
--ei KEY 123 |
| Long |
--el |
--el KEY 123 |
--activity-clear-task 사용 이유
StartupMode.WARM에서 macrobenchmark가 내부적으로 앱을 먼저 실행(LAUNCHER intent, extra 없음)한 뒤, setupBlock의 am start가 기존 Task에 onNewIntent만 트리거한다. onCreate가 호출되지 않아 intent extra가 무시되고 IntroRoute로 진입하는 문제가 생긴다.
--activity-clear-task 플래그로 Task를 초기화하면 onCreate가 새로 호출되어 extra를 정상 수신한다.
6. 측정 지표 해석
StartupTimingMetric 결과값
| 지표 |
설명 |
timeToInitialDisplayMs |
앱 실행 후 첫 프레임이 화면에 그려지기까지의 시간 |
timeToFullDisplayMs |
ReportDrawn() 호출 시점까지의 시간 (앱이 직접 신고한 "완전 렌더링" 시점) |
두 값의 차이가 클수록 초기 데이터 로딩 병목이 있다는 신호다.
FrameTimingMetric 결과값
frameDurationCpuMs
CPU가 한 프레임을 그리는 데 걸린 시간. 기기 주사율에서 파생된 deadline과 비교한다.
deadline = 1000ms ÷ 주사율(Hz)
60Hz → 16.67ms
90Hz → 11.11ms
120Hz → 8.33ms
기기 주사율 확인
adb shell dumpsys display | grep "mRefreshRate"
| 퍼센타일 |
초과 시 체감 |
| P50 초과 |
앱 전체가 버벅임, 심각 |
| P90 초과 |
스크롤할 때 자주 끊김, 사용자가 인지 |
| P95 초과 |
가끔 끊김, 민감한 사용자가 인지 |
| P99 초과 |
드물게 발생, 일반적으로 허용 범위 |
frameOverrunMs
deadline이 이미 차감된 값. 기준은 0ms (16.67ms와 비교하는 게 아님).
frameOverrunMs = 실제 렌더링 시간 - 기기 deadline
| 값 |
의미 |
| 음수 |
deadline 전에 완료 → 정상 |
| 0 ~ 5ms |
소폭 초과 → 경미한 jank |
| 5ms 이상 |
명확한 jank |
| 16ms 이상 |
프레임 드롭 (2배 이상 소요) |
frameDurationCpuMs → deadline(Hz 기반) 미만이면 OK, 작을수록 좋음
frameOverrunMs → 0ms 미만이면 OK, 음수일수록 좋음
7. 추가적인 Metric
MemoryUsageMetric
HomeScreen 진입 시 메모리 사용량 측정. heapSizeKb, rssAnonKb 등을 제공한다.
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.MemoryUsageMetric
@OptIn(ExperimentalMetricApi::class)
metrics = listOf(
FrameTimingMetric(),
MemoryUsageMetric(MemoryUsageMetric.Mode.Max),
)
TraceSectionMetric
특정 코드 구간의 실행 시간을 직접 측정. ViewModel 데이터 로딩 등 병목 구간 특정에 유용하다.
// 측정할 코드에 trace 블록 추가
import androidx.tracing.trace
fun loadData() = trace("HomeViewModel.loadData") {
// 기존 로직
}
// 벤치마크에서
import androidx.benchmark.macro.TraceSectionMetric
metrics = listOf(TraceSectionMetric("HomeViewModel.loadData"))
8. 테스트 실행 명령어
전체 테스트 실행
./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest
특정 테스트만 실행
./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.myapp.benchmark.StartupBenchmark#homeScreenRenderTime
클래스명과 메서드명을 #으로 구분해서 지정한다.
Gradle 명령어로 실행해야 하는 이유
Android Studio 실행 버튼(▶)으로도 테스트가 가능하지만, Perfetto trace 파일이 로컬에 자동으로 저장되지 않는다.
trace 파일이 필요하다면 Gradle 명령어를 사용해야 한다.
trace 파일 저장 경로
<project>/build/outputs/connected_android_test_additional_output/
devNonMinifiedRelease/connected/<기기명>/
StartupBenchmark_homeScreenRenderTime_iter000.perfetto-trace
...
9. 결과 수치 분석 사례
homeScreenRenderTime 실측 결과 (SM-A165N, 60Hz)
timeToInitialDisplayMs min 718.3, median 772.3, max 917.4
timeToFullDisplayMs min 718.3, median 772.3, max 917.4
frameDurationCpuMs P50 20.6, P90 420.2, P95 454.6, P99 474.2
frameOverrunMs P50 16.6, P90 443.5, P95 480.5, P99 495.6
timeToInitialDisplayMs = timeToFullDisplayMs → ReportDrawn()이 HomeContent 진입 즉시 호출되므로 두 값이 같다. 정상 동작이다.
median 772ms → 60Hz 기기 콜드 스타트 기준 보통~느림 경계. 400ms 미만이면 빠름, 800ms 이상이면 느림으로 판단한다.
frameDurationCpuMs P99 474.2ms → 콜드 스타트 초기 프레임은 Hilt DI 초기화, ViewModel 생성 등 무거운 작업이 몰리므로 높게 나오는 것은 정상이다. 스타트업 측정에서 frameDurationCpuMs는 참고용이며, 핵심 지표는 timeToInitialDisplayMs다.
timeToInitialDisplayMs 기준
| 범위 |
평가 |
| ~400ms |
빠름 |
| 400~800ms |
보통 |
| 800ms~ |
느림, 사용자 인지 |
frameCount 신뢰도
frameCount min 1.0, median 1.0, max 1.0
반복당 프레임이 1개면 P50/P90/P95/P99가 모두 단일 샘플 기반이므로 통계적 신뢰도가 낮다. 의미 있는 분포를 얻으려면 최소 50개 이상의 프레임이 필요하다.
HorizontalPager처럼 탭이 1개뿐인 화면은 swipe해도 실제 스크롤이 발생하지 않아 프레임 생성이 거의 없다. LazyColumn처럼 긴 목록이 있는 화면이 FrameTimingMetric 측정에 더 적합하다.
MemoryUsageMetric 실측 결과
memoryGpuMaxKb ~36MB GPU 텍스처, Compose 렌더링 버퍼
memoryHeapSizeMaxKb ~9~10MB JVM 힙 (객체 할당)
memoryRssAnonMaxKb ~56~63MB 앱이 직접 할당한 전체 메모리 (힙 + 네이티브)
memoryRssFileMaxKb ~106~108MB 코드, 라이브러리, 리소스 등 파일 기반 매핑
RSS 합계(Anon + File)가 170MB 수준. memoryRssFileMaxKb가 크면 라이브러리 로딩량이 많다는 신호다.
10. Perfetto Trace 분석
trace 파일 열기
방법 A — Android Studio 링크 클릭
테스트 결과 패널에서 Traces: Iteration 0 1 2 3 4 링크를 클릭하면 Android Studio Profiler가 자동으로 열린다. 단, SQL 쿼리는 불가능하다. trace를 export한 뒤 Perfetto UI에 올려야 SQL 쿼리를 사용할 수 있다.
방법 B — Perfetto UI (SQL 쿼리 가능)
https://ui.perfetto.dev 에서 Open trace file로 .perfetto-trace 파일을 업로드한다.
웹 브라우저에서 바로 사용하며 별도 설치가 필요 없다.
Perfetto UI에서 확인할 것
| 트랙 |
내용 |
Choreographer#doFrame |
프레임 전체 소요 시간 |
measure / layout / draw |
Compose 렌더링 단계별 시간 |
RenderThread |
GPU 드로잉 시간 |
Generating runtime image |
ART JIT 컴파일 및 앱 이미지 생성 시간 — Baseline Profile로 개선 가능 |
SQL 쿼리로 개선점 분석
Perfetto UI 우측 하단 Query (SQL) 버튼을 클릭해서 실행한다.
Baseline Profile로 Generating runtime image를 제거한 이후 다음 쿼리로 순차적으로 병목을 찾는다.
우선순위 요약
| 우선순위 |
영역 |
이유 |
| 1 |
메인 스레드 장기 블로킹 |
전체 병목 상태 |
| 2 |
GC |
초기 객체 할당 과다 여부 |
| 3 |
Compose 리컴포지션 |
화면 렌더링 지연 원인 |
| 4 |
Hilt / 클래스 로딩 |
콜드 스타트에 직접 영향 |
| 5 |
Binder / 파일 I/O |
ANR 위험 및 스레드 블로킹 |
| 6 |
이미지 리소스 |
Coil 디코딩 병목 |
1. 메인 스레드 장기 블로킹 슬라이스
16ms 이상 메인 스레드를 점유한 작업 전체 조회 — 병목 위치를 한눈에 파악한다.
SELECT
s.name,
s.dur / 1e6 AS dur_ms,
t.name AS thread_name
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
AND s.dur > 16000000
ORDER BY s.dur DESC
LIMIT 30
2. GC (Garbage Collection)
콜드 스타트 중 GC가 자주 발생하면 초기 객체 할당이 과도하다는 신호다.
JIT방식이였다면 AOT에 비해 상대적으로 느리기 때문에 객체 할당이 시간에 걸쳐 분산 되었을 것이다.
하지만 BaselineProfile을 적용했다면 ART AOT 방식으로 실행되기에 빠르게 초기화되며 객체가 한꺼번에 대량 할당되어 GC의 수치는 증가할 수 있다.
아래에서 다시 한번 설명한다.
SELECT
name,
COUNT(*) AS count,
AVG(dur) / 1e6 AS avg_ms,
SUM(dur) / 1e6 AS total_ms
FROM slice
WHERE name LIKE '%GC%'
OR name LIKE '%garbage%'
OR name LIKE '%HeapTask%'
GROUP BY name
ORDER BY total_ms DESC
결과 해석
GC가 메인 스레드에서 발생했는지 여부가 total_ms보다 중요하다. Perfetto에서 메인 스레드는 main이 아닌 패키지명(프로세스명) 으로 표시된다.
SELECT
s.name,
s.dur / 1e6 AS dur_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name LIKE '%<패키지명>%'
AND (s.name LIKE '%GC%' OR s.name LIKE '%HeapTask%')
ORDER BY s.dur DESC
| dur_ms |
판단 |
| 16ms 이상 |
프레임 드롭 및 스타트업 지연 영향, 개선 필요 |
| 16ms 미만 |
체감 영향 없음 |
Baseline Profile 적용 후 GC total_ms가 높아지는 현상
Baseline Profile 적용 후 slice_count는 줄지만 total_ms가 오히려 높게 나오는 경우가 있다. 이는 문제가 아니다.
- 미적용 시: JIT 컴파일이 느려 객체 할당이 분산 → 작은 GC가 여러 번 발생
- 적용 후: AOT로 빠르게 초기화되면서 객체가 한꺼번에 대량 할당 → 횟수는 적지만 무거운 GC 한 번 발생
메인 스레드 GC의 dur_ms가 0.05ms 수준이라면 실질적인 영향이 없으며, timeToInitialDisplayMs가 개선됐다면 전체적으로 정상 개선된 것이다.
3. Compose 리컴포지션
count가 비정상적으로 높은 Composable이 있으면 불필요한 리컴포지션을 의심한다.
SELECT
name,
COUNT(*) AS count,
AVG(dur) / 1e6 AS avg_ms,
SUM(dur) / 1e6 AS total_ms
FROM slice
WHERE name LIKE '%recompos%'
OR name LIKE '%Recompos%'
OR name LIKE '%compose%'
GROUP BY name
ORDER BY count DESC
LIMIT 20
4. Hilt / 클래스 로딩
Hilt 컴포넌트 초기화가 병목인 경우 Lazy injection으로 개선 가능하다.
SELECT
name,
COUNT(*) AS count,
AVG(dur) / 1e6 AS avg_ms,
SUM(dur) / 1e6 AS total_ms
FROM slice
WHERE name LIKE '%ClassLoader%'
OR name LIKE '%Hilt%'
OR name LIKE '%dagger%'
OR name LIKE '%Dagger%'
OR name LIKE '%inflate%'
GROUP BY name
ORDER BY total_ms DESC
LIMIT 20
5. Binder IPC
메인 스레드에서 발생하는 IPC는 ANR 위험 요소다.
SELECT
s.name,
COUNT(*) AS count,
AVG(s.dur) / 1e6 AS avg_ms,
SUM(s.dur) / 1e6 AS total_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
AND (s.name LIKE '%binder%' OR s.name LIKE '%Binder%' OR s.name LIKE 'AIDL%')
GROUP BY s.name
ORDER BY total_ms DESC
6. 파일 I/O (DataStore / Room)
메인 스레드에서 발생 여부를 확인한다. thread_name = 'main' 조건을 추가하면 블로킹 I/O 여부를 바로 확인할 수 있다.
SELECT
s.name,
COUNT(*) AS count,
AVG(s.dur) / 1e6 AS avg_ms,
SUM(s.dur) / 1e6 AS total_ms,
t.name AS thread_name
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE s.name LIKE '%DataStore%'
OR s.name LIKE '%SharedPreferences%'
OR s.name LIKE '%Room%'
OR s.name LIKE '%SQLite%'
OR s.name LIKE '%openDatabase%'
GROUP BY s.name, t.name
ORDER BY total_ms DESC
7. 이미지 리소스 (Coil)
SELECT
name,
COUNT(*) AS count,
AVG(dur) / 1e6 AS avg_ms,
MAX(dur) / 1e6 AS max_ms,
SUM(dur) / 1e6 AS total_ms
FROM slice
WHERE name LIKE '%image%' OR name LIKE '%Image%'
OR name LIKE '%bitmap%' OR name LIKE '%Bitmap%'
OR name LIKE '%decode%' OR name LIKE '%Decode%'
OR name LIKE '%coil%' OR name LIKE '%Coil%'
GROUP BY name
ORDER BY total_ms DESC
Coil 이미지 로딩에 trace 추가
Coil 슬라이스가 trace에 잡히지 않는 경우 직접 계측을 추가한다.
// build.gradle.kts
implementation("androidx.tracing:tracing-ktx:1.3.0")
import androidx.tracing.trace
import coil.EventListener
import coil.request.ImageRequest
class CoilTraceEventListener : EventListener {
override fun fetchStart(request: ImageRequest, fetcher: Any, options: Options) {
trace("Coil.fetch") {}
}
override fun decodeStart(request: ImageRequest, decoder: Any, options: Options) {
trace("Coil.decode") {}
}
}
// ImageLoader 초기화 시 등록
ImageLoader.Builder(context)
.eventListener(CoilTraceEventListener())
.build()
8. 각 카테고리별 total_ms를 한번에 합산
SELECT
category,
COUNT(*) AS slice_count,
SUM(dur) / 1e6 AS total_ms
FROM (
SELECT 'GC' AS category, dur FROM slice
WHERE name LIKE '%GC%' OR name LIKE '%garbage%' OR name LIKE '%HeapTask%'
UNION ALL
SELECT 'Compose 리컴포지션' AS category, dur FROM slice
WHERE name LIKE '%recompos%' OR name LIKE '%Recompos%' OR name LIKE '%compose%'
UNION ALL
SELECT 'Hilt/클래스로딩' AS category, dur FROM slice
WHERE name LIKE '%ClassLoader%' OR name LIKE '%Hilt%'
OR name LIKE '%dagger%' OR name LIKE '%Dagger%' OR name LIKE '%inflate%'
UNION ALL
SELECT 'Binder IPC' AS category, s.dur FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
AND (s.name LIKE '%binder%' OR s.name LIKE [baseline-prof.txt](../app/src/main/baseline-prof.txt)'%Binder%' OR s.name LIKE 'AIDL%')
UNION ALL
SELECT '파일 I/O' AS category, dur FROM slice
WHERE name LIKE '%DataStore%' OR name LIKE '%SharedPreferences%'
OR name LIKE '%Room%' OR name LIKE '%SQLite%' OR name LIKE '%openDatabase%'
UNION ALL
SELECT '이미지 리소스' AS category, dur FROM slice
WHERE name LIKE '%image%' OR name LIKE '%Image%'
OR name LIKE '%bitmap%' OR name LIKE '%Bitmap%'
OR name LIKE '%decode%' OR name LIKE '%Decode%'
OR name LIKE '%coil%' OR name LIKE '%Coil%'
)
GROUP BY category
ORDER BY total_ms DESC
Baseline Profile
ART가 앱 시작 시 JIT 컴파일하며 .art 이미지를 생성하는 Generating runtime image 작업을 앱 설치 시점으로 앞당겨 콜드 스타트를 개선한다.
설정
libs.versions.toml
profileinstaller = "1.4.1"
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
app/build.gradle.kts
implementation(libs.androidx.profileinstaller)
BaselineProfileGenerator.kt
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() = baselineProfileRule.collect(
packageName = PACKAGE_NAME,
) {
pressHome()
// IntroScreen 진입 경로
device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
// HomeScreen 직접 진입 경로
device.executeShellCommand(
"am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
)
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
}
프로필 생성 및 적용
# 1. 프로필 생성
./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.myapp.benchmark.BaselineProfileGenerator#generate
# 2. 생성된 파일 위치
# benchmark/build/outputs/connected_android_test_additional_output/devNonMinifiedRelease/connected/<기기명>/BaselineProfileGenerator_generate-baseline-prof.txt
# 3. app/src/main/baseline-prof.txt 로 복사 (AGP가 자동 인식)
효과 비교 테스트
@Test
fun homeScreenRenderTimeWithBaselineProfile() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(BaselineProfileMode.Require),
setupBlock = { pressHome() },
) {
device.executeShellCommand(
"am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
)
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
| CompilationMode |
의미 |
CompilationMode.None() |
JIT만 사용 (기준값) |
CompilationMode.Partial(BaselineProfileMode.Require) |
Baseline Profile AOT 적용 |
CompilationMode.Full() |
전체 AOT 컴파일 (이론적 최대치) |
baseline-prof.txt 내 클래스 유형
| 유형 |
예시 |
설명 |
| 앱 자체 코드 |
L/... |
isMinifyEnabled = false이면 풀네임 유지 |
| 서드파티 라이브러리 |
La0/k;, La1/e1; |
라이브러리 AAR 자체가 이미 R8으로 배포된 것 |
| Compose 컴파일러 생성 클래스 |
Lgc/r; |
Composable 함수의 람다/동반 클래스 |
Baseline Profile 동작 원리
Baseline Profile은 ART의 하이브리드 컴파일 전략(JIT + AOT)을 그대로 유지하되, AOT 커버리지의 시작점을 설치 시점으로 앞당기는 방식이다.
Baseline Profile 없을 때
앱 설치
↓
앱 실행 → 모든 코드를 JIT으로 시작
↓
ART가 자주 실행되는 코드를 감지해 Cloud Profile / 사용자 프로파일 누적
↓
백그라운드 dex2oat → 점점 AOT 비율 증가 (워밍업 기간 필요)
Baseline Profile 있을 때
앱 설치
↓
profileinstaller가 baseline-prof.txt를 참조해 ART가 AOT 컴파일 수행
↓
앱 실행 → 프로파일에 포함된 코드는 이미 네이티브 코드 → JIT 없이 즉시 실행
프로파일에 없는 코드는 여전히 JIT으로 실행
Baseline Profile은 "이 코드들은 AOT로 미리 컴파일해줘"라는 힌트를 ART에 제공하는 파일이며, JIT이 완전히 사라지는 것이 아니라 프로파일에 포함된 코드에 한해 JIT이 발생하지 않는다.
Perfetto Trace에서 확인되는 차이
| 항목 |
Baseline Profile 미적용 |
Baseline Profile 적용 |
Generating runtime image |
66ms 소요 (런타임 JIT 컴파일) |
항목 자체가 사라짐 |
Generating runtime image가 사라진 것은 해당 코드들이 설치 시점에 이미 AOT 컴파일되어 런타임 JIT이 불필요해진 것이다.
프로파일 커버리지 범위
BaselineProfileGenerator의 generate() 내에서 실제로 탐색한 화면과 코드 경로만 프로파일에 기록된다. 실행되지 않은 화면(예: LoginScreen)은 프로파일에 포함되지 않아 해당 화면 진입 시 JIT이 여전히 발생할 수 있다. 핵심 사용자 플로우를 따라 실제 UI 인터랙션을 추가할수록 AOT 커버리지가 넓어진다.
배포 플로우
generate() 코드 작성 (핵심 플로우 커버)
↓
./gradlew generateBaselineProfile (디바이스 연결 필요)
↓
baseline-prof.txt 생성 → app/src/main/에 복사 후 커밋
↓
./gradlew assemble → baseline-prof.txt가 APK에 번들링됨
↓
Play Store 배포 → 설치 시 AOT 컴파일 수행
generate() 코드 수정 후 반드시 generateBaselineProfile을 재실행해야 변경사항이 반영된다. 코드만 수정하고 assemble하면 이전 프로파일이 그대로 패키징된다.
Baseline Profile 효과가 미미할 때
nonMinifiedRelease 빌드로 테스트하면 효과가 작게 나올 수 있다. Baseline Profile은 R8 minification이 켜진 production release 빌드에서 최대 효과를 발휘한다. 또한 병목이 JIT 컴파일이 아닌 Hilt DI 초기화, Room 열기, DataStore 읽기, API 호출 등이라면 Baseline Profile로는 개선이 어렵다.
12. nonMinifiedRelease와 minification
convention plugin에서 nonMinifiedRelease는 initWith(getByName("release"))로 release의 모든 설정을 복사한다. isMinifyEnabled를 별도로 재정의하지 않으면 release의 isMinifyEnabled = true가 그대로 상속된다.
release {
isMinifyEnabled = true // ← 이게
}
create("nonMinifiedRelease") {
initWith(getByName("release")) // ← 여기서 복사됨
// isMinifyEnabled 재정의 없음 → 결국 true
}
목적에 따라 선택
| 선택 |
설정 |
특징 |
| trace 가독성 우선 |
isMinifyEnabled = false 명시 추가 |
Perfetto에서 클래스명 풀네임으로 표시, 생성된 Baseline Profile은 production과 클래스명 불일치 |
| production 환경 재현 |
현재 상태 유지 (minification 켜짐) |
Baseline Profile이 production release 빌드와 클래스명 일치하여 그대로 적용 가능 |