- Trace hai entry point App.onCreate() và iOSApp.init() tới điểm hội tụ startSharedApp
- Giải thích thứ tự RevenueCat, Koin modules dùng chung và modules của từng nền tảng được nạp
- Chọn đúng file cần mở khi gặp crash sớm, config rỗng hoặc lỗi inject ViewModel
Luồng Khởi Động Trên Android Và iOS
Hiểu app đi qua những điểm nào từ lúc bấm icon tới màn hình đầu tiên, trên cả Android và iOS.
Video bài học sắp lên sóng
Trong lúc chờ, bạn hãy đọc bản text của bài học bên dưới: đầy đủ lý thuyết, code mẫu và checklist thực hành.
Mục Tiêu Bài Học
Bạn nắm được luồng khởi động của app trên cả hai nền tảng: từ lúc người dùng bấm icon, nền tảng tạo process, app khởi tạo dependency, Compose được dựng, navigation được nối, cho tới màn hình thật đầu tiên.
Sau bài này, khi app trắng màn hình, crash trước khi có UI, hay không inject được ViewModel, bạn biết mở file nào trước, trên Android lẫn iOS.
Vì Sao Phải Nắm Luồng Khởi Động
Nhiều lỗi khó chịu xảy ra ngay khi app vừa mở: dependency chưa sẵn sàng, UI không render, app crash trước khi có UI. Không có bản đồ khởi động, bạn sẽ debug sai chỗ.
App này có hai entry point, một cho mỗi nền tảng, nhưng chúng gặp nhau rất sớm:
Android iOS
App.onCreate() iOSApp.init()
-> SharedKoinKt.doInitApp()
\ /
-> startSharedApp(config, platformModules)
-> Koin graph: modules dùng chung + modules của nền tảng
LauncherActivity.setContent { } ContentView -> MainViewController()
\ /
-> AppShell()
-> AppTheme + AppNavHost
-> Splash -> Menu
Phần riêng của mỗi nền tảng rất mỏng. Từ startSharedApp và AppShell trở đi, hai nền tảng chạy cùng một code Kotlin trong :shared-app.
Phần 1: Entry Point Android
Mở app/src/main/AndroidManifest.xml:
| Manifest item | Ý nghĩa |
|---|---|
android:name=".App" | Android tạo Application này trước activity đầu tiên |
.LauncherActivity với MAIN và LAUNCHER | Activity được mở từ app icon |
Rồi mở app/src/main/java/com/creative/kta/App.kt. App.onCreate() làm ba việc:
- Bật Timber logging trong debug build.
- Gọi
androidBuildConfig()để gom các giá trị từBuildConfig(Supabase URL, key, flag, version) thành mộtAppBuildConfig. - Gọi
startSharedApp(...)với ba tham số riêng của Android:platformModules = androidStartupModules: Koin modules chỉ Android có, như WorkManager workers và notification scheduler (app/.../di/AppModule.kt).configure:androidLogger,androidContext(this@App)vàworkManagerFactory(), chạy trước khi modules được nạp.afterStart: đăng ký activity lifecycle callbacks, tạo notification channels, lên lịch charging sync.
Phần 2: Entry Point iOS
Mở iosApp/iosApp/iOSApp.swift. Hàm init() của struct iOSApp gọi:
SharedKoinKt.doInitApp()
doInitApp nằm trong shared-app/src/iosMain/kotlin/com/creative/kta/shared/SharedKoin.kt. File tên SharedKoin.kt nên Swift thấy nó dưới tên class SharedKoinKt. Hàm tên doInitApp thay vì initApp vì Swift import một hàm Kotlin bắt đầu bằng init thành một initializer, và call site sẽ rất khó hiểu.
doInitApp làm việc tương tự App.onCreate():
- Gọi
iosAppBuildConfig()để đọc giá trị từInfo.plist(do Xcode điền từConfig.xcconfig) thành cùng data classAppBuildConfig. - Gọi
startSharedAppvớiplatformModules = listOf(iosPlatformModule). afterStart: refresh trạng thái quyền notification và gọicheckGoogleSignInConfiguration().
AppBuildConfig là chỗ hai nền tảng gặp nhau. Mỗi nền tảng đọc từ nguồn của mình (BuildConfig hoặc Info.plist), còn code chung chỉ thấy một data class.
Phần 3: startSharedApp, Điểm Hội Tụ
Mở shared-app/src/commonMain/kotlin/com/creative/kta/shared/StartSharedApp.kt. Hàm startSharedApp chạy theo thứ tự:
require(platformModules.isNotEmpty())
installQzdsImageLoader()
configurePurchases(config)
startKoin {
configure()
modules(appKoinModules(config) + platformModules)
}
afterStart()
Ba chi tiết đáng học:
configurePurchaseschạy trướcstartKoin.SubscriptionManagerImplđọcPurchases.isConfiguredkhi graph được tạo. Configure sau thì paywall quay loading mãi. Khi premium gate tắt, RevenueCat không được configure chút nào: đây là build-time kill switch.appKoinModules(config)(trongAppKoinModules.kt) là danh sách modules dùng chung:coreModule,dataModule,dataPlatformModule,domainModule,sharedAppModule,appConfigModule(config), cộng Koin modules của các feature lấy từFeatureRegistry.koinModules(appFeatureInitializers). Danh sáchappFeatureInitializersđược viết tay trongAppFeatures.kt.require(platformModules.isNotEmpty())chặn sớm trường hợp một nền tảng quên truyền modules của mình.
dataPlatformModule là một expect val. Android và iOS mỗi bên cung cấp actual của mình (DataModule.android.kt, DataModule.ios.kt), bind cùng các contract như AudioManager hay HttpClientEngine với implementation của nền tảng đó.
Đây là pattern bạn sẽ gặp ở nhiều project KMP: một hàm khởi động chung, mỗi nền tảng chỉ truyền vào những gì nó biết.
Phần 4: Từ Activity Và ViewController Tới AppShell
Android, trong LauncherActivity.onCreate:
installSplashScreen()
enableEdgeToEdge()
setContent {
AppShell()
}
iOS, trong ContentView.swift, struct ComposeView trả về MainViewControllerKt.MainViewController(). Hàm MainViewController (shared-app/src/iosMain/.../MainViewController.kt) là:
fun MainViewController(): UIViewController = ComposeUIViewController {
AppShell()
}
Cả hai vẽ cùng một composable. AppShell (shared-app/src/commonMain/.../AppShell.kt) bọc AppNavHost trong AppTheme:
@Composable
fun AppShell(modifier: Modifier = Modifier) {
AppTheme {
AppNavHost(
navController = rememberNavController(),
modifier = modifier.fillMaxSize(),
)
}
}
Theme và nav graph chỉ có một bản. Nếu Android bọc thêm một theme riêng trong setContent, hai nền tảng sẽ lệch màu mà không có gì báo lỗi. Vì vậy activity giữ thật mỏng: nó chỉ lo phần thuộc về Activity, như splash screen của hệ thống, edge-to-edge, nhạc nền và lịch notification theo onResume/onPause.
Phần 5: Đi Đến Màn Hình Đầu Tiên
Mở:
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 khai báo startDestination = SplashRouteData. Splash tự quyết định lúc nào rời đi:
SplashRoute lấy SplashViewModel qua koinViewModel()
-> SplashViewModel khởi tạo audio, chờ một khoảng ngẫu nhiên, emit NavigateToMenu
-> AppNavHost navigate tới MenuRouteData, popUpTo<SplashRouteData> { inclusive = true }
-> Splash bị xoá khỏi back stack
Splash thuộc :shared-app.
Menu thuộc :feature:menu.
AppNavHost nối hai phần này.
AppNavHost biết menu screen tồn tại nhờ các FeatureNavigationProvider liệt kê trong appFeatureNavigationProviders của AppFeatures.kt.
Phần 6: Khi Nào Lỗi Thiếu Binding Xuất Hiện
Koin tạo single và viewModel một cách lazy, khi có người cần chúng lần đầu. Nếu dataModule thiếu một binding, app vẫn khởi động bình thường. Lỗi chỉ nổ khi bạn mở screen cần binding đó, và có thể chỉ trên một nền tảng.
Repo chặn chuyện này bằng AppGraphTest (shared-app/src/commonTest/). Test dựng toàn bộ graph và tạo thử mọi binding đã đăng ký, cùng mọi tham số constructor của chúng. Vì nằm trong commonTest, nó chạy trên cả JVM và iOS Simulator, nên bắt được binding chỉ đăng ký ở một bên. Khi đổi một Koin module, chạy:
./gradlew :shared-app:testAndroidHostTest
Bảng Debug Nhanh
| Vấn đề | Nơi kiểm tra trước |
|---|---|
| Android crash trước khi có UI | App.kt, rồi startSharedApp |
| iOS crash ngay khi mở | SharedKoin.kt (doInitApp), rồi startSharedApp |
| Giá trị config rỗng trên một nền tảng | androidBuildConfig() trong App.kt, hoặc iosAppBuildConfig() và Config.xcconfig |
| ViewModel không inject được | Module cung cấp ViewModel đó, appKoinModules, rồi chạy AppGraphTest |
| App mở nhưng thiếu screen | AppNavHost.kt và AppFeatures.kt |
| Splash không bao giờ thoát | SplashRoute.kt và event điều hướng trong SplashViewModel |
| Notification hoặc nhạc lạ khi app ở background (Android) | LauncherActivity.onPause() và onResume() |
Checkpoint
Đọc App.kt, SharedKoin.kt và StartSharedApp.kt từ trên xuống một lượt.
Bạn hoàn tất bài này khi trả lời được:
- Code khởi động nào là riêng của Android, code nào là riêng của iOS, và chúng gặp nhau ở hàm nào?
- Vì sao RevenueCat phải được configure trước
startKoin? - Nếu
dataModulethiếu một binding, lỗi xuất hiện lúc app khởi động hay lúc mở screen? Test nào bắt được nó sớm hơn? - Vì sao
LauncherActivitykhông bọc thêm theme quanhAppShell()? - File nào quyết định màn hình đầu tiên người dùng nhìn thấy?