Every "make your app faster" post opens with advice. This one opens with a number you can reproduce on your own device, because you can't fix a cold start you haven't measured. Here's the exact workflow I use on a 4.5 MB FOSS Android app, plus the two real things that were slowing its first frame.
1. Measure cold start with three commands (no Android Studio)
A cold start means the process isn't running. If you just tap the launcher icon while the app is backgrounded, you're measuring a warm or hot start and your number will be artificially good. Force a cold start:
adb shell am force-stop dev.weavrik.wifiwarn
adb shell am start -W -n dev.weavrik.wifiwarn/.MainActivity
-W waits for the launch and prints the timing. You'll get something like:
Status: ok
LaunchState: COLD
Activity: dev.weavrik.wifiwarn/.MainActivity
ThisTime: 412
TotalTime: 412
WaitTime: 455
Field meanings (from AOSP's ActivityMetricsLogger):
- ThisTime — milliseconds to launch the last activity: start of launch → first frame.
- TotalTime — ThisTime plus any activities launched before it in the same call.
-
WaitTime — TotalTime plus the system-server time before
startActivityreturned.
The same number lands in logcat, which is what I use when I want a distribution across many runs instead of one:
adb logcat -s ActivityTaskManager:I | grep Displayed
# I/ActivityTaskManager: Displayed dev.weavrik.wifiwarn/.MainActivity: +412ms
Run it ~10 times, discard the first (cold disk cache), and watch the median — never the best run.
2. Look at the artifact before the profiler
The app is deliberately tiny: one Activity, one AccessibilityService, three dependencies. The release APK is 4.5 MB. But unzip the shipped build and you'll see where the weight really sits:
unzip -l wifiwarn-v1.0.apk | grep -E "classes|\.dex|resources\.arsc"
8418928 classes.dex
982700 resources.arsc
A single 8.4 MB classes.dex for a three-screen app. That isn't because the code is big — it's because the release build sets isMinifyEnabled = false, so R8 never runs and the APK ships every class of Material Components + AppCompat + core-ktx, whether used or not. A bigger dex means more classes to load and verify on the startup path before your first frame.
3. Read your own onCreate / onResume
Here's the real MainActivity.onCreate:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Config.migrateLegacy(this) // SharedPreferences read (+ maybe JSON rewrite)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupSwitches()
setupBottomNavigation()
renderSettings()
}
Config.migrateLegacy() reads SharedPreferences and, on the legacy path, parses and rewrites a JSON blob — on the main thread, before the first frame. It runs on every launch even though it's a one-time migration.
onResume is worse:
override fun onResume() {
super.onResume()
updateAccessibilityStatus() // AccessibilityManager IPC
handleOverlayPermissionChange() // Settings.canDrawOverlays() IPC #1
refreshSwitches() // Settings.canDrawOverlays() IPC #2
...
}
getEnabledAccessibilityServiceList() and Settings.canDrawOverlays() are binder calls into system_server, and canDrawOverlays() is invoked twice per resume. onResume completes before the first frame is drawn, so synchronous IPC here sits directly on the cold-start critical path.
4. Fixes, in impact order
(a) Turn on R8. The biggest single lever for a FOSS app that vendors Material:
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
Re-run the unzip -l check afterwards and the 8.4 MB dex should drop hard as unused classes are stripped.
(b) Add a Baseline Profile. Without one, ART starts cold code in the interpreter and JITs it while your first screen draws. A Baseline Profile ships the hot path as AOT-compiled code at install time; Google's own guidance and most startup postmortems report double-digit percentage gains. For a small, stable UI like this it's the cheapest win available — add androidx.profileinstaller and generate the profile from a Macrobenchmark module.
(c) Get IPC off the critical path. Cache canDrawOverlays() once in onCreate, delete the duplicate call inside refreshSwitches(), and gate migrateLegacy() behind a "already migrated" flag so it short-circuits on every launch after the first.
(d) Don't add an Application subclass casually. wifiwarn has none — good. Every line of Application.onCreate runs before your Activity and is pure cold-start tax.
5. Verifying
Re-run step 1 (am force-stop + am start -W) before and after each change. Track the median of 10 cold runs, not the minimum. If a change doesn't move that median, revert it.
Artifact first, then code, then fix — that order is the whole trick. Everything else is guessing.
If you want to read these fixes against a real target, the app is open source (MIT): https://github.com/weavrikx/wifiwarn
Top comments (0)