Dan Tech Academy
After this lesson you will
  • 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
Duration6 min
DifficultyBeginner

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.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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:

text
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 itemWhat it means
android:name=".App"Android creates this Application before the first activity
.LauncherActivity with MAIN and LAUNCHERThe activity opened from the app icon

Then open app/src/main/java/com/creative/kta/App.kt. App.onCreate() does three things:

  1. Turns on Timber logging in debug builds.
  2. Calls androidBuildConfig() to collect the values from BuildConfig (Supabase URL, keys, flags, version) into an AppBuildConfig.
  3. 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) and workManagerFactory(), 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:

swift
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():

  1. Calls iosAppBuildConfig() to read the values from Info.plist (which Xcode fills from Config.xcconfig) into the same AppBuildConfig data class.
  2. Calls startSharedApp with platformModules = listOf(iosPlatformModule).
  3. afterStart: refreshes the notification permission state and calls checkGoogleSignInConfiguration().

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:

kotlin
require(platformModules.isNotEmpty())
installQzdsImageLoader()
configurePurchases(config)
startKoin {
    configure()
    modules(appKoinModules(config) + platformModules)
}
afterStart()

Three details worth learning:

  • configurePurchases runs before startKoin. SubscriptionManagerImpl reads Purchases.isConfigured when 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) (in AppKoinModules.kt) is the list of shared modules: coreModule, dataModule, dataPlatformModule, domainModule, sharedAppModule, appConfigModule(config), plus the features' Koin modules from FeatureRegistry.koinModules(appFeatureInitializers). The appFeatureInitializers list is written by hand in AppFeatures.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:

kotlin
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:

kotlin
fun MainViewController(): UIViewController = ComposeUIViewController {
    AppShell()
}

Both draw the same composable. AppShell (shared-app/src/commonMain/.../AppShell.kt) wraps AppNavHost in AppTheme:

kotlin
@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:

text
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:

text
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
text
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:

bash
./gradlew :shared-app:testAndroidHostTest

Quick Debug Table

ProblemWhere to check first
Android crashes before any UIApp.kt, then startSharedApp
iOS crashes right at launchSharedKoin.kt (doInitApp), then startSharedApp
Config values are empty on one platformandroidBuildConfig() in App.kt, or iosAppBuildConfig() and Config.xcconfig
A ViewModel can't be injectedThe module that provides that ViewModel, appKoinModules, then run AppGraphTest
The app opens but a screen is missingAppNavHost.kt and AppFeatures.kt
Splash never goes awaySplashRoute.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 dataModule is missing a binding, does the error appear at app startup or when a screen opens? Which test catches it earlier?
  • Why doesn't LauncherActivity wrap an extra theme around AppShell()?
  • Which file decides the first screen the user sees?
Sign in