
Portable app-startup system enabling single-source initializers, manifest composition, dependency-ordered startup via Kahn's algorithm, lazy or eager registration, runtime initializer keys, and clear cycle diagnostics.
A Kotlin Multiplatform port of AndroidX App Startup.
On Android it is androidx.startup. Initializer, AppInitializer, Context and the class
token are typealiases of the AndroidX types, so an initializer written once in commonMain
compiles to implements androidx.startup.Initializer, is discovered by the same
InitializationProvider, and is instantiated by the same reflection. There is no wrapper type and
no adapter anywhere in the Android path.
On the other ten targets the library ships its own runtime. It computes the initialization order with Kahn's algorithm, executes it sequentially on the calling thread, and reports cycles as a trimmed path rather than as a bare "cycle detected".
android, desktop (JVM), iosArm64, iosSimulatorArm64, iosX64, macosArm64, macosX64,
linuxX64, mingwX64, js (browser and Node), wasmJs (browser and Node).
macosX64 and iosX64 are compiled and linked but never run: they are disabled on an arm64 Mac and
CI has no x86_64 macOS runner, so a green build is not evidence that their test binaries executed.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kunal26das:startup:3.0.0")
}
}
}Use api rather than implementation if an iOS host has to see these types, because a framework
can only export a dependency its source set declares with api; see From Swift and
Objective-C below.
The Android artifact depends on androidx.startup:startup-runtime with api scope, so an Android
consumer can implement Initializer without declaring AndroidX itself, and it publishes the same
two Android floors that dependency does: minSdk 21 and minCompileSdk 34. Adopting this
library never narrows the device range or forces a compileSdk move relative to plain
androidx.startup; :startup:checkAndroidFloors fails the build if either floor rises.
Two requirements come from the way the artifacts are compiled:
inline, and Kotlin refuses to inline
bytecode built for a newer JVM target than the caller's.One more requirement comes from the consumer's own code rather than from the artifacts. An
application that writes an expect class initializer, which is the shape below for anything
touching a platform SDK, needs
kotlin {
compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") }
}Kotlin still reports expect/actual classes as Beta, so without that flag every such initializer
emits a BETA_EXPECT_ACTUAL_CLASSES warning. It is a warning, not an error, and a consumer whose
initializers all live in commonMain and extend BaseInitializer needs no flag at all.
class LoggerInitializer : BaseInitializer<Logger>() {
override fun create(context: StartupContext): Logger = Logger()
}
class NetworkInitializer : Initializer<Network> {
override fun create(context: StartupContext): Network {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return Network(logger)
}
override fun dependencies(): List<AnyInitializerKey> =
listOf(initializerKey<LoggerInitializer>())
}BaseInitializer exists because neither half of an expect/actual pair may give a member a
default body. Extend it whenever an initializer has no dependencies and the dependencies()
override disappears.
Use StartupContext rather than Context in shared code. Both name the same type, but Context
collides with android.content.Context in any file that imports both.
Starting Crashlytics, a Cocoa reporter and a browser reporter are different calls, so declare the
initializer expect and give each platform its own actual. The class keeps one fully qualified
name on every target, so a single AndroidManifest <meta-data> entry addresses it whichever
actual is compiled in, and it registers in the same manifest as a shared initializer.
expect class CrashReportingInitializer() : Initializer<CrashReporting> {
override fun create(context: StartupContext): CrashReporting
override fun dependencies(): List<AnyInitializerKey>
}actual class CrashReportingInitializer actual constructor() : Initializer<CrashReporting> {
actual override fun create(context: StartupContext): CrashReporting {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return CrashReporting("AndroidCrashReporter(${context.packageName})", logger)
}
actual override fun dependencies(): List<AnyInitializerKey> =
listOf(initializerKey<LoggerInitializer>())
}The expect class has to redeclare every member it does not inherit a body for. Initializer
carries both of its members abstract, so a subclass that declares none is abstract; an expect that
omits them fails with "has no corresponding expected declaration" on the actual side.
Extend BaseInitializer and only create has to be redeclared. BaseInitializer supplies a
concrete dependencies(), so the expect names one member instead of two. This is the shape to
reach for unless the initializer really does declare dependencies:
expect class CrashReportingInitializer() : BaseInitializer<CrashReporting> {
override fun create(context: StartupContext): CrashReporting
}actual class CrashReportingInitializer actual constructor() : BaseInitializer<CrashReporting>() {
actual override fun create(context: StartupContext): CrashReporting =
CrashReporting("AndroidCrashReporter(${context.packageName})")
}Redeclaring create on the expect is what makes it an expected member, so the actual spells its
override actual override. Leave the actual off and the compiler answers Declaration must be
marked with 'actual'.
There is a shorter form, and it is not portable. Drop the body from the expect entirely and
create becomes an inherited abstract member rather than an expected one, so the actual overrides
it with a plain override and the expect never names it at all:
expect class CrashReportingInitializer() : BaseInitializer<CrashReporting>That compiles, links and runs on all eleven platform compilations. A metadata compilation rejects it:
e: CrashReportingInitializer.kt:5:8 Class 'CrashReportingInitializer' is not abstract and does not
implement abstract member:
expect fun create(context: Context): T
The task is compileCommonMainKotlinMetadata, and every KMP module with a shared commonMain has
one: anything published, anything running KSP in commonMain, anything relying on cinterop
commonization. ./gradlew build runs it; compileKotlin<Target>, compileAndroidMain and
link<Target> do not, so this shape can pass a target-by-target verification on all eleven and still
fail the next build. It is not a BaseInitializer quirk either — a plain abstract class with an
unimplemented abstract member behaves identically under an expect class, so nothing this library
could ship would change it. Reach for it only in a module you know has no metadata compilation, such
as a test source set.
Either shape compiles to implements androidx.startup.Initializer with a public no-argument
constructor and AndroidX's own dependencies() signature, so reflection is unaffected.
PlatformInitializer and MemberlessInitializer in the library's own commonTest pin both across
the eleven platform compilations and AndroidInitializerContractTest pins their bytecode; sample's
RuntimeInfoInitializer is what keeps this section honest, because it lives in a commonMain that
really is metadata-compiled. Delete its override fun create line and ./gradlew build goes red.
Every actual must be a class with a public no-argument constructor. AndroidX ignores the
factory in the manifest object and reflects with getDeclaredConstructor().newInstance(), so an
actual object compiles everywhere and throws only on Android, at process start. sample's
CrashReportingContractTest pins both halves of that contract with reflection.
Only the part that varies has to be expect. Where the initializer itself is shared and just needs
a per-platform value, keep one class in commonMain and put the seam behind a small expect — the
sample resolves its SDK name through expect object Platform, with actuals in appleMain,
desktopMain, jsMain, wasmJsMain, linuxMain and mingwMain. An expect may be declared in
an intermediate source set, so Platform lives in nonAndroidMain and Android never sees it.
val manifest = StartupManifest {
metaData<AnalyticsInitializer> { AnalyticsInitializer() }
lazyInitializer<NetworkInitializer> { NetworkInitializer() }
lazyInitializer<LoggerInitializer> { LoggerInitializer() }
}metaData registers a component that is initialized eagerly at startup, the equivalent of a
<meta-data android:value="androidx.startup" /> entry. lazyInitializer registers one that is
created only when something asks for it. remove<T>() is the equivalent of tools:node="remove"
and hides an entry an included manifest contributed.
Manifests compose, later entries winning, so a library can ship one that an application overrides:
val applicationManifest = StartupManifest {
include(libraryManifest)
remove<LibraryDebugInitializer>()
metaData<ApplicationInitializer> { ApplicationInitializer() }
}On Android remove<T>() only suppresses what Startup.install would otherwise start. A component a
library contributed through its own AndroidManifest is created by InitializationProvider before any
application code runs, so nothing here can reach it; suppressing that needs a real
tools:node="remove" entry in the application's AndroidManifest, written by hand.
metaData<T>, lazyInitializer<T> and remove<T>() name the component at compile time. Each also
has an overload that takes the key instead, for an initializer the compiler cannot name: one a host
application constructed and handed to Kotlin, one discovered from a plugin, or one written in Swift.
initializerKey(initializer) builds the key from the instance, and it is the same key
initializerKey<T>() would have reified:
fun manifest(supplied: List<Initializer<*>>): StartupManifest = StartupManifest {
metaData<AnalyticsInitializer> { AnalyticsInitializer() }
for (initializer in supplied) metaData(initializerKey(initializer)) { initializer }
}Registered that way, a host-supplied initializer is an ordinary node: it is ordered behind whatever
it declares in dependencies(), other components may depend on it, it is created once, and a cycle
or a missing registration around it is diagnosed like any other. Running such initializers outside
the graph, before Startup.install, gives up all four.
Startup.install(context, manifest)
val analytics = Startup.getInstance(context)
.initializeComponent(initializerKey<AnalyticsInitializer>())On Android the AndroidManifest is the primary way to start components: InitializationProvider
reads it before any application code runs, exactly as it does for a pure AndroidX app. Declare the
eager components there and nothing else is needed at boot. sample/src/androidMain/AndroidManifest.xml
is a worked example, and AndroidManifestParityTest fails the build if it drifts from
SampleStartup.manifest.
Startup.install remains available as the programmatic equivalent, and is the only route on the
other ten targets.
Each eager component is one <meta-data> line inside the provider block. Write those lines by hand,
exactly as a plain androidx.startup application does:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.AnalyticsInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>InitializationProvider resolves each name with Class.forName, so every one has to be fully
qualified. The xmlns:tools declaration is required as soon as a tools:node="remove" entry is in
the block.
AndroidX answers isEagerlyInitialized from what InitializationProvider discovered in the
AndroidManifest, so a component started only by Startup.install still reports false there.
Declaring the component in the manifest is what makes the two agree.
This is the one failure this library can produce that plain androidx.startup cannot, and it is
worth naming. On Android the StartupManifest factories are never called: InitializationProvider
reads the AndroidManifest and nothing else. So a component registered with metaData<T> and left
out of the XML runs correctly on ten targets and, in an app that relies on
InitializationProvider alone, silently never runs on the eleventh. Startup.install
does start it on Android, but isEagerlyInitialized still reports false for it. There is no
exception, no log and no lint check, because from AndroidX's point of view nothing is wrong.
Until 1.1.0 the library answered that itself, with verifyAndroidManifest(context),
androidManifestDrift(context) and androidManifestDrift(declared). 2.0.0 removes all three:
androidx.startup has no counterpart for any of them, and mirroring androidx.startup is this
library's whole contract. The problem has not gone away with them, so read the rest of this
section rather than treating the removal as a fix. The answer from 2.0.0 is the one a plain
androidx.startup application already uses, and it is two things.
The AndroidManifest is the source of truth on Android. Write its <meta-data> entries by hand,
declare there exactly what should start eagerly, and treat the StartupManifest as the registry for
the other ten targets. Nothing in the library reconciles the two for you.
Keep a parity test of your own. It is a dozen lines against the API that is staying, and it goes
in an Android source set because that is where a key is a java.lang.Class and can name its
component the way Class.forName needs it named:
class AndroidManifestParityTest {
@Test
fun theManifestDeclaresExactlyTheEagerComponents() {
val xml = File(System.getProperty("myapp.androidManifest")!!).readText()
val declared = Regex("<meta-data[^>]*?android:name=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL)
.findAll(xml)
.map { it.groupValues[1] }
.toSet()
assertEquals(manifest.eagerComponents.map { it.name }.toSet(), declared)
}
}Both directions matter. A component eager in the StartupManifest and absent from the XML never
runs on Android; one the XML declares while the StartupManifest keeps it lazy or removed runs
eagerly on Android alone. A name the XML declares that the StartupManifest has never heard of is
your call rather than the library's: a mixed application is free to declare initializers written
directly against androidx.startup beside these, and a test that reports those is useless in exactly
the applications that need one most. The set comparison above treats such a name as a failure, so
subtract them, or compare only the names the StartupManifest knows.
Put the XML on the test task's inputs, or the check stops running. A test that reads a file
Gradle does not know about stays UP-TO-DATE when only that file changes, so deleting a
<meta-data> line leaves the build green and the drift undetected — which is the very failure the
check exists to catch:
tasks.withType<Test>().configureEach {
val manifest = layout.projectDirectory.file("src/androidMain/AndroidManifest.xml")
inputs.file(manifest)
.withPropertyName("androidMainManifest")
.withPathSensitivity(PathSensitivity.RELATIVE)
systemProperty("myapp.androidManifest", manifest.asFile.absolutePath)
}sample's own AndroidManifestParityTest is exactly the test above, run against a real
AndroidManifest.xml and a real StartupManifest on every build. Copy it. Its negative control is
the whole point: delete a <meta-data> line from sample/src/androidMain/AndroidManifest.xml and
./gradlew :sample:testAndroidHostTest fails, naming the component that would have stopped running
on Android alone.
Android-only source sets can keep using the verbatim AndroidX spelling against the very same components:
androidx.startup.AppInitializer.getInstance(context)
.initializeComponent(AnalyticsInitializer::class.java)There is no manifest merger and no ContentProvider, so Startup.install is the only registration
step. Pass DefaultContext when your initializers need nothing from the platform:
Startup.install(DefaultContext, manifest)Initialization runs sequentially on the calling thread, and every entry point is serialized behind
one reentrant lock, which is what AndroidX gets from synchronized (sLock): a component is created
exactly once however many threads ask for it, and an Initializer.create may call back into
initializeComponent without deadlocking. The engine holds no coroutine of its own, because
runBlocking does not exist on Kotlin/JS or Kotlin/Wasm; a component whose own work suspends says
so with CoroutineInitializer, see Initializing something that suspends below.
StartupPlan.waves exposes the Kahn levels as data, and
Startup.install(context, manifest, runner) hands each level to a WaveRunner of your choosing —
see Running a wave concurrently below for what a task may not do.
AppInitializer.initializeComponentOrNull(key) is the read for a component whose key is an
AnyInitializerKey — the element type of dependencies(), what initializerKey(initializer)
returns, and the only key a host that discovered an initializer at run time can build — and for one
whose create returned null. initializeComponent takes neither: its key is
InitializerKey<out Initializer<T>> and its T is bound to Any.
AppInitializer is the same two members here that it is on Android. Until 1.1.0 it carried three
more off Android — isInitialized(component), initializationOrder() and manifest() — and
2.0.0 removes them. On Android AppInitializer is androidx.startup.AppInitializer, which
exposes neither the order it created things in nor whether a given component exists, and keeps no
accessible state to derive either from, so androidx.startup had no counterpart to mirror and those
three were the only members of the API that ten targets had and the eleventh did not.
Record what you need from inside your own create, which is what sample's SampleReport does:
class NetworkInitializer : Initializer<Network> {
override fun create(context: StartupContext): Network {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return Network(logger).also { logger.ready("network") }
}
override fun dependencies(): List<AnyInitializerKey> = listOf(initializerKey<LoggerInitializer>())
}That reads the same on all eleven targets, which the removed members never could. For the manifest,
hold on to the StartupManifest you passed to Startup.install: it is an ordinary value, and
components, eagerComponents, isEager and in all still answer from it. See Upgrading from
1.x below.
Export the library from your framework first. implementation("io.github.kunal26das:startup")
is enough for Kotlin and is not enough for Swift. A dependency module that a framework does not
export has its module name mangled into every class the header does carry, and the declarations that
appear in no exported signature are dropped from it altogether. Add both lines below, or the Swift
snippets in this section name types that do not exist:
kotlin {
sourceSets.commonMain.dependencies {
api("io.github.kunal26das:startup:3.0.0")
}
listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach {
it.binaries.framework {
baseName = "Shared"
export("io.github.kunal26das:startup:3.0.0")
}
}
}A build whose targets are declared elsewhere, in a convention plugin, reaches the same frameworks without naming them:
import org.jetbrains.kotlin.gradle.plugin.mpp.Framework
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
kotlin {
targets.withType<KotlinNativeTarget>().configureEach {
binaries.withType<Framework>().configureEach {
export("io.github.kunal26das:startup:3.0.0")
}
}
}export requires api. Left on implementation, the build fails with dependencies exported in
the framework are not specified as API dependencies of a corresponding source set.
You may already have it without writing the line. A framework with transitiveExport = true that
exports a module which declares api("io.github.kunal26das:startup:3.0.0") exports this library too,
which is what a convention plugin that exports a shared core module typically produces. Check the
generated header for swift_name("InitializerKeyKt") before adding anything: if it is there, the
export is already in place.
This is what a consumer's own framework header contains either way. It is measured rather than
predicted: :sample:checkConsumerObjCExport links one framework each way and greps both.
| declaration | with export(...)
|
without it |
|---|---|---|
Initializer |
Initializer |
StartupInitializer |
Context |
StartupContext |
StartupStartupContext |
InitializerKey |
InitializerKey |
StartupInitializerKey |
StartupManifest |
StartupManifest |
StartupStartupManifest |
initializerKey() |
InitializerKeyKt |
absent entirely |
DefaultContext |
DefaultContext |
absent entirely |
The last two rows are the ones that bite. A top-level function facade appears in no exported
signature, so InitializerKeyKt never reaches the header at all: a Swift class can conform to
StartupInitializer and can never build a key, which leaves its dependencies() able to return only
[]. Swift reports cannot find type 'InitializerKey' in scope and says nothing about export.
With the export in place, Startup, StartupManifest, StartupManifestBuilder, AppInitializer,
Initializer, BaseInitializer, InitializerKey, InitializerKeyKt, StartupContext and
DefaultContext all arrive under those names, so an iOS host can build a manifest and implement an
initializer in Swift. Two things follow from the fact that a reified type argument cannot cross
that boundary:
reified registration functions are hidden from the header. metaData<T>,
lazyInitializer<T>, remove<T>() and initializerKey<T>() carry @HiddenFromObjC, because
Kotlin/Native would otherwise export their non-inline bodies with T collapsed to Initializer<*>
— four Swift-callable methods that compile, run, and register every call site under one key.metaData(component:factory:),
lazyInitializer(component:factory:), remove(component:) and initializerKey(initializer:) are
exported and do what they say:func boot(_ application: UIApplication) throws {
let manifest = StartupManifest.companion.invoke { builder in
let lifecycle = ViewControllerLifecycleInitializer()
builder.metaData(component: InitializerKeyKt.initializerKey(initializer: lifecycle)) { lifecycle }
}
try Startup.shared.install(context: DefaultContext.shared, manifest: manifest)
}install is try from 3.0.0; see Every entry point carries @Throws below.
The Swift name of Context is StartupContext. A Kotlin typealias does not survive the
Objective-C export, so the class itself carries @ObjCName("StartupContext"). That keeps the Swift
name equal to the alias this README already tells Kotlin authors to prefer, and it keeps a bare
Context out of the framework's namespace — which matters, because UIViewControllerRepresentable
declares a Context of its own and a framework-level one shadows it in the iOS host file every
Compose Multiplatform app has, with a does not conform to protocol error that never mentions the
name.
Name any initializer from Swift with initializerKey(initializer:). It is the only key overload
Swift can reach in practice — a reified type argument cannot cross the boundary, and although
initializerKey(kClass:) is exported, a KotlinKClass is not obtainable from Swift to pass it. It
works the same whether the initializer was written in Kotlin or in Swift:
final class HostInitializer: NSObject, Initializer {
func create(context: StartupContext) -> Any? { ... }
func dependencies() -> [InitializerKey<Initializer>] {
[InitializerKeyKt.initializerKey(initializer: KoinInitializer())]
}
}KoinInitializer there is a Kotlin class, and naming it this way constructs one. Be clear-eyed
about that: initializerKey(objCClass:), removed in 2.0.0, took the class object and constructed
nothing, so a Swift dependencies() that names three Kotlin components now runs three constructors
that the returned keys then throw away, once per call. It is free for an initializer that holds
nothing in its constructor and does its work in create, which is what this library asks of every
initializer anyway: AndroidX builds each one reflectively through
getDeclaredConstructor().newInstance(), at a moment the author does not choose. It is not free for
one whose constructor has a side effect, and such an initializer should not have one. If the cost is
real for you, hoist the keys into a let computed once rather than rebuilding them per
dependencies() call. See Upgrading from 1.x below.
Every entry point carries @Throws(StartupException::class). Without it Kotlin/Native does not
propagate an exception to Swift as an NSError: it prints Function doesn't have or inherit
@Throws annotation and thus exception isn't propagated and terminates the process, so a diagnosable
launch failure arrived as an abort. Startup.install, Startup.getInstance,
AppInitializer.initializeComponent, AppInitializer.initializeComponentOrNull,
AppInitializer.isEagerlyInitialized, StartupTask.invoke, StartupPlanner.plan and
StartupPlanner.validate are all try in Swift now. :startup:checkObjCExport asserts the
NSError parameter on every one of them, so a dropped annotation fails the build rather than the
app.
A Swift WaveRunner has one spelling that compiles, because the task list crosses as an array
of Objective-C objects:
final class ConcurrentWaveRunner: NSObject, WaveRunner {
func run(wave: [StartupTask]) {
DispatchQueue.concurrentPerform(iterations: wave.count) { index in
do { try wave[index].invoke() } catch { }
}
}
}A Swift runner cannot rethrow, and does not have to. run is exported without an error
parameter, so a throws conformance does not compile — Swift reports candidate throws, but
protocol does not allow it. Catching and dropping is correct here: the task recorded the failure
before it reached you, and the engine re-raises it as a StartupException naming the component once
run returns. That is why the contract below says a runner must let a failure out where the
language allows.
concurrentPerform runs some iterations on the calling thread and some on workers, which is worth
knowing: an iteration that lands on the calling thread may resolve another component and one that
lands on a worker may not, so a runner written this way must not touch AppInitializer at all.
:startup's checkObjCExport task links Startup.framework and asserts on the generated header,
and :sample's checkConsumerObjCExport does the same for the two frameworks a consumer gets, so
neither the export shape nor the recipe above can regress unnoticed.
Everything in the API mapping below behaves the same on all eleven targets. Six things do not, and the first four are cases where code written and tested on Android would misbehave elsewhere.
StartupPlanner emits Kahn levels, so the two pick different valid
topological orders. Anything that must run before something else has to say so in dependencies().initializeComponent succeeds for a component that no StartupManifest
registers and for one registered with remove<T>(). Off Android the manifest is the only registry
there is, and both throw StartupException.StartupException
with a components path. On Android the failure comes out of AndroidX as
androidx.startup.StartupException, with the message Cannot initialize <FQCN>. Cycle detected.
and no path. StartupPlanner.validate(manifest) is the way to get this library's diagnostics on
Android too.commonMain, so it fires on every target where the graph is planned — but
Startup.install on Android never calls a factory, so there it fires only under
StartupPlanner.validate(manifest). An Android app that never validates keeps running, on the
class AndroidX reflected rather than the one the factory would have built.Initializer<T> leaves T unbounded and the
Objective-C export gives a Swift author Any? to return, so create may hand back null; the
engine stores it, initializeComponentOrNull returns it, and initializeComponent names the
component rather than failing a cast. On Android androidx.startup.Initializer.create is
@NonNull, so such an initializer does not compile at all — an Android-compatible component with
no product is an Initializer<Unit> that returns Unit. It is the same constraint that bounds
CoroutineInitializer<T : Any>.StartupManifest is the whole registry. On Android
it is the AndroidManifest, and the factories in the StartupManifest are never called. That is
the one failure mode adopting this library adds. The AndroidManifest is the source of truth there
and a parity test of your own is the answer to it; see Keep the two Android registries in step
above.By default both runtimes create one component at a time on the calling thread. Everything in a
StartupPlan wave depends only on earlier waves, though, so a wave is safe to run all at once, and
Startup.install(context, manifest, runner) hands each one to a WaveRunner in turn:
Startup.install(context, manifest) { wave ->
runBlocking { coroutineScope { wave.map { async(startupDispatcher) { it() } }.awaitAll() } }
}The dispatcher there is the whole point. A task is an ordinary blocking call rather than a
suspending one, so async { } without a dispatcher inherits runBlocking's single-threaded event
loop and runs the wave one task after another on the calling thread — which is exactly what
Startup.install(context, manifest) already does, at the cost of a runner that looks concurrent.
Give it a dispatcher of its own rather than reaching for Dispatchers.Default. A task blocks the
thread it runs on for as long as its component takes, so a wave of CoroutineInitializers
dispatched onto the pool their own createAsync resumes on starves that pool and hangs the install;
see Initializing something that suspends. Dispatchers.IO is elastic on both the JVM and
Kotlin/Native and is the safe default here.
The library keeps the ordering, the cycle detection, the deduplication and the created components;
the concurrency is yours. install waits for each wave before planning the next, so run must
invoke every task exactly once, must let a task's failure out rather than swallowing it, and must
not return until all of them have finished. A second invocation of a task is refused at the call, by
StartupTask.invoke itself; the other two are checked once run returns. Either way the violation
is a StartupException naming the components it applies to rather than a component quietly filed as
null.
A task is a StartupTask, and it names the component it will create. task.component is the
key and task.toString() is the component's name, so a runner can route a wave rather than merely
run it — the one component that has to stay on the calling thread dispatched differently from the
rest — and can attribute a slow or failed wave to a component. It is also where a host hangs its own
tracing, because the engine has no hook of its own:
Startup.install(context, manifest) { wave ->
runBlocking {
coroutineScope {
wave.map { task ->
async(if (task.component == mainThreadOnly) Dispatchers.Main else Dispatchers.IO) {
trace(task.toString()) { task() }
}
}.awaitAll()
}
}
}A task may call AppInitializer.initializeComponent only from the thread that called install,
and only for what an earlier wave already created. The lock is held across the whole install and it
is reentrant, so a runner that stays on the calling thread — which is every runner on Kotlin/JS and
Kotlin/Wasm — may read a component an earlier wave built. From any other thread that call can never
be served, and it now fails immediately with a StartupException saying so rather than waiting on a
lock the installing thread cannot release until run returns.
A component of the wave being run right now is refused too, on every thread. Nothing a wave creates
is written back until run returns, so a sibling is neither created nor creatable from inside one —
and it is refused by name rather than reported as a cycle, because two components share a wave
precisely when neither declares the other, so there is no cycle to draw. A component that asks for
itself inside a wave gets the same refusal, which says so; without a runner that one is still
reported as the cycle it is. Declaring the edge in
dependencies() is what makes the call safe from anywhere, because that is what puts the dependency
in a strictly earlier wave. That is what makes sample's own manifest safe under a runner:
NetworkInitializer and AnalyticsInitializer resolve what they need imperatively and declare the
same edges, which is the AndroidX-documented pattern, so each of them only ever reads back a
component an earlier wave already built.
On Android the runner is ignored — androidx.startup creates each component itself, depth first
on the calling thread, and offers no seam to change it. For ordinary Initializers a runner is
therefore a performance decision on the other ten targets and never a correctness one, so anything
that must run before something else still has to say so in dependencies().
For a CoroutineInitializer it is a correctness decision. create blocks the thread it is
called on until createAsync finishes, and the runner is what picks that thread: on Android it is
always InitializationProvider's, which is the main thread, while off Android a runner can move it
to a worker or leave it on whoever called install. Which thread that is decides whether the
component's own dispatching can make progress — see Initializing something that suspends.
You can also skip AppInitializer for the concurrent part entirely: plan with
StartupPlanner.plan(manifest, roots, satisfied), read plan.waves, construct your own initializers
— you wrote the factories — call create(context) on a level in parallel, and hold the results
yourself. That is a fork in the road rather than a seam, and it is worth measuring first: an
initializer that hands its work to a background scheduler and returns immediately costs the same
either way.
The graph's promise is that a dependency is created before the component that declares it. For a
component whose real work is a suspend call — which is nearly every mobile SDK — a plain
Initializer can only keep half of it: create has nowhere to await, so the idiomatic escape is to
launch the work and return, and the graph then orders the launches rather than the completions. A
dependency edge that does not wait is not a dependency edge.
CoroutineInitializer is where a component says its work suspends:
class FirebaseInitializer : CoroutineInitializer<Unit> {
override suspend fun createAsync(context: StartupContext) = Firebase.start(context)
}create is inherited and blocks the calling thread until createAsync returns, so anything
declaring this component in dependencies() starts after it has finished rather than after it has
begun. Four consequences follow, and every one of them is yours to accept:
InitializationProvider runs on, which is
the main thread; elsewhere it is whoever called Startup.install, unless a WaveRunner moved the
wave. A component that must not block startup should still launch and return, and say so by
staying an ordinary Initializer.createAsync that dispatches to the main
dispatcher, from the main thread, deadlocks. That is the ordinary runBlocking hazard and this
type does not change it.create holds a worker of the pool the
runner sent it to; if createAsync then needs a worker of that same pool to resume, a wave with
as many such components as the pool has parallelism starves. Measured: twenty components whose
createAsync does withContext(Dispatchers.Default), run by a Dispatchers.Default runner on an
18-core machine, entered 18 bodies, resumed none, and never returned. Three of the same components
finish; so do twenty that only delay. Give the runner a dispatcher the components do not use.createAsync. A wave task that calls
AppInitializer.initializeComponent is refused at once, but only on the thread the task body runs
on. createAsync switching dispatchers moves the call to a thread the guard does not know about,
where it waits for a lock the install cannot release — with or without a runner. Declare the edge
in dependencies(); resolving a component from inside createAsync is not supported.CoroutineInitializer is a Kotlin-side type. Kotlin interface default bodies do not become
Objective-C protocol defaults, so a Swift class conforming to it inherits nothing and must write the
blocking create(context:) itself. A Swift initializer that has to await should implement
Initializer directly and do its own waiting.
CoroutineInitializer<T : Any> bounds T to Any, because androidx.startup.Initializer declares
create @NonNull and this interface implements it on Android like any other.
Kotlin/JS and Kotlin/Wasm have one thread and no way to park it, so there is nothing for create to
block and it throws a StartupException that says exactly that. Those two targets run every
ordinary Initializer as before; it is only the blocking bridge that has nowhere to stand.
androidx.startup |
io.github.kunal26das.startup |
On Android |
|---|---|---|
androidx.startup.Initializer<T> |
Initializer<T> |
typealias |
android.content.Context |
Context, StartupContext
|
typealias |
Class<out Initializer<*>> |
InitializerKey<T>, AnyInitializerKey
|
typealias to java.lang.Class
|
MyInitializer::class.java |
initializerKey<MyInitializer>() |
inlines to a class constant |
instance.getClass() |
initializerKey(instance) |
::class.java on the instance |
Class.forName(name) |
initializerKey(kClass) |
::class.java on the KClass |
androidx.startup.AppInitializer |
AppInitializer |
typealias |
AppInitializer.getInstance(context) |
Startup.getInstance(context) |
delegates to the static |
AppInitializer.initializeComponent(component) |
AppInitializer.initializeComponent(component) |
same method |
AppInitializer.isEagerlyInitialized(component) |
AppInitializer.isEagerlyInitialized(component) |
same method |
<meta-data android:value="androidx.startup" /> |
StartupManifest { metaData<T> { T() } } |
still the manifest |
| no equivalent | StartupManifest { metaData(key) { it } } |
still the manifest |
tools:node="remove" |
StartupManifest { remove<T>() } |
still the manifest |
InitializationProvider.onCreate() |
Startup.install(context, manifest) |
eagerly initializes |
| no equivalent | Startup.install(context, manifest, runner) |
runner ignored |
| no equivalent |
WaveRunner, StartupTask
|
never called |
| no equivalent | AppInitializer.initializeComponentOrNull(key) |
delegates; null not expressible |
| no equivalent | CoroutineInitializer<T : Any> |
blocks the provider's thread |
androidx.startup.StartupException |
StartupException |
not a typealias
|
StartupException is deliberately our own type. AndroidX annotates its exception
@RestrictTo(LIBRARY), so aliasing it would make every consumer's catch clause fail lint's
error-severity RestrictedApi check. Failures raised by AndroidX itself still arrive as AndroidX's
own type on Android, exactly as they do in an app that uses androidx.startup directly.
There is no expect companion object on AppInitializer because a Java static has no member for
one to match, which is why Startup exists.
Until 1.1.0 this table carried eight more rows, each of them a declaration androidx.startup
genuinely had no counterpart for, and each of them a platform asymmetry: four existed for Android's
manifest and did nothing useful off it, three existed on the other ten targets and not on Android,
one existed on the Apple targets alone. 2.0.0 removes all eight, so the table now has one shape
on all eleven targets. See Upgrading from 1.x below.
One no equivalent row survives, StartupManifest { metaData(key) { it } }, and it was never in
that group. It is the registration androidx.startup performs by name in XML, expressed as a key,
which makes it closer to androidx.startup than the reified overload beside it, and it is the
only way Swift or a plugin host can register an initializer the compiler cannot name.
2.1.0 tightens one rule. A factory must now build exactly the class its key names;
2.0.0 accepted a subclass and filed it under the supertype. That never worked on Android,
where AndroidX ignores the factory and reflects the key, so metaData<Base> { Derived() }
started Base on Android and Derived everywhere else from one manifest. Register the
subclass under its own key. Because the covariance of Kotlin function types lets the
reified overloads express the mismatch too, this affects metaData<T> { ... } and
lazyInitializer<T> { ... } as well as the key-taking overloads.
The rejection lives in commonMain and fires wherever the graph is planned, Android
included: Startup.install never calls a factory there, so it does not surface it, but
StartupPlanner.validate(manifest) does, on all eleven targets.
2.1.0 also adds Startup.install(context, manifest, runner) and the WaveRunner it takes,
which run each wave of the plan however the host wants rather than on the calling thread; see
Running a wave concurrently. It is ignored on Android, and nothing else published in 2.0.0
changed.
3.0.0 changes what a WaveRunner receives. run(wave: List<() -> Unit>) is now
run(wave: List<StartupTask>). A task is still invoked the same way — it() — so a runner written
as wave.map { async { it() } } needs no edit; one that named the type of its parameter does. What
the change buys is that a task now names its component, which is what makes routing, tracing and an
attributable failure possible at all.
3.0.0 enforces the WaveRunner contract it always documented. A runner that runs a task twice
is refused at the second call; one that skips a task or catches a task's failure and returns anyway
is a StartupException naming the components once run returns, instead of a component filed as
null that fails much later somewhere else. A wave that
fails now also keeps the components that succeeded beside the failure, which is what the sequential
path always did.
3.0.0 fails fast instead of waiting for a lock that cannot be released. A wave task that calls
AppInitializer.initializeComponent from a thread other than the installing one now throws
immediately. It used to park on the JVM and spin without yielding on Kotlin/Native, where it cost a
core for as long as the install ran and never ended. A task on the installing thread still works for
anything an earlier wave created, as it always did — the flat prohibition in the 2.x README was
wrong about that half — but not for a component of the wave in flight, which is refused by name. A
thread that is not running a task is unaffected: its wait for the install to finish really does end,
so it still waits.
StartupTask's constructor is public, so a runner can still be exercised against a wave the test
built rather than only through Startup.install with a real manifest.
3.0.0 adds AppInitializer.initializeComponentOrNull(key), the read for an AnyInitializerKey
and for a component whose create returned null. Both were previously unreadable: the key type did
not fit initializeComponent, and a null product failed its cast with a bare
NullPointerException raised inside the library. That failure is now a StartupException naming
the component.
3.0.0 adds CoroutineInitializer, and with it a dependency on kotlinx-coroutines-core for
the Android, JVM and native artifacts. See Initializing something that suspends.
3.0.0 annotates the public API with @Throws(StartupException::class), which is source-breaking
for Swift: a call that could fail now needs try. Kotlin callers are unaffected.
2.0.0 removes the eight declarations 1.1.0 deprecated, and nothing else. Each carried
DeprecationLevel.WARNING in 1.1.0, so a consumer that took the warnings has nothing left to do;
one upgrading straight from 1.0.0 gets errors instead, and the table below is the whole list.
The reason is the same for all eight. None of them had a counterpart in androidx.startup, and
mirroring androidx.startup is this library's whole contract — for Android that contract is literal,
because Initializer, AppInitializer, Context and the key are typealiases of the AndroidX
types. They were also the only platform asymmetry in the API mapping table: three existed on the ten
non-Android targets and not on Android, four existed for Android's manifest and did nothing useful
off it, and one existed on the Apple targets alone.
| removed in 2.0.0 | replacement |
|---|---|
AppInitializer.isInitialized(component) |
record it from inside your own Initializer.create
|
AppInitializer.initializationOrder() |
record it from inside your own Initializer.create
|
AppInitializer.manifest() |
keep the StartupManifest you passed to Startup.install
|
manifest.androidManifestMetadata() |
write the <meta-data> entries by hand |
manifest.androidManifestDrift(declared) |
keep a parity test of your own |
manifest.androidManifestDrift(context) |
keep a parity test of your own |
manifest.verifyAndroidManifest(context) |
keep a parity test of your own |
initializerKey(objCClass) |
initializerKey(initializer), from an instance |
The first three. sample's SampleReport is the worked example. The initialization order it
prints is a list each component appends to from inside its own create, which is exactly why the
report reads the same on all eleven targets instead of on ten. isInitialized has no direct
replacement and does not need one: asking for a component that already exists returns it without
running create again, so there is nothing to guard. manifest() handed back the value you passed
to Startup.install — keep it in a val, and components, eagerComponents, isEager and in
all still answer from it.
The Android four. The problem they addressed has not gone away. The StartupManifest and the
AndroidManifest really are two registries and only Android reads the second, so a component in one
and missing from the other still misbehaves on exactly one platform. What changes is who owns the
answer: from 2.0.0 the AndroidManifest is the source of truth on Android, its <meta-data> entries
are written by hand as a plain androidx.startup application writes them, and a consumer that wants
the two held in step keeps its own test. Keep the two Android registries in step above has one,
in a dozen lines, against the API that is staying, and sample's AndroidManifestParityTest runs
exactly it on every build. The generated <meta-data> block is also gone, so paste the lines once
from that test's failure message or write them out; they are three lines for three eager components
and they change about as often as the components do.
The Apple one. initializerKey(objCClass:) was the only Apple-only declaration in the API.
initializerKey(initializer:) needs an instance, so naming a component from Swift now constructs a
throwaway one and runs its constructor — a cost the class-object overload did not have, and the one
genuine regression in this release. It bites only an initializer whose constructor does something,
and none should: AndroidX builds every initializer reflectively through
getDeclaredConstructor().newInstance(), at a moment the author does not choose, so the work belongs
in create.
Nothing else changes. Every other declaration published in 1.1.0 stays exactly as it was, the
key-taking registration overloads included — metaData(component, factory),
lazyInitializer(component, factory), remove(component), initializerKey(initializer) and
initializerKey(kClass). They are not deprecated and are not going anywhere: androidx.startup's
own registration is by name in XML, so a key-taking overload is closer to it than a reified one,
and without them Swift cannot register a host-supplied initializer at all.
Register factories, never instances, and let every factory construct its initializer with no arguments:
val manifest = StartupManifest {
metaData<NetworkInitializer> { NetworkInitializer() }
}A factory that passes constructor arguments compiles, and works on all ten non-Android targets:
val manifest = StartupManifest {
metaData<NetworkInitializer> { NetworkInitializer(httpClient) }
}On Android it throws. AndroidX never calls the factory: InitializationProvider and
AppInitializer build every initializer with getDeclaredConstructor().newInstance(), so a class
without a public no-argument constructor fails at runtime with
StartupException(NoSuchMethodException). Take what an initializer needs from create(context),
or from another component resolved through initializeComponent.
For the same reason the factory is never used to construct the initializer twice: it is called at
most once per run, and only off Android. StartupPlanner.plan and
StartupPlanner.validate also call it, on every target including Android, because reading
dependencies() needs an instance.
These diagnostics come from StartupPlanner, which is what runs on the ten non-Android targets. On
Android the initialization itself is AndroidX's, so a failure there arrives as
androidx.startup.StartupException instead. StartupPlanner.validate(manifest) is commonMain
code and runs everywhere, including in an Android unit test, so it is how to get the diagnostics
below on every platform.
A cycle names the component the walk re-entered and prints the cycle itself, not the acyclic path
that led to it. For Entry -> LoopHead -> LoopTail -> LoopHead:
Cannot initialize LoopHead. Cycle detected: LoopHead -> LoopTail -> LoopHead
A path longer than twelve components is elided in the middle. The same path is available as data on
StartupException.components, first element repeated last, so a test can assert on it instead of
matching text:
val exception = assertFailsWith<StartupException> { StartupPlanner.validate(manifest) }
assertEquals(
listOf(
initializerKey<LoopHeadInitializer>(),
initializerKey<LoopTailInitializer>(),
initializerKey<LoopHeadInitializer>(),
),
exception.components,
)A dependency nobody registered names both ends and the remedy:
Cannot initialize Orphan. No initializer is registered for it, required by OrphanDependent.
Register it in a StartupManifest with metaData or lazyInitializer, then install that manifest
with Startup.install(context, manifest).
A component a remove<T>() entry hides says so, rather than claiming nobody registered it, so the
remedy on offer is not to re-register the entry the application took out:
Cannot initialize Alpha. A remove() entry hides it, and Beta still declares it as a dependency.
Drop that dependencies() entry, or stop removing the component. Startup.install on Android reads
dependencies() reflectively without consulting a StartupManifest, so it creates it there anyway.
A factory registered under a key it does not build names both classes and the remedy:
Cannot initialize Alpha. Its factory produced a Beta instead. A factory has to build the class its
key names: the product would be filed under the registered key here, while Startup.install on
Android ignores the factory and reflects the key, so one manifest would build two different
graphs. Register it under its own key.
Component names are fully qualified on Android, where the key is a java.lang.Class, and simple
elsewhere, because KClass.qualifiedName does not compile on Kotlin/JS.
StartupPlanner.validate(manifest) walks the whole graph without calling Initializer.create,
which makes a cycle or a missing registration a test failure rather than a launch failure. It does
construct every registered initializer, because reading dependencies() needs one.
A component that re-enters the runtime from inside its own create for something that leads back to
it is caught the same way, at the point of re-entry, rather than recursing until the stack dies.
sample is a real application on every target, not a compilation unit that only has to type check.
It boots the shared graph and prints what happened: which components are registered and how eagerly,
the order they were actually created in, the platform SDK CrashReportingInitializer started, and
the shared logger after an analytics.track("launch") call. Those lines come from SampleReport in
commonMain, so every entry point is one loop over the same list.
| Target | Command |
|---|---|
| Desktop, JVM | ./gradlew :sample:desktopRun |
| Android |
./gradlew :androidApp:installDebug and launch App Startup sample
|
| macOS | ./gradlew :sample:runDebugExecutableMacosArm64 |
| iOS simulator |
./gradlew :sample:iosSimulatorApp, then xcrun simctl install and launch
|
| Node, Kotlin/JS | ./gradlew :sample:jsNodeRun |
| Browser, JS | ./gradlew :sample:jsBrowserRun |
| Node, Wasm | ./gradlew :sample:wasmJsNodeRun |
| Browser, Wasm | ./gradlew :sample:wasmJsBrowserRun |
| macOS, x86-64 |
./gradlew :sample:runDebugExecutableMacosX64 (Rosetta) |
| Linux |
./gradlew :sample:linkDebugExecutableLinuxX64, then run it in an amd64 container |
| Windows |
./gradlew :sample:linkDebugExecutableMingwX64, then run it under Wine |
Every row above was executed on one Apple-silicon Mac. macosX64 runs through Rosetta. The Linux and
Windows binaries run inside a Linux VM, which is worth doing before a release because it is the only
local proof those two targets do more than link:
colima start --vm-type=vz --vz-rosetta
docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w ubuntu:24.04 \
./sample/build/bin/linuxX64/debugExecutable/sample.kexe
docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w -e HOME=/tmp -e WINEDEBUG=-all \
debian:bookworm bash -c 'apt-get update -qq && apt-get install -y -qq wine && \
wine sample/build/bin/mingwX64/debugExecutable/sample.exe'
Two targets link here but cannot be executed on this host, for reasons outside the project:
iosX64 needs an x86-64 simulator runtime. The installed iOS 26.5 runtime ships dyld_sim as arm64
only, so a Rosetta spawn aborts with could not use 'dyld_sim' because it is not a compatible arch.iosArm64 needs a physical device attached; a paired but disconnected iPhone reports
transport: None and cannot be targeted.iOS is a real app, installed and launched like any other. :sample:iosSimulatorApp links the
Kotlin/Native binary, lays out SampleApp.app around the Info.plist in sample/iosApp, and
ad-hoc signs it:
./gradlew :sample:iosSimulatorApp
xcrun simctl boot "iPhone 17 Pro Max"
xcrun simctl install booted sample/build/iosApp/SampleApp.app
xcrun simctl launch --console booted io.github.kunal26das.startup.sample.app
The app shows the report in a scrollable monospaced view and prints the same lines, so --console
gives the desktop output while the simulator shows the screen. Its entry point is the main in
iosMain, which hands control to UIApplicationMain; the other seven console targets share the
main in consoleMain, which prints and exits.
The two browser tasks start a webpack dev server on http://localhost:8080/ and never exit; stop them with Ctrl-C. They bind the same port, so run one at a time.
Not runnable on a macOS host. linuxX64 and mingwX64 link there and nowhere else: the
artifacts are an x86-64 ELF binary and a PE32+ executable, and running either on macOS fails with
exec format error. Link them locally, run them on Linux and Windows, which is what CI does.
macosX64, iosX64 and iosArm64 link as well and are not run either, for want of an x86-64 host
and a physical device.
androidApp is the one module that exists purely to be launched. sample stays a Kotlin
Multiplatform library, because its own AndroidManifest.xml and AndroidManifestParityTest are
what prove the shared initializers are declarable the AndroidX way; androidApp applies
com.android.application, depends on sample, and does nothing but display the report and mirror it
to Logcat under the StartupSample tag. It is not published.
Nothing in androidApp calls Startup.install. The components are created by
androidx.startup.InitializationProvider at process start, from the <meta-data> entries sample
contributes to the merged manifest, and the first line of the report is AndroidX's own
isEagerlyInitialized answering for that:
started by androidx.startup.InitializationProvider: true
Run the same sample on two platforms and the difference documented above is visible in the output: Android creates the graph in AndroidX's depth-first order and everything else in Kahn levels.
./gradlew build
./gradlew testAndroidHostTest desktopTest macosArm64Test iosSimulatorArm64Test jsNodeTest \
wasmJsNodeTest linkDebugTestLinuxX64 linkDebugTestMingwX64
build also links a debug executable for every Kotlin/Native target and assembles the Android
sample app, because sample declares binaries.executable() and androidApp is a real application
module. Three verification tasks run beside the tests:
:startup:checkObjCExport links Startup.framework for iosSimulatorArm64 and asserts on the
generated Objective-C header — the reified registration functions absent, the key-taking overloads
present, Context exported as StartupContext.:sample:checkConsumerObjCExport links two frameworks from sample, which is a consumer of the
library: one exports :startup and one does not. It asserts that the first carries the names the
Swift snippets above use and the second carries the Startup-prefixed ones with no
InitializerKeyKt, so the difference export(...) makes stays true.:startup:checkAndroidFloors unzips the published AAR and fails if minSdkVersion rises above 21
or minCompileSdk above 34.The first two need a macOS host and skip elsewhere; the third runs anywhere.
linuxX64Test, mingwX64Test, macosX64Test and iosX64Test are disabled on an arm64 Mac, so
linking their test binaries is the local proof. CI runs the first two for real on ubuntu-latest
and windows-latest; macosX64Test and iosX64Test run nowhere.
Apache-2.0. See LICENSE.
A Kotlin Multiplatform port of AndroidX App Startup.
On Android it is androidx.startup. Initializer, AppInitializer, Context and the class
token are typealiases of the AndroidX types, so an initializer written once in commonMain
compiles to implements androidx.startup.Initializer, is discovered by the same
InitializationProvider, and is instantiated by the same reflection. There is no wrapper type and
no adapter anywhere in the Android path.
On the other ten targets the library ships its own runtime. It computes the initialization order with Kahn's algorithm, executes it sequentially on the calling thread, and reports cycles as a trimmed path rather than as a bare "cycle detected".
android, desktop (JVM), iosArm64, iosSimulatorArm64, iosX64, macosArm64, macosX64,
linuxX64, mingwX64, js (browser and Node), wasmJs (browser and Node).
macosX64 and iosX64 are compiled and linked but never run: they are disabled on an arm64 Mac and
CI has no x86_64 macOS runner, so a green build is not evidence that their test binaries executed.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kunal26das:startup:3.0.0")
}
}
}Use api rather than implementation if an iOS host has to see these types, because a framework
can only export a dependency its source set declares with api; see From Swift and
Objective-C below.
The Android artifact depends on androidx.startup:startup-runtime with api scope, so an Android
consumer can implement Initializer without declaring AndroidX itself, and it publishes the same
two Android floors that dependency does: minSdk 21 and minCompileSdk 34. Adopting this
library never narrows the device range or forces a compileSdk move relative to plain
androidx.startup; :startup:checkAndroidFloors fails the build if either floor rises.
Two requirements come from the way the artifacts are compiled:
inline, and Kotlin refuses to inline
bytecode built for a newer JVM target than the caller's.One more requirement comes from the consumer's own code rather than from the artifacts. An
application that writes an expect class initializer, which is the shape below for anything
touching a platform SDK, needs
kotlin {
compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") }
}Kotlin still reports expect/actual classes as Beta, so without that flag every such initializer
emits a BETA_EXPECT_ACTUAL_CLASSES warning. It is a warning, not an error, and a consumer whose
initializers all live in commonMain and extend BaseInitializer needs no flag at all.
class LoggerInitializer : BaseInitializer<Logger>() {
override fun create(context: StartupContext): Logger = Logger()
}
class NetworkInitializer : Initializer<Network> {
override fun create(context: StartupContext): Network {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return Network(logger)
}
override fun dependencies(): List<AnyInitializerKey> =
listOf(initializerKey<LoggerInitializer>())
}BaseInitializer exists because neither half of an expect/actual pair may give a member a
default body. Extend it whenever an initializer has no dependencies and the dependencies()
override disappears.
Use StartupContext rather than Context in shared code. Both name the same type, but Context
collides with android.content.Context in any file that imports both.
Starting Crashlytics, a Cocoa reporter and a browser reporter are different calls, so declare the
initializer expect and give each platform its own actual. The class keeps one fully qualified
name on every target, so a single AndroidManifest <meta-data> entry addresses it whichever
actual is compiled in, and it registers in the same manifest as a shared initializer.
expect class CrashReportingInitializer() : Initializer<CrashReporting> {
override fun create(context: StartupContext): CrashReporting
override fun dependencies(): List<AnyInitializerKey>
}actual class CrashReportingInitializer actual constructor() : Initializer<CrashReporting> {
actual override fun create(context: StartupContext): CrashReporting {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return CrashReporting("AndroidCrashReporter(${context.packageName})", logger)
}
actual override fun dependencies(): List<AnyInitializerKey> =
listOf(initializerKey<LoggerInitializer>())
}The expect class has to redeclare every member it does not inherit a body for. Initializer
carries both of its members abstract, so a subclass that declares none is abstract; an expect that
omits them fails with "has no corresponding expected declaration" on the actual side.
Extend BaseInitializer and only create has to be redeclared. BaseInitializer supplies a
concrete dependencies(), so the expect names one member instead of two. This is the shape to
reach for unless the initializer really does declare dependencies:
expect class CrashReportingInitializer() : BaseInitializer<CrashReporting> {
override fun create(context: StartupContext): CrashReporting
}actual class CrashReportingInitializer actual constructor() : BaseInitializer<CrashReporting>() {
actual override fun create(context: StartupContext): CrashReporting =
CrashReporting("AndroidCrashReporter(${context.packageName})")
}Redeclaring create on the expect is what makes it an expected member, so the actual spells its
override actual override. Leave the actual off and the compiler answers Declaration must be
marked with 'actual'.
There is a shorter form, and it is not portable. Drop the body from the expect entirely and
create becomes an inherited abstract member rather than an expected one, so the actual overrides
it with a plain override and the expect never names it at all:
expect class CrashReportingInitializer() : BaseInitializer<CrashReporting>That compiles, links and runs on all eleven platform compilations. A metadata compilation rejects it:
e: CrashReportingInitializer.kt:5:8 Class 'CrashReportingInitializer' is not abstract and does not
implement abstract member:
expect fun create(context: Context): T
The task is compileCommonMainKotlinMetadata, and every KMP module with a shared commonMain has
one: anything published, anything running KSP in commonMain, anything relying on cinterop
commonization. ./gradlew build runs it; compileKotlin<Target>, compileAndroidMain and
link<Target> do not, so this shape can pass a target-by-target verification on all eleven and still
fail the next build. It is not a BaseInitializer quirk either — a plain abstract class with an
unimplemented abstract member behaves identically under an expect class, so nothing this library
could ship would change it. Reach for it only in a module you know has no metadata compilation, such
as a test source set.
Either shape compiles to implements androidx.startup.Initializer with a public no-argument
constructor and AndroidX's own dependencies() signature, so reflection is unaffected.
PlatformInitializer and MemberlessInitializer in the library's own commonTest pin both across
the eleven platform compilations and AndroidInitializerContractTest pins their bytecode; sample's
RuntimeInfoInitializer is what keeps this section honest, because it lives in a commonMain that
really is metadata-compiled. Delete its override fun create line and ./gradlew build goes red.
Every actual must be a class with a public no-argument constructor. AndroidX ignores the
factory in the manifest object and reflects with getDeclaredConstructor().newInstance(), so an
actual object compiles everywhere and throws only on Android, at process start. sample's
CrashReportingContractTest pins both halves of that contract with reflection.
Only the part that varies has to be expect. Where the initializer itself is shared and just needs
a per-platform value, keep one class in commonMain and put the seam behind a small expect — the
sample resolves its SDK name through expect object Platform, with actuals in appleMain,
desktopMain, jsMain, wasmJsMain, linuxMain and mingwMain. An expect may be declared in
an intermediate source set, so Platform lives in nonAndroidMain and Android never sees it.
val manifest = StartupManifest {
metaData<AnalyticsInitializer> { AnalyticsInitializer() }
lazyInitializer<NetworkInitializer> { NetworkInitializer() }
lazyInitializer<LoggerInitializer> { LoggerInitializer() }
}metaData registers a component that is initialized eagerly at startup, the equivalent of a
<meta-data android:value="androidx.startup" /> entry. lazyInitializer registers one that is
created only when something asks for it. remove<T>() is the equivalent of tools:node="remove"
and hides an entry an included manifest contributed.
Manifests compose, later entries winning, so a library can ship one that an application overrides:
val applicationManifest = StartupManifest {
include(libraryManifest)
remove<LibraryDebugInitializer>()
metaData<ApplicationInitializer> { ApplicationInitializer() }
}On Android remove<T>() only suppresses what Startup.install would otherwise start. A component a
library contributed through its own AndroidManifest is created by InitializationProvider before any
application code runs, so nothing here can reach it; suppressing that needs a real
tools:node="remove" entry in the application's AndroidManifest, written by hand.
metaData<T>, lazyInitializer<T> and remove<T>() name the component at compile time. Each also
has an overload that takes the key instead, for an initializer the compiler cannot name: one a host
application constructed and handed to Kotlin, one discovered from a plugin, or one written in Swift.
initializerKey(initializer) builds the key from the instance, and it is the same key
initializerKey<T>() would have reified:
fun manifest(supplied: List<Initializer<*>>): StartupManifest = StartupManifest {
metaData<AnalyticsInitializer> { AnalyticsInitializer() }
for (initializer in supplied) metaData(initializerKey(initializer)) { initializer }
}Registered that way, a host-supplied initializer is an ordinary node: it is ordered behind whatever
it declares in dependencies(), other components may depend on it, it is created once, and a cycle
or a missing registration around it is diagnosed like any other. Running such initializers outside
the graph, before Startup.install, gives up all four.
Startup.install(context, manifest)
val analytics = Startup.getInstance(context)
.initializeComponent(initializerKey<AnalyticsInitializer>())On Android the AndroidManifest is the primary way to start components: InitializationProvider
reads it before any application code runs, exactly as it does for a pure AndroidX app. Declare the
eager components there and nothing else is needed at boot. sample/src/androidMain/AndroidManifest.xml
is a worked example, and AndroidManifestParityTest fails the build if it drifts from
SampleStartup.manifest.
Startup.install remains available as the programmatic equivalent, and is the only route on the
other ten targets.
Each eager component is one <meta-data> line inside the provider block. Write those lines by hand,
exactly as a plain androidx.startup application does:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.AnalyticsInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>InitializationProvider resolves each name with Class.forName, so every one has to be fully
qualified. The xmlns:tools declaration is required as soon as a tools:node="remove" entry is in
the block.
AndroidX answers isEagerlyInitialized from what InitializationProvider discovered in the
AndroidManifest, so a component started only by Startup.install still reports false there.
Declaring the component in the manifest is what makes the two agree.
This is the one failure this library can produce that plain androidx.startup cannot, and it is
worth naming. On Android the StartupManifest factories are never called: InitializationProvider
reads the AndroidManifest and nothing else. So a component registered with metaData<T> and left
out of the XML runs correctly on ten targets and, in an app that relies on
InitializationProvider alone, silently never runs on the eleventh. Startup.install
does start it on Android, but isEagerlyInitialized still reports false for it. There is no
exception, no log and no lint check, because from AndroidX's point of view nothing is wrong.
Until 1.1.0 the library answered that itself, with verifyAndroidManifest(context),
androidManifestDrift(context) and androidManifestDrift(declared). 2.0.0 removes all three:
androidx.startup has no counterpart for any of them, and mirroring androidx.startup is this
library's whole contract. The problem has not gone away with them, so read the rest of this
section rather than treating the removal as a fix. The answer from 2.0.0 is the one a plain
androidx.startup application already uses, and it is two things.
The AndroidManifest is the source of truth on Android. Write its <meta-data> entries by hand,
declare there exactly what should start eagerly, and treat the StartupManifest as the registry for
the other ten targets. Nothing in the library reconciles the two for you.
Keep a parity test of your own. It is a dozen lines against the API that is staying, and it goes
in an Android source set because that is where a key is a java.lang.Class and can name its
component the way Class.forName needs it named:
class AndroidManifestParityTest {
@Test
fun theManifestDeclaresExactlyTheEagerComponents() {
val xml = File(System.getProperty("myapp.androidManifest")!!).readText()
val declared = Regex("<meta-data[^>]*?android:name=\"([^\"]+)\"", RegexOption.DOT_MATCHES_ALL)
.findAll(xml)
.map { it.groupValues[1] }
.toSet()
assertEquals(manifest.eagerComponents.map { it.name }.toSet(), declared)
}
}Both directions matter. A component eager in the StartupManifest and absent from the XML never
runs on Android; one the XML declares while the StartupManifest keeps it lazy or removed runs
eagerly on Android alone. A name the XML declares that the StartupManifest has never heard of is
your call rather than the library's: a mixed application is free to declare initializers written
directly against androidx.startup beside these, and a test that reports those is useless in exactly
the applications that need one most. The set comparison above treats such a name as a failure, so
subtract them, or compare only the names the StartupManifest knows.
Put the XML on the test task's inputs, or the check stops running. A test that reads a file
Gradle does not know about stays UP-TO-DATE when only that file changes, so deleting a
<meta-data> line leaves the build green and the drift undetected — which is the very failure the
check exists to catch:
tasks.withType<Test>().configureEach {
val manifest = layout.projectDirectory.file("src/androidMain/AndroidManifest.xml")
inputs.file(manifest)
.withPropertyName("androidMainManifest")
.withPathSensitivity(PathSensitivity.RELATIVE)
systemProperty("myapp.androidManifest", manifest.asFile.absolutePath)
}sample's own AndroidManifestParityTest is exactly the test above, run against a real
AndroidManifest.xml and a real StartupManifest on every build. Copy it. Its negative control is
the whole point: delete a <meta-data> line from sample/src/androidMain/AndroidManifest.xml and
./gradlew :sample:testAndroidHostTest fails, naming the component that would have stopped running
on Android alone.
Android-only source sets can keep using the verbatim AndroidX spelling against the very same components:
androidx.startup.AppInitializer.getInstance(context)
.initializeComponent(AnalyticsInitializer::class.java)There is no manifest merger and no ContentProvider, so Startup.install is the only registration
step. Pass DefaultContext when your initializers need nothing from the platform:
Startup.install(DefaultContext, manifest)Initialization runs sequentially on the calling thread, and every entry point is serialized behind
one reentrant lock, which is what AndroidX gets from synchronized (sLock): a component is created
exactly once however many threads ask for it, and an Initializer.create may call back into
initializeComponent without deadlocking. The engine holds no coroutine of its own, because
runBlocking does not exist on Kotlin/JS or Kotlin/Wasm; a component whose own work suspends says
so with CoroutineInitializer, see Initializing something that suspends below.
StartupPlan.waves exposes the Kahn levels as data, and
Startup.install(context, manifest, runner) hands each level to a WaveRunner of your choosing —
see Running a wave concurrently below for what a task may not do.
AppInitializer.initializeComponentOrNull(key) is the read for a component whose key is an
AnyInitializerKey — the element type of dependencies(), what initializerKey(initializer)
returns, and the only key a host that discovered an initializer at run time can build — and for one
whose create returned null. initializeComponent takes neither: its key is
InitializerKey<out Initializer<T>> and its T is bound to Any.
AppInitializer is the same two members here that it is on Android. Until 1.1.0 it carried three
more off Android — isInitialized(component), initializationOrder() and manifest() — and
2.0.0 removes them. On Android AppInitializer is androidx.startup.AppInitializer, which
exposes neither the order it created things in nor whether a given component exists, and keeps no
accessible state to derive either from, so androidx.startup had no counterpart to mirror and those
three were the only members of the API that ten targets had and the eleventh did not.
Record what you need from inside your own create, which is what sample's SampleReport does:
class NetworkInitializer : Initializer<Network> {
override fun create(context: StartupContext): Network {
val logger = Startup.getInstance(context)
.initializeComponent(initializerKey<LoggerInitializer>())
return Network(logger).also { logger.ready("network") }
}
override fun dependencies(): List<AnyInitializerKey> = listOf(initializerKey<LoggerInitializer>())
}That reads the same on all eleven targets, which the removed members never could. For the manifest,
hold on to the StartupManifest you passed to Startup.install: it is an ordinary value, and
components, eagerComponents, isEager and in all still answer from it. See Upgrading from
1.x below.
Export the library from your framework first. implementation("io.github.kunal26das:startup")
is enough for Kotlin and is not enough for Swift. A dependency module that a framework does not
export has its module name mangled into every class the header does carry, and the declarations that
appear in no exported signature are dropped from it altogether. Add both lines below, or the Swift
snippets in this section name types that do not exist:
kotlin {
sourceSets.commonMain.dependencies {
api("io.github.kunal26das:startup:3.0.0")
}
listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach {
it.binaries.framework {
baseName = "Shared"
export("io.github.kunal26das:startup:3.0.0")
}
}
}A build whose targets are declared elsewhere, in a convention plugin, reaches the same frameworks without naming them:
import org.jetbrains.kotlin.gradle.plugin.mpp.Framework
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
kotlin {
targets.withType<KotlinNativeTarget>().configureEach {
binaries.withType<Framework>().configureEach {
export("io.github.kunal26das:startup:3.0.0")
}
}
}export requires api. Left on implementation, the build fails with dependencies exported in
the framework are not specified as API dependencies of a corresponding source set.
You may already have it without writing the line. A framework with transitiveExport = true that
exports a module which declares api("io.github.kunal26das:startup:3.0.0") exports this library too,
which is what a convention plugin that exports a shared core module typically produces. Check the
generated header for swift_name("InitializerKeyKt") before adding anything: if it is there, the
export is already in place.
This is what a consumer's own framework header contains either way. It is measured rather than
predicted: :sample:checkConsumerObjCExport links one framework each way and greps both.
| declaration | with export(...)
|
without it |
|---|---|---|
Initializer |
Initializer |
StartupInitializer |
Context |
StartupContext |
StartupStartupContext |
InitializerKey |
InitializerKey |
StartupInitializerKey |
StartupManifest |
StartupManifest |
StartupStartupManifest |
initializerKey() |
InitializerKeyKt |
absent entirely |
DefaultContext |
DefaultContext |
absent entirely |
The last two rows are the ones that bite. A top-level function facade appears in no exported
signature, so InitializerKeyKt never reaches the header at all: a Swift class can conform to
StartupInitializer and can never build a key, which leaves its dependencies() able to return only
[]. Swift reports cannot find type 'InitializerKey' in scope and says nothing about export.
With the export in place, Startup, StartupManifest, StartupManifestBuilder, AppInitializer,
Initializer, BaseInitializer, InitializerKey, InitializerKeyKt, StartupContext and
DefaultContext all arrive under those names, so an iOS host can build a manifest and implement an
initializer in Swift. Two things follow from the fact that a reified type argument cannot cross
that boundary:
reified registration functions are hidden from the header. metaData<T>,
lazyInitializer<T>, remove<T>() and initializerKey<T>() carry @HiddenFromObjC, because
Kotlin/Native would otherwise export their non-inline bodies with T collapsed to Initializer<*>
— four Swift-callable methods that compile, run, and register every call site under one key.metaData(component:factory:),
lazyInitializer(component:factory:), remove(component:) and initializerKey(initializer:) are
exported and do what they say:func boot(_ application: UIApplication) throws {
let manifest = StartupManifest.companion.invoke { builder in
let lifecycle = ViewControllerLifecycleInitializer()
builder.metaData(component: InitializerKeyKt.initializerKey(initializer: lifecycle)) { lifecycle }
}
try Startup.shared.install(context: DefaultContext.shared, manifest: manifest)
}install is try from 3.0.0; see Every entry point carries @Throws below.
The Swift name of Context is StartupContext. A Kotlin typealias does not survive the
Objective-C export, so the class itself carries @ObjCName("StartupContext"). That keeps the Swift
name equal to the alias this README already tells Kotlin authors to prefer, and it keeps a bare
Context out of the framework's namespace — which matters, because UIViewControllerRepresentable
declares a Context of its own and a framework-level one shadows it in the iOS host file every
Compose Multiplatform app has, with a does not conform to protocol error that never mentions the
name.
Name any initializer from Swift with initializerKey(initializer:). It is the only key overload
Swift can reach in practice — a reified type argument cannot cross the boundary, and although
initializerKey(kClass:) is exported, a KotlinKClass is not obtainable from Swift to pass it. It
works the same whether the initializer was written in Kotlin or in Swift:
final class HostInitializer: NSObject, Initializer {
func create(context: StartupContext) -> Any? { ... }
func dependencies() -> [InitializerKey<Initializer>] {
[InitializerKeyKt.initializerKey(initializer: KoinInitializer())]
}
}KoinInitializer there is a Kotlin class, and naming it this way constructs one. Be clear-eyed
about that: initializerKey(objCClass:), removed in 2.0.0, took the class object and constructed
nothing, so a Swift dependencies() that names three Kotlin components now runs three constructors
that the returned keys then throw away, once per call. It is free for an initializer that holds
nothing in its constructor and does its work in create, which is what this library asks of every
initializer anyway: AndroidX builds each one reflectively through
getDeclaredConstructor().newInstance(), at a moment the author does not choose. It is not free for
one whose constructor has a side effect, and such an initializer should not have one. If the cost is
real for you, hoist the keys into a let computed once rather than rebuilding them per
dependencies() call. See Upgrading from 1.x below.
Every entry point carries @Throws(StartupException::class). Without it Kotlin/Native does not
propagate an exception to Swift as an NSError: it prints Function doesn't have or inherit
@Throws annotation and thus exception isn't propagated and terminates the process, so a diagnosable
launch failure arrived as an abort. Startup.install, Startup.getInstance,
AppInitializer.initializeComponent, AppInitializer.initializeComponentOrNull,
AppInitializer.isEagerlyInitialized, StartupTask.invoke, StartupPlanner.plan and
StartupPlanner.validate are all try in Swift now. :startup:checkObjCExport asserts the
NSError parameter on every one of them, so a dropped annotation fails the build rather than the
app.
A Swift WaveRunner has one spelling that compiles, because the task list crosses as an array
of Objective-C objects:
final class ConcurrentWaveRunner: NSObject, WaveRunner {
func run(wave: [StartupTask]) {
DispatchQueue.concurrentPerform(iterations: wave.count) { index in
do { try wave[index].invoke() } catch { }
}
}
}A Swift runner cannot rethrow, and does not have to. run is exported without an error
parameter, so a throws conformance does not compile — Swift reports candidate throws, but
protocol does not allow it. Catching and dropping is correct here: the task recorded the failure
before it reached you, and the engine re-raises it as a StartupException naming the component once
run returns. That is why the contract below says a runner must let a failure out where the
language allows.
concurrentPerform runs some iterations on the calling thread and some on workers, which is worth
knowing: an iteration that lands on the calling thread may resolve another component and one that
lands on a worker may not, so a runner written this way must not touch AppInitializer at all.
:startup's checkObjCExport task links Startup.framework and asserts on the generated header,
and :sample's checkConsumerObjCExport does the same for the two frameworks a consumer gets, so
neither the export shape nor the recipe above can regress unnoticed.
Everything in the API mapping below behaves the same on all eleven targets. Six things do not, and the first four are cases where code written and tested on Android would misbehave elsewhere.
StartupPlanner emits Kahn levels, so the two pick different valid
topological orders. Anything that must run before something else has to say so in dependencies().initializeComponent succeeds for a component that no StartupManifest
registers and for one registered with remove<T>(). Off Android the manifest is the only registry
there is, and both throw StartupException.StartupException
with a components path. On Android the failure comes out of AndroidX as
androidx.startup.StartupException, with the message Cannot initialize <FQCN>. Cycle detected.
and no path. StartupPlanner.validate(manifest) is the way to get this library's diagnostics on
Android too.commonMain, so it fires on every target where the graph is planned — but
Startup.install on Android never calls a factory, so there it fires only under
StartupPlanner.validate(manifest). An Android app that never validates keeps running, on the
class AndroidX reflected rather than the one the factory would have built.Initializer<T> leaves T unbounded and the
Objective-C export gives a Swift author Any? to return, so create may hand back null; the
engine stores it, initializeComponentOrNull returns it, and initializeComponent names the
component rather than failing a cast. On Android androidx.startup.Initializer.create is
@NonNull, so such an initializer does not compile at all — an Android-compatible component with
no product is an Initializer<Unit> that returns Unit. It is the same constraint that bounds
CoroutineInitializer<T : Any>.StartupManifest is the whole registry. On Android
it is the AndroidManifest, and the factories in the StartupManifest are never called. That is
the one failure mode adopting this library adds. The AndroidManifest is the source of truth there
and a parity test of your own is the answer to it; see Keep the two Android registries in step
above.By default both runtimes create one component at a time on the calling thread. Everything in a
StartupPlan wave depends only on earlier waves, though, so a wave is safe to run all at once, and
Startup.install(context, manifest, runner) hands each one to a WaveRunner in turn:
Startup.install(context, manifest) { wave ->
runBlocking { coroutineScope { wave.map { async(startupDispatcher) { it() } }.awaitAll() } }
}The dispatcher there is the whole point. A task is an ordinary blocking call rather than a
suspending one, so async { } without a dispatcher inherits runBlocking's single-threaded event
loop and runs the wave one task after another on the calling thread — which is exactly what
Startup.install(context, manifest) already does, at the cost of a runner that looks concurrent.
Give it a dispatcher of its own rather than reaching for Dispatchers.Default. A task blocks the
thread it runs on for as long as its component takes, so a wave of CoroutineInitializers
dispatched onto the pool their own createAsync resumes on starves that pool and hangs the install;
see Initializing something that suspends. Dispatchers.IO is elastic on both the JVM and
Kotlin/Native and is the safe default here.
The library keeps the ordering, the cycle detection, the deduplication and the created components;
the concurrency is yours. install waits for each wave before planning the next, so run must
invoke every task exactly once, must let a task's failure out rather than swallowing it, and must
not return until all of them have finished. A second invocation of a task is refused at the call, by
StartupTask.invoke itself; the other two are checked once run returns. Either way the violation
is a StartupException naming the components it applies to rather than a component quietly filed as
null.
A task is a StartupTask, and it names the component it will create. task.component is the
key and task.toString() is the component's name, so a runner can route a wave rather than merely
run it — the one component that has to stay on the calling thread dispatched differently from the
rest — and can attribute a slow or failed wave to a component. It is also where a host hangs its own
tracing, because the engine has no hook of its own:
Startup.install(context, manifest) { wave ->
runBlocking {
coroutineScope {
wave.map { task ->
async(if (task.component == mainThreadOnly) Dispatchers.Main else Dispatchers.IO) {
trace(task.toString()) { task() }
}
}.awaitAll()
}
}
}A task may call AppInitializer.initializeComponent only from the thread that called install,
and only for what an earlier wave already created. The lock is held across the whole install and it
is reentrant, so a runner that stays on the calling thread — which is every runner on Kotlin/JS and
Kotlin/Wasm — may read a component an earlier wave built. From any other thread that call can never
be served, and it now fails immediately with a StartupException saying so rather than waiting on a
lock the installing thread cannot release until run returns.
A component of the wave being run right now is refused too, on every thread. Nothing a wave creates
is written back until run returns, so a sibling is neither created nor creatable from inside one —
and it is refused by name rather than reported as a cycle, because two components share a wave
precisely when neither declares the other, so there is no cycle to draw. A component that asks for
itself inside a wave gets the same refusal, which says so; without a runner that one is still
reported as the cycle it is. Declaring the edge in
dependencies() is what makes the call safe from anywhere, because that is what puts the dependency
in a strictly earlier wave. That is what makes sample's own manifest safe under a runner:
NetworkInitializer and AnalyticsInitializer resolve what they need imperatively and declare the
same edges, which is the AndroidX-documented pattern, so each of them only ever reads back a
component an earlier wave already built.
On Android the runner is ignored — androidx.startup creates each component itself, depth first
on the calling thread, and offers no seam to change it. For ordinary Initializers a runner is
therefore a performance decision on the other ten targets and never a correctness one, so anything
that must run before something else still has to say so in dependencies().
For a CoroutineInitializer it is a correctness decision. create blocks the thread it is
called on until createAsync finishes, and the runner is what picks that thread: on Android it is
always InitializationProvider's, which is the main thread, while off Android a runner can move it
to a worker or leave it on whoever called install. Which thread that is decides whether the
component's own dispatching can make progress — see Initializing something that suspends.
You can also skip AppInitializer for the concurrent part entirely: plan with
StartupPlanner.plan(manifest, roots, satisfied), read plan.waves, construct your own initializers
— you wrote the factories — call create(context) on a level in parallel, and hold the results
yourself. That is a fork in the road rather than a seam, and it is worth measuring first: an
initializer that hands its work to a background scheduler and returns immediately costs the same
either way.
The graph's promise is that a dependency is created before the component that declares it. For a
component whose real work is a suspend call — which is nearly every mobile SDK — a plain
Initializer can only keep half of it: create has nowhere to await, so the idiomatic escape is to
launch the work and return, and the graph then orders the launches rather than the completions. A
dependency edge that does not wait is not a dependency edge.
CoroutineInitializer is where a component says its work suspends:
class FirebaseInitializer : CoroutineInitializer<Unit> {
override suspend fun createAsync(context: StartupContext) = Firebase.start(context)
}create is inherited and blocks the calling thread until createAsync returns, so anything
declaring this component in dependencies() starts after it has finished rather than after it has
begun. Four consequences follow, and every one of them is yours to accept:
InitializationProvider runs on, which is
the main thread; elsewhere it is whoever called Startup.install, unless a WaveRunner moved the
wave. A component that must not block startup should still launch and return, and say so by
staying an ordinary Initializer.createAsync that dispatches to the main
dispatcher, from the main thread, deadlocks. That is the ordinary runBlocking hazard and this
type does not change it.create holds a worker of the pool the
runner sent it to; if createAsync then needs a worker of that same pool to resume, a wave with
as many such components as the pool has parallelism starves. Measured: twenty components whose
createAsync does withContext(Dispatchers.Default), run by a Dispatchers.Default runner on an
18-core machine, entered 18 bodies, resumed none, and never returned. Three of the same components
finish; so do twenty that only delay. Give the runner a dispatcher the components do not use.createAsync. A wave task that calls
AppInitializer.initializeComponent is refused at once, but only on the thread the task body runs
on. createAsync switching dispatchers moves the call to a thread the guard does not know about,
where it waits for a lock the install cannot release — with or without a runner. Declare the edge
in dependencies(); resolving a component from inside createAsync is not supported.CoroutineInitializer is a Kotlin-side type. Kotlin interface default bodies do not become
Objective-C protocol defaults, so a Swift class conforming to it inherits nothing and must write the
blocking create(context:) itself. A Swift initializer that has to await should implement
Initializer directly and do its own waiting.
CoroutineInitializer<T : Any> bounds T to Any, because androidx.startup.Initializer declares
create @NonNull and this interface implements it on Android like any other.
Kotlin/JS and Kotlin/Wasm have one thread and no way to park it, so there is nothing for create to
block and it throws a StartupException that says exactly that. Those two targets run every
ordinary Initializer as before; it is only the blocking bridge that has nowhere to stand.
androidx.startup |
io.github.kunal26das.startup |
On Android |
|---|---|---|
androidx.startup.Initializer<T> |
Initializer<T> |
typealias |
android.content.Context |
Context, StartupContext
|
typealias |
Class<out Initializer<*>> |
InitializerKey<T>, AnyInitializerKey
|
typealias to java.lang.Class
|
MyInitializer::class.java |
initializerKey<MyInitializer>() |
inlines to a class constant |
instance.getClass() |
initializerKey(instance) |
::class.java on the instance |
Class.forName(name) |
initializerKey(kClass) |
::class.java on the KClass |
androidx.startup.AppInitializer |
AppInitializer |
typealias |
AppInitializer.getInstance(context) |
Startup.getInstance(context) |
delegates to the static |
AppInitializer.initializeComponent(component) |
AppInitializer.initializeComponent(component) |
same method |
AppInitializer.isEagerlyInitialized(component) |
AppInitializer.isEagerlyInitialized(component) |
same method |
<meta-data android:value="androidx.startup" /> |
StartupManifest { metaData<T> { T() } } |
still the manifest |
| no equivalent | StartupManifest { metaData(key) { it } } |
still the manifest |
tools:node="remove" |
StartupManifest { remove<T>() } |
still the manifest |
InitializationProvider.onCreate() |
Startup.install(context, manifest) |
eagerly initializes |
| no equivalent | Startup.install(context, manifest, runner) |
runner ignored |
| no equivalent |
WaveRunner, StartupTask
|
never called |
| no equivalent | AppInitializer.initializeComponentOrNull(key) |
delegates; null not expressible |
| no equivalent | CoroutineInitializer<T : Any> |
blocks the provider's thread |
androidx.startup.StartupException |
StartupException |
not a typealias
|
StartupException is deliberately our own type. AndroidX annotates its exception
@RestrictTo(LIBRARY), so aliasing it would make every consumer's catch clause fail lint's
error-severity RestrictedApi check. Failures raised by AndroidX itself still arrive as AndroidX's
own type on Android, exactly as they do in an app that uses androidx.startup directly.
There is no expect companion object on AppInitializer because a Java static has no member for
one to match, which is why Startup exists.
Until 1.1.0 this table carried eight more rows, each of them a declaration androidx.startup
genuinely had no counterpart for, and each of them a platform asymmetry: four existed for Android's
manifest and did nothing useful off it, three existed on the other ten targets and not on Android,
one existed on the Apple targets alone. 2.0.0 removes all eight, so the table now has one shape
on all eleven targets. See Upgrading from 1.x below.
One no equivalent row survives, StartupManifest { metaData(key) { it } }, and it was never in
that group. It is the registration androidx.startup performs by name in XML, expressed as a key,
which makes it closer to androidx.startup than the reified overload beside it, and it is the
only way Swift or a plugin host can register an initializer the compiler cannot name.
2.1.0 tightens one rule. A factory must now build exactly the class its key names;
2.0.0 accepted a subclass and filed it under the supertype. That never worked on Android,
where AndroidX ignores the factory and reflects the key, so metaData<Base> { Derived() }
started Base on Android and Derived everywhere else from one manifest. Register the
subclass under its own key. Because the covariance of Kotlin function types lets the
reified overloads express the mismatch too, this affects metaData<T> { ... } and
lazyInitializer<T> { ... } as well as the key-taking overloads.
The rejection lives in commonMain and fires wherever the graph is planned, Android
included: Startup.install never calls a factory there, so it does not surface it, but
StartupPlanner.validate(manifest) does, on all eleven targets.
2.1.0 also adds Startup.install(context, manifest, runner) and the WaveRunner it takes,
which run each wave of the plan however the host wants rather than on the calling thread; see
Running a wave concurrently. It is ignored on Android, and nothing else published in 2.0.0
changed.
3.0.0 changes what a WaveRunner receives. run(wave: List<() -> Unit>) is now
run(wave: List<StartupTask>). A task is still invoked the same way — it() — so a runner written
as wave.map { async { it() } } needs no edit; one that named the type of its parameter does. What
the change buys is that a task now names its component, which is what makes routing, tracing and an
attributable failure possible at all.
3.0.0 enforces the WaveRunner contract it always documented. A runner that runs a task twice
is refused at the second call; one that skips a task or catches a task's failure and returns anyway
is a StartupException naming the components once run returns, instead of a component filed as
null that fails much later somewhere else. A wave that
fails now also keeps the components that succeeded beside the failure, which is what the sequential
path always did.
3.0.0 fails fast instead of waiting for a lock that cannot be released. A wave task that calls
AppInitializer.initializeComponent from a thread other than the installing one now throws
immediately. It used to park on the JVM and spin without yielding on Kotlin/Native, where it cost a
core for as long as the install ran and never ended. A task on the installing thread still works for
anything an earlier wave created, as it always did — the flat prohibition in the 2.x README was
wrong about that half — but not for a component of the wave in flight, which is refused by name. A
thread that is not running a task is unaffected: its wait for the install to finish really does end,
so it still waits.
StartupTask's constructor is public, so a runner can still be exercised against a wave the test
built rather than only through Startup.install with a real manifest.
3.0.0 adds AppInitializer.initializeComponentOrNull(key), the read for an AnyInitializerKey
and for a component whose create returned null. Both were previously unreadable: the key type did
not fit initializeComponent, and a null product failed its cast with a bare
NullPointerException raised inside the library. That failure is now a StartupException naming
the component.
3.0.0 adds CoroutineInitializer, and with it a dependency on kotlinx-coroutines-core for
the Android, JVM and native artifacts. See Initializing something that suspends.
3.0.0 annotates the public API with @Throws(StartupException::class), which is source-breaking
for Swift: a call that could fail now needs try. Kotlin callers are unaffected.
2.0.0 removes the eight declarations 1.1.0 deprecated, and nothing else. Each carried
DeprecationLevel.WARNING in 1.1.0, so a consumer that took the warnings has nothing left to do;
one upgrading straight from 1.0.0 gets errors instead, and the table below is the whole list.
The reason is the same for all eight. None of them had a counterpart in androidx.startup, and
mirroring androidx.startup is this library's whole contract — for Android that contract is literal,
because Initializer, AppInitializer, Context and the key are typealiases of the AndroidX
types. They were also the only platform asymmetry in the API mapping table: three existed on the ten
non-Android targets and not on Android, four existed for Android's manifest and did nothing useful
off it, and one existed on the Apple targets alone.
| removed in 2.0.0 | replacement |
|---|---|
AppInitializer.isInitialized(component) |
record it from inside your own Initializer.create
|
AppInitializer.initializationOrder() |
record it from inside your own Initializer.create
|
AppInitializer.manifest() |
keep the StartupManifest you passed to Startup.install
|
manifest.androidManifestMetadata() |
write the <meta-data> entries by hand |
manifest.androidManifestDrift(declared) |
keep a parity test of your own |
manifest.androidManifestDrift(context) |
keep a parity test of your own |
manifest.verifyAndroidManifest(context) |
keep a parity test of your own |
initializerKey(objCClass) |
initializerKey(initializer), from an instance |
The first three. sample's SampleReport is the worked example. The initialization order it
prints is a list each component appends to from inside its own create, which is exactly why the
report reads the same on all eleven targets instead of on ten. isInitialized has no direct
replacement and does not need one: asking for a component that already exists returns it without
running create again, so there is nothing to guard. manifest() handed back the value you passed
to Startup.install — keep it in a val, and components, eagerComponents, isEager and in
all still answer from it.
The Android four. The problem they addressed has not gone away. The StartupManifest and the
AndroidManifest really are two registries and only Android reads the second, so a component in one
and missing from the other still misbehaves on exactly one platform. What changes is who owns the
answer: from 2.0.0 the AndroidManifest is the source of truth on Android, its <meta-data> entries
are written by hand as a plain androidx.startup application writes them, and a consumer that wants
the two held in step keeps its own test. Keep the two Android registries in step above has one,
in a dozen lines, against the API that is staying, and sample's AndroidManifestParityTest runs
exactly it on every build. The generated <meta-data> block is also gone, so paste the lines once
from that test's failure message or write them out; they are three lines for three eager components
and they change about as often as the components do.
The Apple one. initializerKey(objCClass:) was the only Apple-only declaration in the API.
initializerKey(initializer:) needs an instance, so naming a component from Swift now constructs a
throwaway one and runs its constructor — a cost the class-object overload did not have, and the one
genuine regression in this release. It bites only an initializer whose constructor does something,
and none should: AndroidX builds every initializer reflectively through
getDeclaredConstructor().newInstance(), at a moment the author does not choose, so the work belongs
in create.
Nothing else changes. Every other declaration published in 1.1.0 stays exactly as it was, the
key-taking registration overloads included — metaData(component, factory),
lazyInitializer(component, factory), remove(component), initializerKey(initializer) and
initializerKey(kClass). They are not deprecated and are not going anywhere: androidx.startup's
own registration is by name in XML, so a key-taking overload is closer to it than a reified one,
and without them Swift cannot register a host-supplied initializer at all.
Register factories, never instances, and let every factory construct its initializer with no arguments:
val manifest = StartupManifest {
metaData<NetworkInitializer> { NetworkInitializer() }
}A factory that passes constructor arguments compiles, and works on all ten non-Android targets:
val manifest = StartupManifest {
metaData<NetworkInitializer> { NetworkInitializer(httpClient) }
}On Android it throws. AndroidX never calls the factory: InitializationProvider and
AppInitializer build every initializer with getDeclaredConstructor().newInstance(), so a class
without a public no-argument constructor fails at runtime with
StartupException(NoSuchMethodException). Take what an initializer needs from create(context),
or from another component resolved through initializeComponent.
For the same reason the factory is never used to construct the initializer twice: it is called at
most once per run, and only off Android. StartupPlanner.plan and
StartupPlanner.validate also call it, on every target including Android, because reading
dependencies() needs an instance.
These diagnostics come from StartupPlanner, which is what runs on the ten non-Android targets. On
Android the initialization itself is AndroidX's, so a failure there arrives as
androidx.startup.StartupException instead. StartupPlanner.validate(manifest) is commonMain
code and runs everywhere, including in an Android unit test, so it is how to get the diagnostics
below on every platform.
A cycle names the component the walk re-entered and prints the cycle itself, not the acyclic path
that led to it. For Entry -> LoopHead -> LoopTail -> LoopHead:
Cannot initialize LoopHead. Cycle detected: LoopHead -> LoopTail -> LoopHead
A path longer than twelve components is elided in the middle. The same path is available as data on
StartupException.components, first element repeated last, so a test can assert on it instead of
matching text:
val exception = assertFailsWith<StartupException> { StartupPlanner.validate(manifest) }
assertEquals(
listOf(
initializerKey<LoopHeadInitializer>(),
initializerKey<LoopTailInitializer>(),
initializerKey<LoopHeadInitializer>(),
),
exception.components,
)A dependency nobody registered names both ends and the remedy:
Cannot initialize Orphan. No initializer is registered for it, required by OrphanDependent.
Register it in a StartupManifest with metaData or lazyInitializer, then install that manifest
with Startup.install(context, manifest).
A component a remove<T>() entry hides says so, rather than claiming nobody registered it, so the
remedy on offer is not to re-register the entry the application took out:
Cannot initialize Alpha. A remove() entry hides it, and Beta still declares it as a dependency.
Drop that dependencies() entry, or stop removing the component. Startup.install on Android reads
dependencies() reflectively without consulting a StartupManifest, so it creates it there anyway.
A factory registered under a key it does not build names both classes and the remedy:
Cannot initialize Alpha. Its factory produced a Beta instead. A factory has to build the class its
key names: the product would be filed under the registered key here, while Startup.install on
Android ignores the factory and reflects the key, so one manifest would build two different
graphs. Register it under its own key.
Component names are fully qualified on Android, where the key is a java.lang.Class, and simple
elsewhere, because KClass.qualifiedName does not compile on Kotlin/JS.
StartupPlanner.validate(manifest) walks the whole graph without calling Initializer.create,
which makes a cycle or a missing registration a test failure rather than a launch failure. It does
construct every registered initializer, because reading dependencies() needs one.
A component that re-enters the runtime from inside its own create for something that leads back to
it is caught the same way, at the point of re-entry, rather than recursing until the stack dies.
sample is a real application on every target, not a compilation unit that only has to type check.
It boots the shared graph and prints what happened: which components are registered and how eagerly,
the order they were actually created in, the platform SDK CrashReportingInitializer started, and
the shared logger after an analytics.track("launch") call. Those lines come from SampleReport in
commonMain, so every entry point is one loop over the same list.
| Target | Command |
|---|---|
| Desktop, JVM | ./gradlew :sample:desktopRun |
| Android |
./gradlew :androidApp:installDebug and launch App Startup sample
|
| macOS | ./gradlew :sample:runDebugExecutableMacosArm64 |
| iOS simulator |
./gradlew :sample:iosSimulatorApp, then xcrun simctl install and launch
|
| Node, Kotlin/JS | ./gradlew :sample:jsNodeRun |
| Browser, JS | ./gradlew :sample:jsBrowserRun |
| Node, Wasm | ./gradlew :sample:wasmJsNodeRun |
| Browser, Wasm | ./gradlew :sample:wasmJsBrowserRun |
| macOS, x86-64 |
./gradlew :sample:runDebugExecutableMacosX64 (Rosetta) |
| Linux |
./gradlew :sample:linkDebugExecutableLinuxX64, then run it in an amd64 container |
| Windows |
./gradlew :sample:linkDebugExecutableMingwX64, then run it under Wine |
Every row above was executed on one Apple-silicon Mac. macosX64 runs through Rosetta. The Linux and
Windows binaries run inside a Linux VM, which is worth doing before a release because it is the only
local proof those two targets do more than link:
colima start --vm-type=vz --vz-rosetta
docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w ubuntu:24.04 \
./sample/build/bin/linuxX64/debugExecutable/sample.kexe
docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w -e HOME=/tmp -e WINEDEBUG=-all \
debian:bookworm bash -c 'apt-get update -qq && apt-get install -y -qq wine && \
wine sample/build/bin/mingwX64/debugExecutable/sample.exe'
Two targets link here but cannot be executed on this host, for reasons outside the project:
iosX64 needs an x86-64 simulator runtime. The installed iOS 26.5 runtime ships dyld_sim as arm64
only, so a Rosetta spawn aborts with could not use 'dyld_sim' because it is not a compatible arch.iosArm64 needs a physical device attached; a paired but disconnected iPhone reports
transport: None and cannot be targeted.iOS is a real app, installed and launched like any other. :sample:iosSimulatorApp links the
Kotlin/Native binary, lays out SampleApp.app around the Info.plist in sample/iosApp, and
ad-hoc signs it:
./gradlew :sample:iosSimulatorApp
xcrun simctl boot "iPhone 17 Pro Max"
xcrun simctl install booted sample/build/iosApp/SampleApp.app
xcrun simctl launch --console booted io.github.kunal26das.startup.sample.app
The app shows the report in a scrollable monospaced view and prints the same lines, so --console
gives the desktop output while the simulator shows the screen. Its entry point is the main in
iosMain, which hands control to UIApplicationMain; the other seven console targets share the
main in consoleMain, which prints and exits.
The two browser tasks start a webpack dev server on http://localhost:8080/ and never exit; stop them with Ctrl-C. They bind the same port, so run one at a time.
Not runnable on a macOS host. linuxX64 and mingwX64 link there and nowhere else: the
artifacts are an x86-64 ELF binary and a PE32+ executable, and running either on macOS fails with
exec format error. Link them locally, run them on Linux and Windows, which is what CI does.
macosX64, iosX64 and iosArm64 link as well and are not run either, for want of an x86-64 host
and a physical device.
androidApp is the one module that exists purely to be launched. sample stays a Kotlin
Multiplatform library, because its own AndroidManifest.xml and AndroidManifestParityTest are
what prove the shared initializers are declarable the AndroidX way; androidApp applies
com.android.application, depends on sample, and does nothing but display the report and mirror it
to Logcat under the StartupSample tag. It is not published.
Nothing in androidApp calls Startup.install. The components are created by
androidx.startup.InitializationProvider at process start, from the <meta-data> entries sample
contributes to the merged manifest, and the first line of the report is AndroidX's own
isEagerlyInitialized answering for that:
started by androidx.startup.InitializationProvider: true
Run the same sample on two platforms and the difference documented above is visible in the output: Android creates the graph in AndroidX's depth-first order and everything else in Kahn levels.
./gradlew build
./gradlew testAndroidHostTest desktopTest macosArm64Test iosSimulatorArm64Test jsNodeTest \
wasmJsNodeTest linkDebugTestLinuxX64 linkDebugTestMingwX64
build also links a debug executable for every Kotlin/Native target and assembles the Android
sample app, because sample declares binaries.executable() and androidApp is a real application
module. Three verification tasks run beside the tests:
:startup:checkObjCExport links Startup.framework for iosSimulatorArm64 and asserts on the
generated Objective-C header — the reified registration functions absent, the key-taking overloads
present, Context exported as StartupContext.:sample:checkConsumerObjCExport links two frameworks from sample, which is a consumer of the
library: one exports :startup and one does not. It asserts that the first carries the names the
Swift snippets above use and the second carries the Startup-prefixed ones with no
InitializerKeyKt, so the difference export(...) makes stays true.:startup:checkAndroidFloors unzips the published AAR and fails if minSdkVersion rises above 21
or minCompileSdk above 34.The first two need a macOS host and skip elsewhere; the third runs anywhere.
linuxX64Test, mingwX64Test, macosX64Test and iosX64Test are disabled on an arm64 Mac, so
linking their test binaries is the local proof. CI runs the first two for real on ubuntu-latest
and windows-latest; macosX64Test and iosX64Test run nowhere.
Apache-2.0. See LICENSE.