Dan Tech Academy
Sau bài này bạn sẽ
  • 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
Thời lượng6 phút
Độ khóBeginner

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 đang cập nhật

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.

Bản quay đang trong khâu hậu kỳ

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:

text
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à LAUNCHERActivity được mở từ app icon

Rồi mở app/src/main/java/com/creative/kta/App.kt. App.onCreate() làm ba việc:

  1. Bật Timber logging trong debug build.
  2. Gọi androidBuildConfig() để gom các giá trị từ BuildConfig (Supabase URL, key, flag, version) thành một AppBuildConfig.
  3. 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:

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

  1. Gọi iosAppBuildConfig() để đọc giá trị từ Info.plist (do Xcode điền từ Config.xcconfig) thành cùng data class AppBuildConfig.
  2. Gọi startSharedApp với platformModules = listOf(iosPlatformModule).
  3. afterStart: refresh trạng thái quyền notification và gọi checkGoogleSignInConfiguration().

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

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

Ba chi tiết đáng học:

  • configurePurchases chạy trước startKoin. SubscriptionManagerImpl đọc Purchases.isConfigured khi 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) (trong AppKoinModules.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ách appFeatureInitializers được viết tay trong AppFeatures.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:

kotlin
installSplashScreen()
enableEdgeToEdge()
setContent {
    AppShell()
}

iOS, trong ContentView.swift, struct ComposeView trả về MainViewControllerKt.MainViewController(). Hàm MainViewController (shared-app/src/iosMain/.../MainViewController.kt) là:

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

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

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 khai báo startDestination = SplashRouteData. Splash tự quyết định lúc nào rời đi:

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

bash
./gradlew :shared-app:testAndroidHostTest

Bảng Debug Nhanh

Vấn đềNơi kiểm tra trước
Android crash trước khi có UIApp.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ảngandroidBuildConfig() trong App.kt, hoặc iosAppBuildConfig() và Config.xcconfig
ViewModel không inject đượcModule cung cấp ViewModel đó, appKoinModules, rồi chạy AppGraphTest
App mở nhưng thiếu screenAppNavHost.kt và AppFeatures.kt
Splash không bao giờ thoátSplashRoute.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 dataModule thiế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 LauncherActivity không bọc thêm theme quanh AppShell()?
  • File nào quyết định màn hình đầu tiên người dùng nhìn thấy?
Đăng nhập