Why Startup Time Matters
Cold start time is a first impression. Google Play's performance dashboard surfaces startup metrics; users abandon apps that take more than 2-3 seconds to show content. The primary enemies of fast startup are:
- Doing too much in
Application.onCreate()— content providers, analytics, logging, and crash reporting all initializing synchronously - Blocking the main thread during initialization
- Initializing libraries eagerly when they're only needed later
The Problem: ContentProvider Chains
Many libraries abuse ContentProvider to auto-initialize without any setup code. A library registers a ContentProvider that runs in onCreate() before your Application.onCreate(). With 10 libraries doing this, you get 10 blocking init calls before your first line of code.
<!-- What WorkManager, Firebase, etc. used to do internally -->
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:exported="false"
android:multiprocess="true" />
App Startup Library
The Jetpack App Startup library provides a single ContentProvider (InitializationProvider) that runs all initializers in one pass. Critically, it supports dependency ordering and allows deferring initialization to explicit call time.
// build.gradle.kts
implementation("androidx.startup:startup-runtime:1.1.1")
Implementing an Initializer
// TimberInitializer.kt
class TimberInitializer : Initializer<Unit> {
override fun create(context: Context): Unit {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(CrashReportingTree())
}
return Unit
}
// No dependencies
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
// AnalyticsInitializer depends on Timber being ready first
class AnalyticsInitializer : Initializer<AnalyticsTracker> {
override fun create(context: Context): AnalyticsTracker {
return AnalyticsTracker.init(context) // returns singleton
}
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(TimberInitializer::class.java) // runs after Timber
}
// WorkManagerInitializer (disable WorkManager's own ContentProvider)
class WorkManagerInitializer : Initializer<WorkManager> {
override fun create(context: Context): WorkManager {
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.INFO)
.build()
WorkManager.initialize(context, config)
return WorkManager.getInstance(context)
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
Auto-init Registration in Manifest
<!-- AndroidManifest.xml -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- Auto-init: runs during ContentProvider creation -->
<meta-data
android:name="com.example.TimberInitializer"
android:value="androidx.startup" />
<meta-data
android:name="com.example.AnalyticsInitializer"
android:value="androidx.startup" />
<!-- Disable WorkManager's own ContentProvider -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
Manual Init (Lazy, Deferred)
For non-critical initializers, remove from manifest and call explicitly when needed:
<!-- Mark as manual-only: remove from auto-init -->
<meta-data
android:name="com.example.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
// Initialize later (e.g., after first screen is displayed)
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Show first screen immediately...
// Defer non-critical initialization
lifecycleScope.launch {
delay(500) // or after onWindowFocusChanged
AppInitializer.getInstance(this@MainActivity)
.initializeComponent(WorkManagerInitializer::class.java)
}
}
}
Kotlin lazy Delegate Patterns
Kotlin's lazy {} is the primary tool for deferring object construction to first use.
// Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED)
private val database: AppDatabase by lazy {
Room.databaseBuilder(context, AppDatabase::class.java, "app_db").build()
}
// Main-thread-only singleton (no synchronization overhead)
private val adapter: ArticleAdapter by lazy(LazyThreadSafetyMode.NONE) {
ArticleAdapter()
}
// Custom initialization mode
class HeavyFeatureManager private constructor() {
companion object {
// Double-checked locking equivalent
val instance: HeavyFeatureManager by lazy { HeavyFeatureManager() }
}
private val expensiveResource by lazy {
// Only initialized when first accessed — not at construction time
DatabaseConnectionPool(size = 10)
}
}
Application-Level Lazy Singletons
class MyApplication : Application() {
// Initialized only when first accessed — not blocking onCreate()
val imageLoader: ImageLoader by lazy {
ImageLoader.Builder(this)
.memoryCache { MemoryCache.Builder(this).maxSizePercent(0.25).build() }
.diskCache { DiskCache.Builder().maxSizeBytes(50 * 1024 * 1024).build() }
.build()
}
val analyticsTracker: AnalyticsTracker by lazy {
AnalyticsTracker(apiKey = BuildConfig.ANALYTICS_KEY)
}
override fun onCreate() {
super.onCreate()
// onCreate() is lean — only critical init here
// TimberInitializer handles Timber via App Startup
}
}
Baseline Profiles
Baseline Profiles tell the ART compiler which code paths are hot during startup, enabling ahead-of-time compilation instead of interpretation. This reduces cold start time by 20-40%.
// build.gradle.kts
plugins {
id("androidx.baselineprofile")
}
dependencies {
baselineProfile(project(":baselineprofile"))
}
// baselineprofile/src/main/kotlin/com/example/BaselineProfileGenerator.kt
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() {
rule.collect(packageName = "com.example.app") {
// Simulate the startup journey
pressHome()
startActivityAndWait()
// Navigate the critical paths you want compiled AOT
device.findObject(By.text("Explore")).click()
device.waitForIdle()
device.findObject(By.text("Articles")).click()
device.waitForIdle()
}
}
}
The generated baseline-prof.txt is packaged into the APK and used by Play's cloud compilation service.
Startup Tracing
Use androidx.tracing to add custom trace sections visible in Perfetto/Android Studio Profiler:
implementation("androidx.tracing:tracing-ktx:1.1.0")
class UserRepository {
suspend fun loadUsers(): List<User> = withContext(Dispatchers.IO) {
Trace.beginSection("UserRepository.loadUsers")
try {
api.getUsers()
} finally {
Trace.endSection()
}
}
}
// Kotlin extension
suspend fun <T> tracedSection(name: String, block: suspend () -> T): T =
trace(name) { block() }
Startup Checklist
| Check | Action |
|---|---|
| App Startup library added | Replace ContentProvider soup with InitializationProvider |
Application.onCreate() lean | Move all library init to Initializer<T> classes |
| Non-critical inits deferred | Use manual init or lazy {} |
| Baseline Profile generated | Add baselineprofile module; run generator against release |
| Strict mode enabled in debug | Catches disk/network reads on main thread |
| Tracing added to hot paths | Measure in Android Studio Profiler |
// Enable StrictMode in debug to catch main-thread violations early
class MyApplication : Application() {
override fun onCreate() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
super.onCreate()
}
}
Key Takeaways
| Concept | Summary |
|---|---|
| ContentProvider init | Libraries abuse it for auto-init; App Startup consolidates to one |
Initializer<T> | Declare dependencies; run in topological order; supports manual mode |
| Auto vs manual init | Auto: critical path; manual: deferred to when feature is first needed |
lazy {} | Kotlin delegate; thread-safe by default; use NONE on main thread only |
| Baseline Profiles | ART AOT compilation of hot paths; 20-40% cold start improvement |
| StrictMode | Debug-only; catches disk/network IO on main thread before shipping |