DEV Community

Weavri
Weavri

Posted on

Cold start in a 4.5 MB FOSS Android app: measure it with adb, then fix what's actually slow

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
Enter fullscreen mode Exit fullscreen mode

-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
Enter fullscreen mode Exit fullscreen mode

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 startActivity returned.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
    ...
}
Enter fullscreen mode Exit fullscreen mode

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"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)