# 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.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-02-trace-app-startup
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Kiến trúc Production mobile app
- Thời lượng: 6 phút
- Độ khó: beginner
- Truy cập: Miễn phí, đọc toàn bài
- English: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-02-trace-app-startup.md

## Mục tiêu

- 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

---

## 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à `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:

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`.

```mermaid
%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "primaryBorderColor": "#475569", "secondaryColor": "#1e293b", "tertiaryColor": "#0f172a", "lineColor": "#64748b", "textColor": "#e2e8f0", "clusterBkg": "#0f172a", "clusterBorder": "#475569", "titleColor": "#cbd5e1", "edgeLabelBackground": "#334155", "fontSize": "15px"}, "flowchart": {"wrappingWidth": 420}}}%%
flowchart TB
    classDef ui fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#e2e8f0
    classDef vm fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef dom fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#e2e8f0
    classDef data fill:#1e293b,stroke:#8b5cf6,stroke-width:2px,color:#e2e8f0
    classDef root fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#e2e8f0
    classDef bad fill:#1e293b,stroke:#ef4444,stroke-width:2px,color:#e2e8f0
    classDef good fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef note fill:#0f172a,stroke:#475569,stroke-dasharray:4 3,color:#cbd5e1
    accTitle: Luồng khởi động trên Android và iOS
    accDescr: Android bắt đầu ở App.onCreate, iOS bắt đầu ở iOSApp init gọi SharedKoinKt.doInitApp. Cả hai đọc cấu hình thành AppBuildConfig rồi gọi startSharedApp với platform module của mình. startSharedApp cài image loader, cấu hình RevenueCat, startKoin với appKoinModules cộng platform modules, rồi chạy afterStart. Sau đó LauncherActivity và MainViewController cùng vẽ AppShell.

    subgraph AND["Android"]
        direction TB
        A1["App.onCreate()"]:::root --> A2["androidBuildConfig()<br/>đọc BuildConfig"]:::root
        A2 --> A3["platformModules = androidStartupModules"]:::root
    end
    subgraph IOS["iOS"]
        direction TB
        I1["iOSApp init()<br/>SharedKoinKt.doInitApp()"]:::root --> I2["iosAppBuildConfig()<br/>đọc Info.plist"]:::root
        I2 --> I3["platformModules = listOf(iosPlatformModule)"]:::root
    end
    subgraph START["startSharedApp(...)"]
        direction TB
        S1["installQzdsImageLoader()"]:::vm --> S2["configurePurchases(config)<br/>trước startKoin"]:::vm
        S2 --> S3["startKoin { configure(); modules(appKoinModules(config) + platformModules) }"]:::vm
        S3 --> S4["afterStart()"]:::vm
    end
    A3 --> START
    I3 --> START
    START --> UIA["Android: LauncherActivity<br/>setContent { AppShell() }"]:::ui
    START --> UII["iOS: MainViewController()<br/>ComposeUIViewController { AppShell() }"]:::ui
    UIA --> SHELL["AppShell: AppTheme + AppNavHost"]:::ui
    UII --> SHELL
```

## 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ó 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 `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?

---

- Bài trước: [Đọc Module Graph](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-01-read-module-graph.md)
- Bài tiếp theo: [Feature Registration Và Navigation Boundaries](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-03-understand-feature-discovery.md)
