From Icon Tap to First Frame: Measuring and Cutting Android Cold Start by 70%
This is the third article in the "Android App Performance Optimization" series. The first two articles addressed fluency after content appears (XML rendering hierarchy and Overdraw, Compose Recomposition). This one goes back to an earlier moment: between the user tapping the icon and the first frame / full content appearing.
After reading this article you will: understand the full cold-start chain and source execution order → use a three-tier tool chain to locate where time is spent → apply four cuts (SDK initialization tiering and DAG evolution, ContentProvider governance, moving heavy main-thread work off with TTID/TTFD trade-offs, Baseline Profile and Startup Profile) → use Macrobenchmark on two real devices for repeatable, quantitative acceptance.
Environment note: All timing data in this article comes from the accompanying open-source demo project StartupDemo measured on two cross-generation real devices — Xiaomi Mi 6 (Snapdragon 835, Android 9 / API 28) and Xiaomi 13 Pro (Snapdragon 8 Gen 2, Android 16 / API 36) — in non-debuggable Release/Benchmark builds (10 cold starts each, median taken). See "Appendix: Demo project and measurement environment" at the end.
1. Problem statement: user taps the app, content takes a while to appear
Product came again.
"Startup is too slow. After tapping the icon there's a wait before content appears, especially on existing mid-to-low-end devices. Users have mentioned it several times in store reviews."
Don't guess the cause; measure it.
Step one: make sure it's a cold start. Kill the process first, then start and wait for the first frame:
adb shell am force-stop com.example.startupdemo
adb shell am start -W -n com.example.startupdemo/.MainActivity
On our two test devices, the unoptimized baseline version (step0-baseline) produces:
# Device A: Xiaomi Mi 6 (Snapdragon 835, Android 9 / API 28)
Starting: Intent { cmp=com.example.startupdemo/.MainActivity }
Status: ok
Activity: com.example.startupdemo/.MainActivity
ThisTime: 1266
TotalTime: 1266
WaitTime: 1285
Complete
# Device B: Xiaomi 13 Pro (Snapdragon 8 Gen 2, Android 16 / API 36)
Starting: Intent { cmp=com.example.startupdemo/.MainActivity }
Status: ok
LaunchState: COLD
Activity: com.example.startupdemo/.MainActivity
TotalTime: 754
WaitTime: 759
Complete
Note: Among 10 cold starts, Xiaomi Mi 6
TotalTimemedian is 1269.5ms (WaitTimemedian 1288.5ms); Xiaomi 13 ProTotalTimemedian is 760.5ms (WaitTimemedian 766.0ms). Android 10+ printsLaunchState: COLDdirectly.
Step two: look at the Displayed log in Logcat. The system prints a line after the first frame is drawn and submitted:
# Xiaomi Mi 6 (Android 9, tag is ActivityManager)
10-26 12:17:15.412 2088 2110 I ActivityManager: Displayed com.example.startupdemo/.MainActivity: +1s266ms
# Xiaomi 13 Pro (Android 16, tag is ActivityTaskManager)
10-23 12:11:08.918 2880 2904 I ActivityTaskManager: Displayed com.example.startupdemo/.MainActivity for user 0: +754ms
(The log tag varies by system version: Android 9 and below use ActivityManager, Android 10+ use ActivityTaskManager. Just run adb logcat -d | grep Displayed.)
Is this number fast or slow? Android official documentation (App startup time) gives a clear red line: Android vitals treats cold start ≥ 5 seconds as excessive (warm start ≥ 2 seconds, hot start ≥ 1.5 seconds). Vitals's default core metric is TTID (Time To Initial Display). Perceptually, once cold start exceeds 1 second, users clearly notice the transition wait.
Now that we know total time, the key question is: where did this 1269.5ms (flagship: 760.5ms) go?
Many people's first instinct is "homepage layout too heavy" or "network request too slow." But in the official division of cold start, homepage layout and rendering are just the last segment. Preceding it are process creation, ContentProvider initialization, Application creation, and Activity creation. To cut efficiently, you must break down the entire chain.
2. Related knowledge
2.1 Cold start full chain: how time is split
Cold start means the app starts "from scratch": before that, the system has no process for the app. Official docs split it into five stages:
① Load and start the app (system SystemServer / Launcher)
└─ Parse Intent, check permissions, show Starting Window (SplashScreen)
↓
② Create app process (system Zygote fork)
└─ fork new process, init ART VM, call ActivityThread.main()
↓
③ Create and bind Application object (ActivityThread.handleBindApplication)
├─ Application.attachBaseContext()
├─ installContentProviders() → all ContentProvider.onCreate() ← before Application.onCreate()!
└─ Instrumentation.callApplicationOnCreate() → Application.onCreate()
↓
④ Create and start main Activity (ActivityThread.performLaunchActivity)
└─ Activity.onCreate() → onStart() → onResume()
↓
⑤ First draw (ViewRootImpl.performTraversals / Choreographer#doFrame)
└─ measure / layout / draw + RenderThread render submission (topics of first two articles)
↓
First frame appears = TTID (Displayed)
Among these five, ①② are done by the system and cannot be directly intervened from the app layer; ③④⑤ are all your code (or third-party library code), and each is a target for optimization.
Pay special attention to a highly concealed source-level detail in stage ③: In AOSP ActivityThread.handleBindApplication(), after calling app.attachBaseContext(), the system first calls installContentProviders() to run every registered ContentProvider's onCreate() one by one, and only then calls mInstrumentation.callApplicationOnCreate(app) to execute Application.onCreate() — all serially on the main thread. Later we'll see this mechanism's damage in a real project.
2.2 Two metrics: TTID and TTFD
| Metric | Full name and meaning | How to collect | Limitations |
|---|---|---|---|
| TTID | Time To Initial Display: time when the first frame is drawn and shown | Automatically computed by the system; Logcat Displayed log and Macrobenchmark's timeToInitialDisplayMs |
Only reflects "how quickly the first frame draws"; even a pure white screen or empty skeleton counts as complete |
| TTFD | Time To Full Display: time when asynchronous data finishes loading and the user can actually interact | Developer must call Activity.reportFullyDrawn() when core content is ready (in Compose, ReportDrawnWhen { ... }); system prints Fully drawn log |
Depends on correct reporting timing; if not reported, Macrobenchmark won't output timeToFullDisplayMs |
Both metrics are necessary: focusing only on TTID can easily produce a fake optimization of "first frame very fast, but the user stares at an empty skeleton for a long time." Step 3 later uses two-device real measurements to dissect this trade-off.
2.3 Diagnostic toolchain: make time visible first
The biggest taboo in cold-start optimization is "blind men and an elephant." A standard engineering diagnostic chain has three layers:
Layer 1: am start -W and Displayed / Fully drawn logs.
Fastest, coarsest: tells you total launch time in seconds, good for smoke tests, but it's a black box — doesn't tell you where time went.
Layer 2: Perfetto system trace + custom Trace points.
This is the main microscope for "where did time go." Use android.os.Trace to wrap key initialization with traceSection:
import android.os.Trace
inline fun <T> traceSection(name: String, block: () -> T): T {
Trace.beginSection(name)
try {
return block()
} finally {
Trace.endSection()
}
}
// Use in initialization code
override fun onCreate() {
super.onCreate()
traceSection("init.crash") { CrashReporter.init(this) }
}
When recording traces, don't use a Debug build (Debug disables compiler optimizations and adds debugging connection overhead, skewing startup timing). Use an R8-enabled Release build and add to <application> in AndroidManifest.xml:
<profileable android:shell="true" />
Then capture startup trace with perfetto command or Android Studio Profiler, open in Perfetto UI (ui.perfetto.dev), and see each traceSection's exact start, end, and duration on the main-thread timeline.
Layer 3: Jetpack Macrobenchmark. The first two tools tell you what happened in "one launch"; but cold start fluctuates with system I/O cache and CPU frequency scheduling, so a single measurement can't be the acceptance basis. Macrobenchmark answers "what is the statistical distribution of many cold starts?":
@get:Rule val rule = MacrobenchmarkRule()
@Test
fun coldStartup() = rule.measureRepeated(
packageName = "com.example.startupdemo",
metrics = listOf(StartupTimingMetric()),
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = { pressHome() }
) {
startActivityAndWait()
}
It automatically repeats kill-process and cold start 10+ times, outputting timeToInitialDisplayMs and timeToFullDisplayMs min, median, and max. All subsequent acceptance comparisons in this article use 10-cold-start medians.
2.4 Three culprits that slow cold start
Culprit ①: Application.onCreate() synchronously, serially initializes a pile of SDKs
class DemoApp : Application() {
override fun onCreate() {
super.onCreate()
CrashReporter.init(this) // crash reporting
AnalyticsSdk.init(this) // analytics
PushSdk.init(this) // push
MapSdk.init(this) // map component
AppDatabaseInitializer.initAndQuery(this) // database
HttpInitializer.init(this) // network and image library
// ... every release, every new SDK, another line
}
}
All these initializations run serially, hog the main thread, and block the first frame. Every SDK owner will say "my init is fast, just a few tens of milliseconds," but a dozen SDKs stacked together becomes hundreds of milliseconds or even a second of white screen.
Culprit ②: ContentProviders "run ahead" where you can't see them
Many third-party SDKs (and some early Jetpack libraries), to tout "zero-config integration — not even one line in Application.onCreate()," secretly register a ContentProvider in their own AAR's AndroidManifest.xml. Thanks to the mechanism in 2.1, the system instantiates these providers and calls their onCreate() before Application.onCreate().
After Gradle Manifest Merger, all these providers end up in your final APK. Result: you wrote zero lines in DemoApp.kt and AndroidManifest.xml, yet the main thread already runs a string of initialization logic you never knew about.
How to find them? In Android Studio, open app/src/main/AndroidManifest.xml, switch to the Merged Manifest tab at the bottom (or directly inspect build output app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml), and search for <provider>:
In our demo project, the business main module's AndroidManifest.xml has no <provider> at all, yet the merged manifest has 3 <provider>s (involving 6 init components): two from third-party SDK modules (FakePushInitProvider, FakeAnalyticsInitProvider), and one Jetpack App Startup InitializationProvider (which internally mounts WorkManager, EmojiCompat, ProcessLifecycle, ProfileInstaller four initializers).
Culprit ③: the first Activity does I/O and parsing heavy work on the main thread
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ❌ Main thread synchronously reads a large file + parses 500 product JSON; not a single pixel draws until done
val products = traceSection("activity.loadProducts") {
ProductLoader.loadFromAssets(this)
}
setContent { ProductListScreen(products) }
}
File reads, synchronous DB queries, large JSON deserialization — if done inside the first Activity.onCreate() synchronously, setContent and the subsequent first-frame draw can only wait.
2.5 Baseline Profile and Startup Profile: make first-frame code skip "interpreted execution" and page faults
Even after moving non-main-thread work off, the main thread still executes thousands of methods during cold start (especially Jetpack Compose, which ships as an APK-distributed library and, unlike traditional Android View, is not precompiled in the system image boot.art).
Since Android 7.0 (API 24) introduced mixed compilation, a freshly-installed app's DEX bytecode hasn't been AOT-compiled; many methods must run through the Interpreter or JIT, which is another hidden cost of cold start.
Baseline Profile is a "hot class and method list" packaged with the APK (source at src/main/baseline-prof.txt; AGP compiles it to binary via CompileArtProfileTask with R8 obfuscation mapping, stored at assets/dexopt/baseline.prof in the APK). How it works:
- With Google Play (or a store supporting Cloud/Baseline Profile): during installation, the system reads
assets/dexopt/baseline.profand AOT-compiles the listed hot methods into machine code inspeed-profilemode, so the user's first launch skips interpreted execution. - Do domestic app stores / local installs (no Google Play) benefit? Yes! By adding
androidx.profileinstaller:profileinstaller,ProfileInstallerInitializerafter the first frame is drawn on first launch, in the background, transcodesassets/dexopt/baseline.profinto the system directory/data/misc/profiles/cur/0/<packageName>/primary.prof. This way, without waiting for the user to slowly accumulate JIT hot spots, whenever the device enters idle+charging state and triggers the systembg-dexopt-job(or viacmd package compile -m speed-profilefor testing), ART can precompile immediately using this list. - Combine with Startup Profile for DEX layout optimization: In AGP 8.2+, setting
includeInStartupProfile = truealso guides R8 to perform DEX Layout Optimizations during packaging — compactly arranging startup-critical classes at the head of the mainclasses.dex, greatly reducing page faults from disk during cold-start class loading.
Also clearly understand its boundary: Baseline Profile optimizes "CPU execution speed of existing code"; it does nothing for thread blocking, file I/O waits, or lock contention. So it fits as the "last cut" after emptying the main thread's heavy work.
3. Concrete sample application: StartupDemo four-cut practice
Scenario: product list app cold-start full-chain optimization
About the demo project: All measured data comes from the open-source StartupDemo (repo contains five cumulative Git tags
step0-baselinethroughstep4-baseline-profile).
- Real components: Room (real SQLite table creation and synchronous query
init.db), WorkManager (androidx.workscheduler), OkHttp + Coil (real disk cache andImageLoaderinitinit.http), App Startup, Lifecycle Process, ProfileInstaller are all official real libraries.- Simulated SDKs: push (
PushSdk), analytics (AnalyticsSdk), map (MapSdk), crash reporting (CrashReporter), plus two implicitly registered third-party providers (FakePushInitProvider,FakeAnalyticsInitProvider), all encapsulated in the:fakesdksmodule. To guarantee 100% truthfulness and measurability, the simulated modules strictly forbidThread.sleep; they use real local file read/write, JSON serialization/deserialization, and iterative SHA-256 digest computation (all workload constants fixed inSimConfig.kt, identical across five stages). Their timing magnitudes are meant to make bottlenecks visible on the Perfetto timeline; do not treat the specific milliseconds as real timings of any particular commercial SDK.
Step 0: Diagnose the scene — look at tools first, then code
Before changing a single line, we grab a Perfetto trace on step0-baseline and run 10 cold-start benchmarks.
Compile the median measured timings for each stage on both real devices:
| Stage / custom trace section | Medians on Mi 6 (SD 835, API 28) | Medians on Mi 13 Pro (SD 8 Gen 2, API 36) | Thread |
|---|---|---|---|
③-A ContentProvider stage (all provider onCreates) |
224.0ms (17.6% of TTID) | 74.3ms (9.8% of TTID) | main |
├─ provider.fakepush (FakePushInitProvider) |
123.6ms | 43.4ms | main |
├─ provider.fakeanalytics (FakeAnalyticsInitProvider) |
83.2ms | 38.7ms | main |
└─ InitializationProvider (incl. WorkManagerInitializer etc., 4 items) |
17.2ms | 2.2ms | main |
③-B Application.onCreate stage |
515.6ms (40.6% of TTID) | 192.5ms (25.3% of TTID) | main |
├─ init.crash (crash SDK, P0) |
28.9ms | 10.6ms | main |
├─ init.analytics (analytics SDK, P1) |
108.6ms | 41.0ms | main |
├─ init.push (push SDK, P1) |
156.8ms | 59.4ms | main |
├─ init.map (map SDK, P2) |
205.0ms | 77.5ms | main |
├─ init.db (Room DB open and initial query) |
5.1ms | 1.4ms | main |
└─ init.http (OkHttp + Coil ImageLoader creation) |
9.9ms | 1.9ms | main |
④ Activity.onCreate stage |
169.6ms (13.4% of TTID) | 226.8ms (29.8% of TTID) | main |
└─ of which activity.loadProducts (read + parse 500 product JSON) |
161.3ms | 210.4ms | main |
⑤ First draw stage (Choreographer#doFrame: Compose composition/layout/draw) |
179.4ms (14.1% of TTID) | 214.1ms (28.2% of TTID) | main |
| TTID (median of 10 cold starts, Min–Max) | 1269.5ms (1258–1344ms) | 760.5ms (741–1024ms) | — |
From this table and Perfetto timeline, we get three hard conclusions:
- Execution-order proof: In the
bindApplicationstage,provider.fakepush(28.0ms151.6ms),235.5ms), andprovider.fakeanalytics(152.3msInitializationProviderall run beforeApplication.onCreate(256.0ms~772.5ms). - Cognitive flip: On the Mi 6, the first frame drawing that actually paints the homepage (
179.4ms) is only 14.1% of total startup;ContentProvider(224.0ms) +Application.onCreate(515.6ms) together eat 739.6ms, 58.3% of total cold-start time! Before the first frame even starts measuring layout, the main thread has struggled ~0.9s in initialization quicksand. - Cross-generation amplification: The same synchronous blocking code takes 760.5ms on a Snapdragon 8 Gen 2 flagship but expands to 1269.5ms on Snapdragon 835 (just
Application.onCreategrows from 192.5ms to 515.6ms, a 2.7x difference). This is why devs only testing on top-end hardware say "it's fine," but existing-device users complain about startup freezing after release.
Cause locked. Now apply four cuts in order of descending benefit.
Step 1: Cut one — tier the initialization in Application.onCreate (tag: step1-init-tiering)
Not every SDK must initialize before the first frame. We split tasks in Application.onCreate() into three levels by "when it's truly needed":
| Level | Definition | Typical example | Handling |
|---|---|---|---|
| P0 | Must complete before first frame; otherwise startup exceptions are missed; and it's very lightweight | CrashReporter |
Keep in Application.onCreate on main thread synchronously |
| P1 | Not needed for first frame, but should be ready shortly after startup | PushSdk, AnalyticsSdk |
Dispatch to background coroutine/thread pool asynchronously (don't block main) |
| P2 | Only needed when entering a specific secondary page or triggering a specific feature | MapSdk (maps, 205ms) |
Move completely out of startup path; use by lazy lazy-load on demand |
Note: In Step 0 measurements, real
Room(init.db5.1ms) andOkHttp/Coil(init.http9.9ms) total about 15ms. To strictly control experimental variables, we keep these two unchanged in this demo; but in real large projects, if the DB has complex migrations or the network layer has heavy cert verification, they should also go into P1/P2 async or lazy.
After refactoring, DemoApp.kt:
// P2: only initialized when the user first taps the "Map" entry; startup cost zero
object MapSdkHolder {
lateinit var appContext: Context
val instance: MapSdk by lazy {
traceSection("init.map") { MapSdk.init(appContext) }
}
}
class DemoApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
MapSdkHolder.appContext = applicationContext
traceSection("app.onCreate") {
// P0: must complete before first frame, and lightweight
traceSection("init.crash") { CrashReporter.init(this) }
// P1: doesn't block first frame, offload to background thread pool
appScope.launch {
traceSection("init.push") { PushSdk.init(this@DemoApp) }
traceSection("init.analytics") { AnalyticsSdk.init(this@DemoApp) }
}
// Room and OkHttp/Coil stay as is
traceSection("init.db") { AppDatabaseInitializer.initAndQuery(this) }
traceSection("init.http") { HttpInitializer.init(this) }
}
}
}
⚠️ Three prerequisites to confirm before touching code
- Does the SDK allow initialization on a background thread? Many SDKs containing UI/WebView or internally creating
Handler()(without passingLooper.getMainLooper()) strongly depend on the main thread; moving them off throwsRuntimeException: Can't create handler inside thread that has not called Looper.prepare(). Check docs or decompile before changing. - Are there topological dependencies between initializers? If
AnalyticsSdkreads a device ID generated byDeviceIdSdk, you can't throw both into a concurrent thread pool without synchronization and expect correct ordering. - Privacy compliance red line: SDKs that collect Android ID, OAID, MAC, location, etc. (analytics, push, ads) must never initialize before the user taps "Agree" on the privacy policy dialog (even on a background thread; ministry compliance checks use hook call-stack detection).
💡 Industry evolution: when 5 SDKs becomes 50 (DAG startup task scheduling framework)
In the demo we have only a few SDKs; hand-writing appScope.launch + by lazy is clean. But in large commercial apps, startup tasks often number 30–40 with complex dependencies (e.g., network lib + device fingerprint → login state restore → ABTest config fetch).
The industry-standard approach is a DAG (Directed Acyclic Graph)-based startup framework (e.g., Alibaba's Alpha, the Anchors mechanism, or a self-built coroutine DAG scheduler):
- Encapsulate each init action as a
StartupTask, declaringdependencies(),runOnMainThread(), andneedWait()(whether to block waiting for its completion before entering the first screen). - The launcher builds a topological graph at compile time or at startup, using
CountDownLatch/CompletableDeferredto schedule concurrently between main thread and background threads, eliminating hand-written callback hell.
Measured verification (Step 1):
# Xiaomi Mi 6 (Snapdragon 835, API 28)
Application.onCreate main-thread time: 515.6ms → 48.3ms (-90.6%)
Step 1 TTID (10-run median): 1269.5ms → 811.0ms (-458.5ms / -36.1%)
# Xiaomi 13 Pro (Snapdragon 8 Gen 2, API 36)
Application.onCreate main-thread time: 192.5ms → 21.8ms (-88.7%)
Step 1 TTID (10-run median): 760.5ms → 441.0ms (-319.5ms / -42.0%)
This one cut alone saves 458.5ms on Mi 6 and 319.5ms on Mi 13 Pro — the largest single-step gain in the whole optimization.
Step 2: Cut two — govern ContentProviders (tag: step2-provider-governance)
Step 1 compressed Application.onCreate to 48.3ms, but looking back at Perfetto, the ContentProvider stage still occupies 223.4ms (98.4ms on Mi 13 Pro).
We pull every <provider> and its internal <meta-data> from the Merged Manifest into a table and review each for keep/remove:
Providers from different sources require completely different handling; three cases:
Case A: Components based on Jetpack App Startup (e.g., WorkManager)
First clarify a common misconception: Jetpack App Startup is not for "asynchronous acceleration"; its core role is to merge each library's separately-registered ContentProvider into a single InitializationProvider, saving the overhead of instantiating multiple providers, and providing a lightweight dependency-order declaration. But all Initializer.create() calls inside InitializationProvider still run synchronously on the main thread by default!
For heavy components like WorkManager mounted under InitializationProvider but not needed for the first screen, the official recommendation is to remove its automatic initialization meta-data in app/src/main/AndroidManifest.xml using tools:node="remove":
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- Remove WorkManager's auto-init, switch to on-demand -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
Then make DemoApp implement the Configuration.Provider interface. This way WorkManager runs no code during startup; only when the user taps the "Sync" button on the list page and first calls WorkManager.getInstance(context) does it read the config and initialize on demand:
class DemoApp : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.build()
}
Case B: Third-party libraries that register their own independent ContentProvider
For :fakesdks's implicit FakePushInitProvider and FakeAnalyticsInitProvider, we strip them entirely with tools:node="remove" in the main project's AndroidManifest.xml, then manually call their init entry points from DemoApp's background coroutine:
<!-- Step 2: Remove third-party auto-registered ContentProviders, let the app init them on background coroutines -->
<provider
android:name="com.example.fakesdks.FakePushInitProvider"
android:authorities="${applicationId}.fakepush-init"
tools:node="remove" />
<provider
android:name="com.example.fakesdks.FakeAnalyticsInitProvider"
android:authorities="${applicationId}.fakeanalytics-init"
tools:node="remove" />
⚠️ Risk warning: Only remove a library's provider when the third-party library explicitly provides a manual initialization API (or you can confirm its internal Context can be injected manually). If a closed-source SDK only assigns
sContextinside its ownContentProvider.onCreate()and exposes no other entry, blindly usingtools:node="remove"will cause it to throwNullPointerExceptionat runtime. Confirm documentation or decompile before removal, and run full functional regression after removal.
Case C: Your own base libraries want to consolidate ContentProviders
If you're developing an internal component library, never let each module register its own ContentProvider to grab a Context. Rewrite them to implement App Startup's Initializer<T> and share the single InitializationProvider:
class MySdkInitializer : Initializer<MySdk> {
override fun create(context: Context): MySdk = MySdk.init(context)
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
Measured verification (Step 2):
After recompiling, check app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml — FakePushInitProvider, FakeAnalyticsInitProvider, and WorkManagerInitializer are gone from the merged manifest. Real-device results:
# Xiaomi Mi 6 (Snapdragon 835, API 28)
ContentProvider stage time: 223.4ms → 2.2ms (-99.0%)
Step 2 TTID (median): 811.0ms → 646.5ms (-164.5ms / -20.3%)
# Xiaomi 13 Pro (Snapdragon 8 Gen 2, API 36)
ContentProvider stage time: 98.4ms → 2.2ms (-97.8%)
Step 2 TTID (median): 441.0ms → 306.0ms (-135.0ms / -30.6%)
Step 3: Cut three — move heavy work out of the first Activity's main thread (tag: step3-activity-offload)
After the first two cuts, ContentProvider (2.2ms) and Application.onCreate (63.9ms) are both very light. On the Mi 6, the main thread's biggest blocking block is now MainActivity.onCreate's synchronous read-and-parse of 500 product JSON (163.1ms).
We move file reading and parsing into MainViewModel's Dispatchers.IO coroutine. The first frame renders a lightweight skeleton SkeletonList(), then auto-refreshes to ProductList() when data parsing completes, and reports TTFD via ReportDrawnWhen:
// ❌ Before (Step 0 ~ Step 2): main thread synchronously reads file + parses JSON, first frame must wait for it
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val products = traceSection("activity.loadProducts") {
ProductLoader.loadFromAssets(this)
}
setContent { ProductListScreen(products = products) }
}
// ✅ After (Step 3): first frame shows skeleton, IO thread parses then auto-refreshes and reports TTFD
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val _products = MutableStateFlow<List<Product>?>(null)
val products: StateFlow<List<Product>?> = _products
init {
viewModelScope.launch(Dispatchers.IO) {
_products.value = traceSection("activity.loadProducts") {
ProductLoader.loadFromAssets(getApplication())
}
}
}
}
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialFlow = vm.products
setContent {
val list by initialFlow.collectAsState()
MaterialTheme {
// Render SkeletonList() when list == null, real list when not null
ProductListScreen(products = list)
}
// When the real product list arrives and finishes composition/draw, call reportFullyDrawn() to report TTFD
ReportDrawnWhen { list != null }
}
}
}
Measured verification and deep findings (Step 3's TTID vs TTFD trade-off):
# Xiaomi Mi 6 (Snapdragon 835, API 28)
Activity.onCreate main-thread time: 172.2ms → 13.1ms (-92.4%)
Step 3 TTID (median) : 646.5ms → 481.5ms (-165.0ms / -25.5%, first-frame skeleton)
Step 3 TTFD (median) : 646.5ms → 567.0ms (-79.5ms / -12.3%, real product list)
# Xiaomi 13 Pro (Snapdragon 8 Gen 2, API 36)
Activity.onCreate main-thread time: 59.7ms → 9.7ms (-83.8%)
Step 3 TTID (median) : 306.0ms → 237.5ms (-68.5ms / -22.4%)
Step 3 TTFD (median) : 306.0ms → 238.0ms (-68.0ms / -22.2%)
Look carefully at these two sets of real data — the two cross-generation devices show completely different yet perfectly self-consistent phenomena:
- On the Mi 6 (Snapdragon 835): TTID drops 165.0ms, but TTFD only drops 79.5ms — a gap of 85.5ms opens up!
Why? Look at the segment data in
step3/benchmark_mi6.json: in Step 2,activity.loadProductswhen running exclusively on main thread takes 163.1ms; after Step 3 moves it toDispatchers.IO, it stretches to 226.0ms! Reason: Step 1 and Step 2 threw four simulated SDK initializations into background coroutines; Step 3 now also throws product JSON parsing into background coroutines. On Snapdragon 835's limited CPU cores, multiple background threads and the main thread's first Compose render compete for CPU time slices, causing data load completion (567.0ms) to lag first-frame skeleton display (481.5ms) by 85.5ms. This is a wake-up call: moving tasks to background threads is not a "free lunch." If background threads are stuffed with high-CPU P1 tasks, they not only slow first-screen business data loading (raising TTFD), they also steal CPU cycles from the main thread! In production, first-screen data requests should have higher scheduling priority than ordinary P1 SDK initializations. - On the Mi 13 Pro (Snapdragon 8 Gen 2): TTID and TTFD both drop to 238ms!
Because the 8 Gen 2 has abundant multi-core parallelism, the
Dispatchers.IOthread finishes parsing 500 products in just 59.0ms (at141.8msafter process start) and stuffs them into theStateFlow— while the main thread is still busy with first Compose composition and RenderThread shader init (first frame completes at201.3ms). As a result, background data beats first-frame rendering, and the first frame directly draws the full product list, benefiting both TTID and TTFD!
Step 4: Cut four — Baseline Profile, the last cut (tag: step4-baseline-profile)
The first three cuts are all "make the main thread do less." Now the main thread only has init.crash, lightweight Activity.onCreate, and first draw (on the Mi 6, 190.0ms, now the biggest chunk). Cut four's goal: "make what the main thread must do execute faster."
1. Write a generator and collect the profile. In the :baselineprofile module, use BaselineProfileRule to cover the full path from cold start to first-screen render (and set includeInStartupProfile = true to enable DEX layout optimization):
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(
packageName = "com.example.startupdemo",
includeInStartupProfile = true
) {
pressHome()
startActivityAndWait()
}
}
The generated rule file lives at app/src/main/baseline-prof.txt (contains HSPLcom/example/startupdemo/... hot startup methods and Lcom/example/startupdemo/...; startup classes). When running ./gradlew assembleRelease, AGP's mergeReleaseArtProfile and compileReleaseArtProfile tasks merge it with rules from Jetpack Compose, Room, Lifecycle, etc. (this project merges to 80 rules under com/example/ plus thousands of Jetpack rules), compiling into assets/dexopt/baseline.prof inside the APK.
2. Verify real gains with Macrobenchmark comparing None and Partial:
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBenchmarks {
@get:Rule val rule = MacrobenchmarkRule()
@Test fun startupNoProfile() = startup(CompilationMode.None())
@Test fun startupWithProfile() =
startup(CompilationMode.Partial(BaselineProfileMode.Require))
private fun startup(mode: CompilationMode) = rule.measureRepeated(
packageName = "com.example.startupdemo",
metrics = listOf(StartupTimingMetric()),
compilationMode = mode,
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = { pressHome() }
) {
startActivityAndWait()
}
}
Here CompilationMode.Partial(BaselineProfileMode.Require)'s Require is crucial: it forces installation of the APK's built-in baseline.prof via ProfileInstallReceiver and runs cmd package compile -f -m speed-profile; if profile installation or compilation fails, it throws and aborts, preventing false "looks effective but isn't" data.
📝 API 34+ measurement note: On Android 14 (API 34) and higher (e.g., our test device Mi 13 Pro, Android 16 / API 36), the system triggers some background precompilation after the app's first launch, and the new architecture's big cores are so fast at interpretation/JIT, so record the device's API level explicitly in reports.
Measured verification (Step 4: CompilationMode.None() vs CompilationMode.Partial(Require)) :
# Xiaomi Mi 6 (Snapdragon 835, Android 9 / API 28)
CompilationMode.None() : TTID = 490.5ms, TTFD = 578.5ms, first-draw FirstDraw = 193.1ms
CompilationMode.Partial(): TTID = 380.5ms, TTFD = 424.0ms, first-draw FirstDraw = 112.4ms
→ First-draw time drops 41.8% (193.1ms → 112.4ms), TTID further drops 110.0ms (-22.4%)!
# Xiaomi 13 Pro (Snapdragon 8 Gen 2, Android 16 / API 36)
CompilationMode.None() : TTID median = 246.0ms (range 224.0–364.0ms)
CompilationMode.Partial(): TTID median = 238.0ms (range 224.0–272.0ms)
→ TTID median improves 8.0ms (-3.3%), max tail latency drops from 364.0ms to 272.0ms (-25.3%)!
On the mid-to-low-end device (Mi 6), due to limited single-core CPU power, Compose's many classes' first interpreted execution and JIT compilation originally took 193.1ms; with Baseline Profile precompiled to machine code, first draw plunges to 112.4ms (41.8% faster), and background JSON parsing also speeds up from 226.4ms to 183.2ms, bringing overall TTID from 490.5ms to 380.5ms (22.4% faster) and TTFD from 578.5ms to 424.0ms (26.7% faster)!
Also verified that Step 1's P2 map lazy-load button and Step 2's WorkManager on-demand sync button work 100% normally:
Quantitative comparison: two-device full-chain measured total table
Data source: StartupDemo/results/ under
summary_mi6.csv,summary_mi13pro.csv, andbenchmark.jsoninstep0~`step4. Each group is non-debuggable Release build, cold start (COLD`), 10 consecutive runs taking median (range in parentheses).
Table 1: Device A — Xiaomi Mi 6 (Snapdragon 835, Android 9 / API 28) full-stage evolution comparison
| Metric | Step 0 (baseline) |
Step 1 (init-tiering) |
Step 2 (provider-gov) |
Step 3 (activity-offload) |
Step 4 (None no profile) |
Step 4 (Partial with profile) |
Total reduction (Step 0 → Step 4) |
|---|---|---|---|---|---|---|---|
| TTID median (range) | 1269.5ms (1258–1344) | 811.0ms (796–875) | 646.5ms (620–1030) | 481.5ms (464–878) | 490.5ms (470–906) | 380.5ms (366*–400) | -889.0ms (-70.0%) |
| TTFD median | 1269.5ms | 811.0ms | 646.5ms | 567.0ms | 578.5ms | 424.0ms | -845.5ms (-66.6%) |
| ContentProvider stage | 224.0ms | 223.4ms | 2.2ms | 2.2ms | 2.2ms | 2.2ms | -221.8ms (-99.0%) |
Application.onCreate |
515.6ms | 48.3ms | 63.9ms | 62.5ms | 64.2ms | 53.8ms | -461.8ms (-89.6%) |
Activity.onCreate |
169.6ms | 170.5ms | 172.2ms | 13.1ms | 13.7ms | 12.8ms | -156.8ms (-92.5%) |
First draw (FirstDraw) |
179.4ms | 183.6ms | 195.3ms | 190.0ms | 193.1ms | 112.4ms | -67.0ms (-37.3%) |
*Note: On Mi 6 Step 4 (
Partial), round 10 had a 109.0ms non-full cold-start anomaly due to a race betweenam force-stopand system launcher switch (retained and annotated in rawbenchmark_mi6.json); remaining 9 valid cold starts have min 366.0ms, 10-round median 380.5ms (9-round median 381.0ms).
Table 2: Device B — Xiaomi 13 Pro (Snapdragon 8 Gen 2, Android 16 / API 36) full-stage evolution comparison
| Metric | Step 0 (baseline) |
Step 1 (init-tiering) |
Step 2 (provider-gov) |
Step 3 (activity-offload) |
Step 4 (None no profile) |
Step 4 (Partial with profile) |
Total reduction (Step 0 → Step 4) |
|---|---|---|---|---|---|---|---|
| TTID median (range) | 760.5ms (741–1024) | 441.0ms (399–842) | 306.0ms (252–383) | 237.5ms (212–277) | 246.0ms (224–364) | 238.0ms (224–272) | -522.5ms (-68.7%) |
| TTFD median | 760.5ms | 441.0ms | 306.0ms | 238.0ms | 246.1ms | 238.1ms | -522.4ms (-68.7%) |
| ContentProvider stage | 74.3ms | 98.4ms | 2.2ms | 2.2ms | 2.2ms | 2.2ms | -72.1ms (-97.0%) |
Application.onCreate |
192.5ms | 21.8ms | 26.0ms | 25.6ms | 25.4ms | 27.1ms | -165.4ms (-85.9%) |
Activity.onCreate |
226.8ms | 62.2ms | 59.7ms | 9.7ms | 10.3ms | 11.2ms | -215.6ms (-95.1%) |
First draw (FirstDraw) |
214.1ms | 168.3ms | 118.2ms | 162.3ms | 102.6ms | 114.6ms | -99.5ms (-46.5%) |
Putting the two devices' four-cut evolution together gives a clear global picture of cold-start optimization:
- On the Mi 6, cold-start TTID drops from 1269.5ms to 380.5ms (70.0% faster), TTFD from 1269.5ms to 424.0ms (66.6% faster); total in-app main-thread time compresses from 1088.6ms to 181.2ms.
- On the Mi 13 Pro, cold-start TTID drops from 760.5ms to 238.0ms (68.7% faster), delivering a tap-the-icon-and-it-opens-instant experience.
4. Summary
Quick-reference card: 5 cold-start optimization principles
✅ 1. Measure first, then optimize
Watch both TTID and TTFD; use Perfetto + custom Trace.beginSection to find "where time went,"
and Macrobenchmark for multi-run cold-start medians as acceptance, never conclude from a single Logcat.
✅ 2. `Application.onCreate` initializations must be tiered
P0 must-before-first-frame (and lightweight) / P1 after-first-frame-asap (background coroutine/thread pool) / P2 init-on-use (by lazy).
Confirm three things before changing: can it init on background thread, are there topological dependencies (use DAG scheduler for complex cases), and is it privacy-compliance constrained.
✅ 3. Beware invisible implicit initialization in your app
Review all <provider>s in Merged Manifest;
App Startup-based components (e.g., WorkManager) can have meta-data removed and switch to on-demand init;
For third-party independently-registered Providers, use tools:node="remove" only after confirming manual init support, then run full regression.
✅ 4. The first Activity must not do I/O and heavy parsing on the main thread
Show skeleton first, load data in Dispatchers.IO and refresh;
TTID being good doesn't mean user experience good; must use ReportDrawnWhen / reportFullyDrawn() to also watch TTFD,
and watch out that too many background P1 threads can fight with first-screen data loading for CPU.
✅ 5. Baseline Profile + Startup Profile as the last cut
It uses AOT precompilation and DEX layout rearrangement to make main-thread code execute faster, but doesn't cure main-thread blocking;
For domestic environment, definitely integrate androidx.profileinstaller, and use Macrobenchmark to compare None vs Partial(Require).
Methodology elevation
The first two articles optimized "how efficiently the screen renders"; this one optimizes "what the main thread was busy with before the screen appears."
Reviewing the four-cut practice, the essence of cold-start optimization is one sentence: Before the first frame appears, make the main thread do less, or make what it must do faster.
- Make main thread do less: SDK initialization tiering (Step 1),
ContentProvidergovernance (Step 2), offloading first-screenActivityheavy work (Step 3). - Make what main thread does faster: Baseline Profile guided AOT compilation and Startup Profile DEX layout optimization (Step 4).
The prerequisite for all this is first using Perfetto to make the originally black-box time visible, then using multi-run real-device statistics to speak, not guessing bottlenecks by feel.
Next article preview
Startup speed and rendering fluency are solved. Next is another invisible killer that makes users "feel increasingly laggy and eventually get killed by the system in the background or even foreground": memory.
Next article covers Android memory leaks and memory jank troubleshooting and governance: how LeakCanary automatically detects leaks, the most common leak patterns at work, and how to read the shortest strong reference chain (GC Roots Shortest Path) from a seemingly inscrutable Heap Dump (.hprof).
Appendix: demo project and measurement environment
- Demo project repo: https://github.com/shuhui-sh/StartupDemo (contains
:app,:fakesdks,:baselineprofilemodules, and five cumulative Git tagsstep0-baseline,step1-init-tiering,step2-provider-governance,step3-activity-offload,step4-baseline-profile). - Raw data and reproducible artifacts: all stored under StartupDemo/results/:
results/device.md: hardware, firmware, and security patch details of both test devices.results/merged_manifest_providers.txt: complete<provider>list and XML diff for Step 0 and Step 2 merged manifests.results/summary.csv,summary_mi6.csv,summary_mi13pro.csv: summary tables of 10-cold-start medians for both devices at each stage.results/step0/~results/step4/: per-stagebenchmark.json(with 10 raw segment measurements),am_start_w.txt, and recorded.perfetto-tracefiles.results/FINDINGS.md: item-by-item verification report for 5 core experimental hypotheses (H1–H5).
- Test devices and software versions:
- Device A (primary baseline): Xiaomi MI 6 (Snapdragon 835
msm8998, 6GB RAM, Android 9 / API 28, MIUIV11.0.5.0.PCACNXM). - Device B (flagship control): Xiaomi 13 Pro
2210132C(Snapdragon 8 Gen 2SM8550, 8GB RAM, Android 16 / API 36, HyperOSOS3.0.312.0.WMBCNXM). - Toolchain versions: JDK
17.0.0.1, Gradle8.7, AGP8.5.2, Kotlin2.0.21, Compose BOM2024.10.01, Room2.6.1, WorkManager2.9.1, OkHttp4.12.0, Coil2.7.0, App Startup1.1.1, ProfileInstaller1.4.1, AndroidX Benchmark1.3.3.
- Device A (primary baseline): Xiaomi MI 6 (Snapdragon 835
- Official reference docs:
This article is part of the "Android Performance Optimization" column
Column: https://juejin.cn/column/7511952023380017167
Series articles
- Android Rendering Optimization: from understanding Overdraw to using ConstraintLayout for one-layout-adapts-all-screens
- What you learned about render optimization in the XML era is largely no longer the focus in Compose — Recomposition is the new battlefield
- From tapping the icon to content appearing: what your app does in those seconds — Android cold-start full-chain optimization in practice (this article)
Next up: memory leaks — after a page is closed, why is the object still alive, and how does LeakCanary find it?