- Trace the two entry points, App.onCreate() and iOSApp.init(), to where they converge in startSharedApp
- Explain the order in which RevenueCat, the shared Koin modules and each platform's modules are loaded
- Pick the right file to open for an early crash, an empty config or a ViewModel injection error
Startup Flow On Android And iOS
Understand which points the app passes through from tapping the icon to the first screen, on both Android and iOS.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
You will understand the app's startup flow on both platforms: from the moment the user taps the icon, through the platform creating the process, the app setting up dependencies, Compose being built and navigation being wired, up to the first real screen.
After this lesson, when the app shows a blank screen, crashes before any UI appears, or can't inject a ViewModel, you will know which file to open first, on Android and on iOS.
Why Learn The Startup Flow
Many annoying bugs happen right as the app opens: a dependency isn't ready, the UI doesn't render, the app crashes before there is any UI. Without a map of startup, you end up debugging in the wrong place.
This app has two entry points, one per platform, but they meet very early:
Android iOS
App.onCreate() iOSApp.init()
-> SharedKoinKt.doInitApp()
\ /
-> startSharedApp(config, platformModules)
-> Koin graph: shared modules + platform modules
LauncherActivity.setContent { } ContentView -> MainViewController()
\ /
-> AppShell()
-> AppTheme + AppNavHost
-> Splash -> Menu
The platform-specific part is very thin. From startSharedApp and AppShell onward, both platforms run the same Kotlin code in :shared-app.
Part 1: The Android Entry Point
Open app/src/main/AndroidManifest.xml:
| Manifest item | What it means |
|---|---|
android:name=".App" | Android creates this Application before the first activity |
.LauncherActivity with MAIN and LAUNCHER | The activity opened from the app icon |
Then open app/src/main/java/com/creative/kta/App.kt. App.onCreate() does three things:
- Turns on Timber logging in debug builds.
- Calls
androidBuildConfig()to collect the values fromBuildConfig(Supabase URL, keys, flags, version) into anAppBuildConfig. - Calls
startSharedApp(...)with three Android-specific arguments:platformModules = androidStartupModules: Koin modules only Android has, such as the WorkManager workers and the notification scheduler (app/.../di/AppModule.kt).configure:androidLogger,androidContext(this@App)andworkManagerFactory(), which run before the modules are loaded.afterStart: registers activity lifecycle callbacks, creates notification channels, schedules the charging sync.
Part 2: The iOS Entry Point
Open iosApp/iosApp/iOSApp.swift. The init() of the iOSApp struct calls:
SharedKoinKt.doInitApp()
doInitApp lives in shared-app/src/iosMain/kotlin/com/creative/kta/shared/SharedKoin.kt. The file is named SharedKoin.kt, so Swift sees it as the class SharedKoinKt. The function is called doInitApp instead of initApp because Swift imports a Kotlin function whose name starts with init as an initializer, and the call site becomes very confusing.
doInitApp does the same job as App.onCreate():
- Calls
iosAppBuildConfig()to read the values fromInfo.plist(which Xcode fills fromConfig.xcconfig) into the sameAppBuildConfigdata class. - Calls
startSharedAppwithplatformModules = listOf(iosPlatformModule). afterStart: refreshes the notification permission state and callscheckGoogleSignInConfiguration().
AppBuildConfig is where the two platforms meet. Each platform reads from its own source (BuildConfig or Info.plist), and the shared code only sees one data class.
Part 3: startSharedApp, Where They Converge
Open shared-app/src/commonMain/kotlin/com/creative/kta/shared/StartSharedApp.kt. startSharedApp runs in this order:
require(platformModules.isNotEmpty())
installQzdsImageLoader()
configurePurchases(config)
startKoin {
configure()
modules(appKoinModules(config) + platformModules)
}
afterStart()
Three details worth learning:
configurePurchasesruns beforestartKoin.SubscriptionManagerImplreadsPurchases.isConfiguredwhen the graph is created. Configure it afterwards and the paywall spins on loading forever. When the premium gate is off, RevenueCat isn't configured at all: this is a build-time kill switch.appKoinModules(config)(inAppKoinModules.kt) is the list of shared modules:coreModule,dataModule,dataPlatformModule,domainModule,sharedAppModule,appConfigModule(config), plus the features' Koin modules fromFeatureRegistry.koinModules(appFeatureInitializers). TheappFeatureInitializerslist is written by hand inAppFeatures.kt.require(platformModules.isNotEmpty())fails early if a platform forgets to pass its modules.
dataPlatformModule is an expect val. Android and iOS each provide their own actual (DataModule.android.kt, DataModule.ios.kt), binding the same contracts, such as AudioManager or HttpClientEngine, to that platform's implementation.
This is a pattern you will see in many KMP projects: one shared startup function, and each platform passes in only what it knows.
Part 4: From Activity And ViewController To AppShell
On Android, in LauncherActivity.onCreate:
installSplashScreen()
enableEdgeToEdge()
setContent {
AppShell()
}
On iOS, in ContentView.swift, the ComposeView struct returns MainViewControllerKt.MainViewController(). The MainViewController function (shared-app/src/iosMain/.../MainViewController.kt) is:
fun MainViewController(): UIViewController = ComposeUIViewController {
AppShell()
}
Both draw the same composable. AppShell (shared-app/src/commonMain/.../AppShell.kt) wraps AppNavHost in AppTheme:
@Composable
fun AppShell(modifier: Modifier = Modifier) {
AppTheme {
AppNavHost(
navController = rememberNavController(),
modifier = modifier.fillMaxSize(),
)
}
}
There is only one copy of the theme and the nav graph. If Android wrapped an extra theme of its own inside setContent, the two platforms would drift apart in color with nothing reporting an error. So the activity stays very thin: it only handles what belongs to an Activity, such as the system splash screen, edge-to-edge, background music and the notification schedule on onResume/onPause.
Part 5: Getting To The First Screen
Open:
shared-app/src/commonMain/kotlin/com/creative/kta/shared/navigation/AppNavHost.kt
shared-app/src/commonMain/kotlin/com/creative/kta/shared/navigation/SplashNavigation.kt
shared-app/src/commonMain/kotlin/com/creative/kta/shared/splash/SplashRoute.kt
shared-app/src/commonMain/kotlin/com/creative/kta/shared/splash/SplashViewModel.kt
AppNavHost declares startDestination = SplashRouteData. Splash decides on its own when to leave:
SplashRoute gets SplashViewModel through koinViewModel()
-> SplashViewModel initializes audio, waits a random delay, emits NavigateToMenu
-> AppNavHost navigates to MenuRouteData, popUpTo<SplashRouteData> { inclusive = true }
-> Splash is removed from the back stack
Splash belongs to :shared-app.
Menu belongs to :feature:menu.
AppNavHost connects the two.
AppNavHost knows the menu screen exists through the FeatureNavigationProviders listed in appFeatureNavigationProviders in AppFeatures.kt.
Part 6: When A Missing Binding Shows Up
Koin creates single and viewModel definitions lazily, the first time someone needs them. If dataModule is missing a binding, the app still starts normally. The error only blows up when you open the screen that needs that binding, and maybe only on one platform.
The repo guards against this with AppGraphTest (shared-app/src/commonTest/). The test builds the whole graph and tries to create every registered binding, along with all of their constructor parameters. Because it lives in commonTest, it runs on both the JVM and the iOS Simulator, so it catches a binding registered on only one side. When you change a Koin module, run:
./gradlew :shared-app:testAndroidHostTest
Quick Debug Table
| Problem | Where to check first |
|---|---|
| Android crashes before any UI | App.kt, then startSharedApp |
| iOS crashes right at launch | SharedKoin.kt (doInitApp), then startSharedApp |
| Config values are empty on one platform | androidBuildConfig() in App.kt, or iosAppBuildConfig() and Config.xcconfig |
| A ViewModel can't be injected | The module that provides that ViewModel, appKoinModules, then run AppGraphTest |
| The app opens but a screen is missing | AppNavHost.kt and AppFeatures.kt |
| Splash never goes away | SplashRoute.kt and the navigation event in SplashViewModel |
| Odd notifications or music while the app is in the background (Android) | LauncherActivity.onPause() and onResume() |
Checkpoint
Read App.kt, SharedKoin.kt and StartSharedApp.kt from top to bottom once.
You are done with this lesson when you can answer:
- Which startup code is Android-only, which is iOS-only, and in which function do they meet?
- Why must RevenueCat be configured before
startKoin? - If
dataModuleis missing a binding, does the error appear at app startup or when a screen opens? Which test catches it earlier? - Why doesn't
LauncherActivitywrap an extra theme aroundAppShell()? - Which file decides the first screen the user sees?