Making Shadowfax Android App 40% faster

Ishaan Garg
Shadowfax
Published in
7 min readJan 15, 2024

--

We were able to achieve 750ms of median start time & 1.9s for 90th percentile

90th percentile start time

1. Setting Goals

Every millisecond counts when it comes to mobile app performance. The faster your app loads, the more likely users are to stick around.

The Shadowfax Rider App, with more than 100,000 DAUs, faced a challenge as the app had bloated to take about 3.5 seconds to start!

The goal was to trim this time down to:

  • < 2 seconds for 90th percentile
  • < 800ms for median users

2. Measuring App Start Time

According to Firebase, app start time is the duration from when the app is launched from the launcher until the first activity’s `onResume()` method is called. This duration is also reported in the logcat like so:

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms

You can read more here. For us Firebase Startup time was the source of truth.

If you consider your app to be fully loaded at some point after the onResume is called (like after your map has been fully drawn), then you can report that point in time to the system & Firebase with Activity.reportFullyDrawn()

If you’re using Perfetto, then it also visualizes startup time, more on that later.

3. Drilling down

We can’t improve what we can’t measure, so measure everything.

To break down parts of the app by their start time durations, we added @trace annotation from firebase perf library to app class onCreate() function, and onCreate() & onStart() of BaseActivity & MainActivity. Basically measuring everything on a top-level so we get to know the main culprits & drill-down from there.

Apart from Main & Base activities, the app class was taking 30% of the app startup time, so we did 2 things:

3.1 Lazy load libraries & content providers (-10% startup time)

Application class usually initialises a lot of libraries. We moved whichever libs we didn’t need immediately on app start to init in background. If you have any content providers you can also use the Startup Library to lazy load those.

Tip: search your merged manifest for “<provider” to see which libs initialise a content provider. Multiple providers may then be moved to a single provider via the Startup Library.

Here’s how we moved some SDKs to initialise in a background thread:

class MyApp : Application() {
@AddTrace(name = "backgroundInitializationsTrace")
private fun performBackgroundInitializations() {
runWithLooper {
// init SDKs here that do NOT require init on main thread
}
}
}

object MyUtils {
// util fun for init on background thread
fun runWithLooper(runnable: Runnable) {
val threadHandler = HandlerThread("Thread${System.currentTimeMillis()}")
try {
threadHandler.start()
val handler = Handler(threadHandler.looper)
handler.post {
runnable.run()
}
} catch (e: OutOfMemoryError) {
firebaseCrashlytics.recordException(e)
runWithinMainLooper(runnable)
}
}
// fallback
fun runWithinMainLooper(mainThread: Runnable) {
val handler = Handler(Looper.getMainLooper())
handler.post {
mainThread.run()
}
}
}

3.2 Baseline Profiles (-7% startup time)

Google recommends setting up baseline profile to improve first app startup time. We noticed a 7% overall app start time improvement, your mileage may vary but definitely try it out.

So this was a good start but we needed to dig deeper.

4. Using Perfetto

We can measure time taken by every function by running a system trace from Android Studio, then launching the app & loading the trace into the Perfetto visualizer.

We can use Android Studio’s inbuilt Profiler, but Perfetto has better navigation & details.

Details on how to do system tracing is here, but for profiling app launch time, don’t run the app normally. Here’s what you need to do:

  1. First choose the release build variant for your app, instead of debug build, for accurate results
  2. Click the 3-dot menu near run button in Android Studio
  3. then choose “Profile App with low overhead” to launch the app
  4. Now once the app has completely loaded, stop the recording
  5. Lastly export the trace using the save icon on Profiler, then import it into Perfetto Web UI

Once you load the trace into Perfetto, don’t get scared after you encounter a gazillion colours throwing up on your screen. We don’t need to deal with all of that.

4.1 Find startup time metric in Perfetto

Ctrl/cmd+F for “startup” to see the visualisation of your app startup time. Click on that green bar then press ‘M’ on your keyboard. This will mark the start & end of that bar, this is what we’re concerned about for now, everything else is just noise.

4.2 Find the root cause(s)

Now search for your package name below the startup time bar & click to expand it. Remember to focus on just the marked area of the graph and see the main thread, it will show you the duration of each function on x-axis, nested bars on y-axis mean nested functions.

Inspect the horizontally longer bars first, as those took the most time. See which of these functions took more time than expected & list them down. Then once we have the top 4–5 culprits we can optimize them.

Remember that frames should get rendered within 16ms to achieve 60fps

Tip: Use WASD keys to move around, read more about perfetto UI here. The docs are a tad bit outdated but the core principles are same.

5. Going down the rabbit hole

There may be functions with long durations but nothing to show for it.

Look at this looong BaseActivity onResume() bar, the graph doesn’t tell us what’s taking so long.

Only at the end do we see some nested functions that account for like a fourth of its overall time.

So what do we do?

We need to add more tracing. It’s easy with the tracing library available for both Java & kotlin. Details are here but let’s look at a snippet:

class MyClass {
fun foo(pika: String) {
trace("MyClass.foo") {
// existing fun logic here…
}
}
}

Now MyClass.foo will start showing up in Perfetto. So add tracing for all nested functions in the function you’re debugging, then re-record a trace & analyse it in Perfetto. Rinse & repeat for each lifecycle function.

6. Our Solutions

This whole exercise gave us very clear root causes & targets. Now it was time to fix each of these causes one by one. Based on the observations from Perfetto and SysTrace, multiple optimisations were executed:

6.1 Optimising HomeFragment Layout (-15% startup time)

Reduced up to 600ms by ensuring no nesting of views with ConstraintLayout, and using viewstubs instead of hidden views. This approach prevents unnecessary measurement & inflation of views that are hidden by default & displayed only after user-interaction. Read more on view stubs.

The mapView which took 200–300 ms was moved to init only after onResume() was invoked so it loads after the core UI showing order count & status has been laid out.

private fun HomeFragment.lazyLoadMap() {
Handler(Looper.getMainLooper()).post {
//this runnable is executed only after mainThread has inflated HomeFrag
initMap()
}
}

6.2 Optimising MainActivity (-5% startup time)

Repeated trace tests showed that LinearLayout was performing better than ConstraintLayout for our MainActivity as it was just a container for Fragments with not more than 3–4 views. In fact switching to LinearLayout made the MainActivity start 2x faster, especially on warm starts.

6.3 Lazy loading MainActivity SDKs (-10% time)

Perfetto showed that a single 3rd party SDK being initialised in MainActivity was contributing to 70% of the onCreate() duration. We started lazy-loading it in the background as it was not required immediately on app start.

7. Actual performance in the wild

After releasing these solutions over several releases, we saw the 90th percentile of start-up time gradually go from 3.5 seconds to just under 2 seconds, a remarkable 42% reduction.

Speed for 90th percentile & median users

We continue to look for more bottlenecks & work on speeding up the app to help make our partners more productive.

Finding bottlenecks with Perfetto was crucial to this project & gave us the confidence to fix them, as we knew how much of a perf gain we’d achieve from fixing each issue.

h/t to Burhan & Vishnu for helping improve the app start time

--

--

Android SDE III at Shadowfax. ex-founder @ Lubble. Loves all things code & coffee