Header image: The In-Between by Romain Guy.
My previous articles focused on measuring Android app start in production. Once we've established a metric and scenarios that trigger a slow app start, the next step is to improve performance.
To understand what makes the app start slow, we need to profile it. Android Studio provides several types of profiler recording configurations:
- Trace System Calls (aka systrace, perfetto): Low impact on runtime, great for understanding how the app interacts with the system and CPUs, but not the Java method calls that happen inside the app VM.
- Sample C/C++ Functions (aka Simpleperf): Not interesting to me, the apps I deal with run much more bytecode than native code. On Q+ this is supposed to now also sample Java stacks in a low overhead way, but I haven't managed to get that working.
- Trace Java Methods: This captures all VM method calls which introduces so much overhead that the results don't mean much.
- Sample Java Methods: Less overhead than tracing but shows the Java method calls that happen inside the VM. This is my preferred option when profiling app startup.
Start recording on app startup
The Android Studio profiler has UI to start a trace by connecting to an already running process, but no obvious way to start recording on app startup.
The option exist but is hidden away in the run configuration for your app: check Start this recording on startup in the profiling tab.
Then deploy the app via Run > Profile app.
Profiling release builds
Android developers typically use a debug build type for their everyday work, and debug builds often include a debug drawer, extra libraries such as LeakCanary, etc. Developers should profile release builds rather than debug builds to make sure they're fixing the actual issues that their customers are facing.
Unfortunately, release builds are non debuggable so the Android profiler can't record traces on release builds.
Here are a few options to work around that issue.
1. Create a debuggable release build
We could temporarily make our release build debuggable, or create a new release build type just for profiling.
android {
buildTypes {
release {
debuggable true
// ...
}
}
}
Libraries and Android Framework code often have a different behavior if the APK is debuggable. ART disables a lot of optimizations to enable connecting a debugger, which affects performance significantly and unpredictably (see this talk. So this solution is not ideal.
2. Profile on a rooted device
Rooted devices allow the Android Studio profiler to record traces on non debuggable builds.
Profiling on an emulator is generally not recommended - the performance of every system component will be different (cpu speed, cache sizes, disk perf), so an 'optimization' can actually make things slower by shifting the work to something that's slower on a phone. If you don't have a rooted physical device available, you can create an emulator without Play Services and then run adb root
.
3. Use simpleperf on Android Q
There's a tool called simpleperf which supposedly enables profiling release builds on non rooted Q+ devices, if they have a special manifest flag. The doc calls it profileableFromShell
, the XML example has a profileable
tag with an android:shell
attribute, and the official manifest documentation shows nothing.
<manifest ...>
<application ...>
<profileable android:shell="true" />
</application>
</manifest>
I looked at the manifest parsing code on cs.android.com:
if (tagName.equals("profileable")) {
sa = res.obtainAttributes(
parser,
R.styleable.AndroidManifestProfileable
);
if (sa.getBoolean(
R.styleable.AndroidManifestProfileable_shell,
false
)) {
ai.privateFlags |= ApplicationInfo.PRIVATE_FLAG_PROFILEABLE_BY_SHELL;
}
}
It looks like you can trigger profiling from command line if the manifest has <profileable android:shell="true" />
(I haven't tried). As far as I understand the Android Studio team is still working on integrating with this new capability.
Profiling a downloaded APK
At Square our releases are built in CI. As we saw earlier, profiling app startup from Android Studio requires checking an option in a run configuration. How can we do this with a downloaded APK?
Turns out, it's possible but hidden under File > Profile or Debug APK. This opens up a new window with the unzipped APK, and from that you can set up the run configuration and start profiling.
Android Studio profiler slows everything down
Unfortunately, when I tested these methods on a production app, profiling from Android Studio slowed down app startup a lot (~10x slower), even on recent Android versions. I'm not sure why, maybe it's the "advanced profiling", which doesn't seem like it can be disabled. We need to find another way!
Profiling from code
Instead of profiling from Android Studio, we can start the trace directly from code:
val tracesDirPath = TODO("path for trace directory")
val fileNameFormat = SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss_SSS'.trace'",
Locale.US
)
val fileName = fileNameFormat.format(Date())
val traceFilePath = tracesDirPath + fileName
// Save up to 50Mb data.
val maxBufferSize = 50 * 1000 * 1000
// Sample every 1000 microsecond (1ms)
val samplingIntervalUs = 1000
Debug.startMethodTracingSampling(
traceFilePath,
maxBufferSize,
samplingIntervalUs
)
// ...
Debug.stopMethodTracing()
We can then pull the trace file from the device and load it in Android Studio.
When to start recording
We should start recording the trace as early as possible in the app lifecycle. As I explained in Android Vitals - Diving into cold start waters 🥶, the earliest code that can run on app startup before Android P is a ContentProvider
and on Android P+ it's the AppComponentFactory
.
Before Android P / API < 28
class AppStartListener : ContentProvider() {
override fun onCreate(): Boolean {
Debug.startMethodTracingSampling(...)
return false
}
// ...
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name=".AppStartListener"
android:authorities="com.example.appstartlistener"
android:exported="false" />
</application>
</manifest>
Unfortunately we cannot control the order in which ContentProvider
instances are created, so we may be missing some of the early startup code.
Edit: @anjalsaneen pointed out in the comments that when defining a provider we can set a initOrder tag, and the highest number gets initialized first.
Android P+ / API 28+
@RequiresApi(28)
class MyAppComponentFactory() :
androidx.core.app.AppComponentFactory() {
@RequiresApi(29)
override fun instantiateClassLoader(
cl: ClassLoader,
aInfo: ApplicationInfo
): ClassLoader {
if (Build.VERSION.SDK_INT >= 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateClassLoader(cl, aInfo)
}
override fun instantiateApplicationCompat(
cl: ClassLoader,
className: String
): Application {
if (Build.VERSION.SDK_INT < 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateApplicationCompat(cl, className)
}
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:appComponentFactory=".MyAppComponentFactory"
tools:replace="android:appComponentFactory"
tools:targetApi="p">
</application>
</manifest>
Where to store traces
val tracesDirPath = TODO("path for trace directory")
- API < 28: The broadcast receiver has access to a context on which we can call Context.getDataDir() to store the trace in the app directory.
-
API 28: AppComponentFactory.instantiateApplication() is in charge of creating a new application instance so there's no context available yet. We can hard code the path to
/sdcard/
directly, though that requires theWRITE_EXTERNAL_STORAGE
permission. -
API 29+: When targeting API 29, hardcoding
/sdcard/
stops working. We can add the requestLegacyExternalStorage flag but it's not supported on API 30 anyway. Edit: Yacine Rezgui suggested trying out MANAGE_EXTERNAL_STORAGE on API 30+. Either way, AppComponentFactory.instantiateClassLoader() passes anApplicationInfo
so we can use ApplicationInfo.dataDir to store the trace in the app directory.
When to stop recording
In Android Vitals - First draw time 👩🎨, we learnt that cold start ends when the app's first frame completely loads. We can leverage the code from that article to know when to stop method tracing:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
var firstDraw = false
val handler = Handler()
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
handler.postAtFrontOfQueue {
Debug.stopMethodTracing()
}
}
}
}
})
}
}
We could also record for a fixed amount of time greater than the app start, e.g. 5 seconds:
Handler(Looper.getMainLooper()).postDelayed({
Debug.stopMethodTracing()
}, 5000)
Profiling with Nanoscope
Another option for profiling app startup is uber/nanoscope. It's an Android image with built-in low overhead tracing. It's great but has a few limitations:
- It only traces the main thread.
- A large app will overfill the in-memory trace buffer.
App Startup steps
Once we have a startup trace, we can start investigating what's taking time. You should expect 3 main sections:
-
ActivityThread.handlingBindApplication()
contains the startup work before activity creation. If that's slow then we probably need to optimizeApplication.onCreate()
. -
TransactionExecutor.execute()
is in charge of creating and resuming the activity, which includes inflating the view hierarchy. -
ViewRootImpl.performTraversals()
is where the framework performs the first measure, layout and draw. If this is slow then it could be the view hierarchy being too complex, or views with custom drawing that need to be optimized.
If you notice that a service is being started before the first view traversal, it might be worth delaying the start of that service so that it happens after the view traversal.
Conclusion
A few take aways:
- Profile release builds to focus on the actual issues.
- The state of profiling app start on Android is far from ideal. There's basically no good out of the box solution, but the Jetpack Benchmark team is working on this.
- Start the recording from code to prevent Android Studio from slowing everything down.
Many thanks to the many folks who helped me out on Slack and Twitter: Kurt Nelson, Justin Wong, Leland Takamine, Yacine Rezgui, Raditya Gumay, Chris Craik, Mike Nakhimovich, Artem Chubaryan, Rahul Ravikumar, Yacine Rezgui, Eugen Pechanec, Louis CAD, Max Kovalev.
Top comments (8)
Thanks for good article about profiling! I would like to suggest trying out this profiling tool : github.com/Grigory-Rylov/android-m...
Advantages:
Wow, really cool!
Unfortunately we cannot control the order in which ContentProvider instances are created, so we may be missing some of the early startup code.
developer.android.com/guide/topics...
Android has an initOrder tag. The value is a simple integer, with higher numbers being initialized first.
Oh that's interesting, Looks like I missed this. Does it work?
Yes. it works.
Thanks for this great article series. we are instrumenting the time taken for each step in the production.
dev-to-uploads.s3.amazonaws.com/up...
Nice!
Blog updated:
Thanks for the great article, I wonder how's your current workflow to detect the regressions on the startup time? Do integrate this with you CI setup or do you profile the builds manually after having measurements on the wild?
where is the source code project?