I’m personally bothered by poor performance, wanting to leverage given resources to the best of my knowledge and abilities. This applies especially to the startup time of my (our) Android applications, where, gradually, you wouldn’t notice the change in performance that much.
That all changed when I got a work assignment on one of our legacy apps.
To my surprise, the legacy app launched comparatively fast. Really, really fast. Back in 2016, we didn’t even bother with obfuscations via ProGuard, so knowing this, I was really stunned. So I started to do some digging into what might be causing such a noticeable drop in performance.
Let’s talk first about how I measured the app startup performance.
- OEM locked Pixel 5
- Verbose adb reports
- Debug app variant
adb shell am start-activity -W -n $PACKAGE_NAME/$MAIN_ACTIVITY | grep "TotalTime" | cut -d ' ' -f 2
echo ">> Test start <<"
for i in $(seq 1 25); do
LOOP_COUNT=$((LOOP_COUNT + 1))
adb shell am force-stop $PACKAGE_NAME
CUMULATIVE_TIME=$((CUMULATIVE_TIME + THIS_LAUNCH_TIME))
echo -n "."
printf "\n>> Test end <<\n"
echo "Average startup time: $((CUMULATIVE_TIME / LOOP_COUNT))ms"
There’s one important thing to mention. I did not lock my clock as Chet describes in his article. The reason being I don’t have my device (Pixel 5) unlocked and I really like to play with it. With that in mind, please take the results with a grain of salt as they will be a little bit skewed.
I mitigated the deltas as much as I could, not touching the device for at least 30 seconds before beginning the test to avoid “touch boost” triggering on the chip’s governor.
Since the old application I was talking about was not obfuscated I wanted to at least match the performance of this application and that’ll be best done in debug configuration. Don’t worry though I’ll be obfuscating the newer app and reporting the results.
Step 0: Initial Performance
I naturally started from the current state. So the usual routine, build debug, launch and measure. For the sake of consistency, I let the app launch about 25 times and had the script make an average.
I’m going to round to the tens because the unlocked governor can cause these 0–10ms delays anyway.
The number was 1170ms.
Step 1: Koin
My first thought was: “Koin… That must be it.”. We use it everywhere nowadays and the module declarations could potentially take some time on app startup time. I know the drawbacks of using a service locator instead of a “true” dependency injection, so I went ahead and rewrote the DI with Dagger in one of my own apps. Took me about half a day to have the app in the exact same state as it was before but with Dagger providing the dependencies.
I’d like to report that I wasted half of my day to confirm that Koin is insanely fast… and that I potentially may not have implemented the Dagger ideally.
App startup time: 1180ms. Naturally, I reverted the changes.
Step 2: Profiling
Saving no app startup time whatsoever, I turned to profiling. …only to discover that the Android Studio Profiler needs to startup before it even shows me the UI so I can start recording traces. That said I gave up on this idea since it wouldn’t really help me that much, knowing there are other options.
I found out the next day that I can have the device record all the traces for me and use Perfetto to show very detailed visualization of what’s really happening.
I employed logging through AppComponentFactory to deduce whether constructing objects is fast.
App startup time unchanged.
Step 3: Clearing up the Application class
Another very common way to halt the startup with initializing SharedPreferences, Coroutines, or generally any Thread Pools or long-running tasks in the Application class. I went ahead and removed or postponed (via Handler.postDelayed) everything that wasn’t necessary in order for the app to launch successfully.
Once I was done with these changes, although confident it would not change much because I’m really prudent about having the Application class clean, I ran the test again, and…
App startup time unchanged.
Step 4: Content Providers are evil?
I set out on a crusade to manually disable all ContentProviders. I found out there are some 5 Providers residing inside my app. 2 of which are Lifecycle based, 1 being Firebase owned, 1 WorkManager owned and the remaining one being my FileProvider.
I went ahead and rebuilt the app to test the results. Inexplicably the app crashed immediately after launch due to issues with Lifecycles. Who knew? With that failure, I moved to Step 5, since that was imminent anyway.
App startup time unchanged.
Step 5: androidx.startup
Implementing this library before, the process was quick and breezy, except for the Lifecycle Providers. As these two aforementioned Providers are not public to instantiate. Worry not, reflection to the rescue. Speaking of which, the Initializers are not created through AppComponentFactory, so I needed to add more measuring inline clauses. Build, launch, test!
App startup time unchanged. …but why?
Well, that’s because the Startup library aggregates the Initializers and runs the create methods right after each other. It lifts a few milliseconds here and there when initializing dozens of ContentProviders, but in this case, it didn’t really do much.
That said, you can always provide “Unit” and delay the initialization to a later time. It, however, had a negligible impact on everything but Firebase. It takes well over 60ms (on my device) to start up and find every component through its Services.
App startup time: 1110ms! Progress!
Step 6: Inspecting AppComponentFactory logs
I noticed that the Main Activity takes some 20ms to start up, which is weird as it only executes code in its onCreate method. The culprits were dependencies filled with “Lazy” implementations in order to execute some logic in onCreate. We can use some Koin magic and use get() instead of inject() right at the call site, which will help a little bit with the memory as the reference will be cleared eventually.
Everything else seemed to be in order, taking 1–5ms to create the instances, nothing weird or unusual.
App startup time: 1100ms.
Step 7: useEmbeddedDex=true
This comes as a surprise to me. I wanted to try out all options and this struck me as worth a try. I expected that by bypassing Android’s AOT, the startup time and overall performance would be worse, if not the same. Quite the opposite! It shaved off 140ms.
It doesn’t come as a recommendation though. It seems to be disabled by default for a reason and I’d argue that AOT performance improvements are more noticeable in heavy content-oriented apps, but I might as well be wrong. I’ll roll with it, for now, keeping in mind that I’d need to disable that later if any errors do occur.
App startup time: 960ms.
Step 8: Inspecting onCreate(s)
I don’t know whether I’m surprised at this point really, but implementing androidx.navigation exactly as the documentation says is deeply flawed. The fragment instantiation at layout inflation time costs about 300ms.
The fix is obviously easy enough… You can optionally wait until the entire layout draws, then create the NavHostFragment manually, assign it as the primary navigation fragment and commit it. The only thing remaining is to wait until it’s attached and start up your code. (or don’t at all if you don’t depend on NavControllers in your activity)
Surprise, surprise the app launches in 660ms. Wonderful.
Step 9: Obfuscation
At this point, I figured out the Perfetto method described at the beginning, but I did not really find any more long-running tasks other than classloader. This is understandable though as it loads even classes that are completely unused.
After applying the obfuscation the app crashed upon startup again. It’s a kind of obvious error I made before, since I’m loading the Lifecycle features through reflection, the system cannot find the newly obfuscated names. There are nonetheless two ways out of this trap. The first one is enabling the ContentProviders and removing the reflection, second add a proguard rule to keep names.
Because I’m a sadist who likes to have the app broken by updates to random libraries, I’ve chosen the latter. I strongly recommend to use the former though. Do as I say, not as I do (:
Finally, the app has built successfully, launched just fine and it’s time for our final measurements.