# Giải Phẫu Một Project KMP

> Nhìn vào bên trong một module Kotlin Multiplatform: target, source set, default hierarchy template, plugin Android cho thư viện KMP và những gì không đặt được trong commonMain.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Nền Tảng Kotlin Multiplatform
- Thời lượng: 15 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-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md

## Mục tiêu

- Phân biệt target với source set và đọc được cây source set do default hierarchy template tạo ra
- Biết test trong commonTest, androidHostTest, iosTest chạy ở đâu và mỗi lệnh test chứng minh điều gì
- Nhận ra API không dùng được trong commonMain và cách repo thay thế, gồm cách dùng dispatcher đúng trên iOS

---

## Mục Tiêu Bài Học

Bài này đi vào bên trong một module KMP: target là gì, source set là gì, code trong mỗi source set được phép gọi API nào, và vì sao một dòng code chạy tốt trên Android lại không compile được trong `commonMain`.

Sau bài này, mở một project KMP bất kỳ, bạn đọc được nó compile ra những gì, code của mỗi nền tảng nằm ở đâu, và đoán trước được lỗi nào sẽ xuất hiện khi bạn đặt code sai chỗ.

## Phần 1: Target Và Source Set

Hai khái niệm này hay bị dùng lẫn, nhưng chúng trả lời hai câu hỏi khác nhau.

| Khái niệm  | Trả lời câu hỏi                   | Ví dụ trong repo                                     |
| ---------- | --------------------------------- | ---------------------------------------------------- |
| Target     | Module này compile ra cho máy nào | Android, `iosArm64`, `iosSimulatorArm64`             |
| Source set | Đoạn code này compile cho target nào | `commonMain`, `androidMain`, `iosMain`            |

Target được khai báo một lần cho mọi module trong convention plugin `KmpLibraryConventionPlugin` (thư mục `build-logic/`): một target Android qua block `androidLibrary`, và hai target iOS `iosArm64()` cho iPhone thật, `iosSimulatorArm64()` cho Simulator trên Mac chip Apple Silicon. Repo không khai báo `iosX64`, nên Simulator trên Mac Intel không nằm trong phạm vi build.

Source set là một thư mục code cộng với dependency của nó. Quy tắc quan trọng nhất của KMP nằm ở đây: compiler chỉ cho một source set dùng những API có mặt trên mọi target mà source set đó compile tới. `commonMain` compile cho cả Android lẫn iOS, nên nó không thấy `android.*` và cũng không thấy `java.*`.

Tài liệu chính thức: [Discover your project](https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html).

## Phần 2: Default Hierarchy Template

Bạn không thấy dòng nào trong build file tạo ra `iosMain`. Kotlin Gradle plugin tự dựng nó từ default hierarchy template: dựa vào các target bạn khai báo, plugin tạo sẵn các source set trung gian phù hợp và nối chúng với nhau.

Với ba target của repo, cây source set phía production là:

```text
commonMain
├── androidMain
└── nativeMain
    └── appleMain
        └── iosMain
            ├── iosArm64Main
            └── iosSimulatorArm64Main
```

Phía test có cây tương ứng (`commonTest`, `iosTest`...). Android test source set của repo tên là `androidHostTest`, sẽ nói ở Phần 3.

Bạn tự kiểm chứng được cây này mà không cần Mac:

```bash
./gradlew :kernel:tasks --all | grep Metadata
```

Output có `compileCommonMainKotlinMetadata`, `compileNativeMainKotlinMetadata`, `compileAppleMainKotlinMetadata` và `compileIosMainKotlinMetadata`. Mỗi task là một source set trung gian mà plugin đã tạo.

Ba điều cần nhớ khi đọc project khác:

- Repo này chỉ dùng `iosMain`. `nativeMain` và `appleMain` tồn tại nhưng trống. Code trong `iosMain` chạy trên cả iPhone thật lẫn Simulator, nên bạn gần như không bao giờ cần `iosArm64Main` riêng.
- Tài liệu ghi rõ: khi project chỉ có target iOS, `nativeMain` và `appleMain` vẫn thấy toàn bộ API iOS. Đừng suy ra từ tên rằng chúng bị giới hạn.
- Nếu một project cũ tự nối source set bằng `dependsOn(...)`, template bị tắt và Gradle in cảnh báo. Gặp cảnh báo đó trong project bạn nhận, hãy đọc kỹ phần cấu hình tay trước khi thêm target mới.

Tài liệu: [Source set hierarchy](https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html).

## Phần 3: Test Chạy Ở Đâu

Test trong `commonTest` không chạy "ở đâu cả". Nó chạy trên từng target.

| Source set        | Chạy bằng task                        | Máy cần có      |
| ----------------- | ------------------------------------- | --------------- |
| `commonTest`      | `testAndroidHostTest` (JVM)           | Bất kỳ          |
| `commonTest`      | `iosSimulatorArm64Test`               | Mac             |
| `androidHostTest` | `testAndroidHostTest`                 | Bất kỳ          |
| `iosTest`         | `iosSimulatorArm64Test`               | Mac             |

Task `testAndroidHostTest` đến từ dòng `withHostTest {}` trong `KmpLibraryConventionPlugin`. Comment trong plugin nói rõ mục đích: chạy `commonTest` trên Android host. `androidHostTest` dành cho test cần thư viện chỉ có trên JVM, ví dụ MockK hay JUnit.

Hệ quả thực tế: một test xanh trên máy Windows hay Linux mới chứng minh logic đúng trên JVM. Code iOS trong `iosMain` chỉ được test khi ai đó chạy `iosSimulatorArm64Test` trên Mac. Phía JVM, lệnh test cho KMP module là `testAndroidHostTest`. Các lệnh iOS trong bài này chưa được chạy thử trên Mac khi viết bài.

Tài liệu: [Test your multiplatform app](https://kotlinlang.org/docs/multiplatform/multiplatform-run-tests.html).

## Phần 4: Plugin Android Cho Thư Viện KMP

Mở `KmpLibraryConventionPlugin`. Nó áp hai plugin:

```text
org.jetbrains.kotlin.multiplatform
com.android.kotlin.multiplatform.library
```

Plugin thứ hai là plugin Android dành riêng cho thư viện KMP. Các project KMP cũ dùng `com.android.library` cùng `androidTarget()`. Google ghi rõ cách đó dựa vào API đã deprecated từ AGP 9. Repo dùng AGP 9.0.0, nên đi theo plugin mới.

Plugin mới khác `com.android.library` ở vài điểm sẽ ảnh hưởng tới cách bạn chia code:

| Đặc điểm                         | Hệ quả trong repo                                                                 |
| -------------------------------- | --------------------------------------------------------------------------------- |
| Chỉ một variant, không build type hay product flavor | Thư viện KMP không có `BuildConfig` debug/release. Config build nằm ở `:app` và đi vào code chung qua `AppBuildConfig` |
| Không hỗ trợ native build (NDK)  | Module cần NDK phải là module Android thuần. KDoc của plugin ghi rõ điều này     |
| Test tắt mặc định                | Plugin phải bật `withHostTest {}` thì mới có `testAndroidHostTest`               |
| Android resources tắt mặc định   | Plugin bật `android.experimental.kmp.enableAndroidResources`. Comment giải thích: thiếu cờ này, Compose Resources vẫn compile nhưng không được đóng gói vào APK, và mọi lần đọc resource sẽ lỗi lúc chạy |

Một lưu ý khi nâng cấp: tài liệu của Google cho biết từ AGP 8.12 có block `android {}` thay cho `androidLibrary {}`, và `androidLibrary {}` bị deprecated từ AGP 9.1. Repo đang ở AGP 9.0.0 với `androidLibrary {}`. Khi nâng AGP, đây là chỗ đầu tiên cần đổi.

Tài liệu: [Set up the Android Gradle Library Plugin for KMP](https://developer.android.com/kotlin/multiplatform/plugin).

## Phần 5: Những Gì Không Sống Được Trong `commonMain`

Khi chuyển code Android sang `commonMain`, phần lớn lỗi compile rơi vào vài nhóm quen thuộc. Repo đã đi qua tất cả, nên mỗi nhóm có một lời giải thật để bạn tham khảo.

| Code Android quen dùng                 | Vì sao không compile                  | Repo thay bằng gì                                                       |
| -------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- |
| `java.io.File`, `java.*`               | iOS không có JDK                      | Đường dẫn dạng `String` qua interface `DataStoreFileResolver`         |
| `Context`, `Activity`                  | Chỉ có trên Android                   | Interface trong `commonMain`, implementation qua Koin                   |
| `BuildConfig`                          | Sinh theo build type của Android, thư viện KMP không có build type | `AppBuildConfig`, do `:app` hoặc `iosAppBuildConfig` truyền vào         |
| Timber                                 | Thư viện chỉ cho Android              | Kermit (`co.touchlab.kermit`). Timber chỉ còn ở `androidMain` và `:app` |
| `java.time`                            | API của JDK                           | `kotlinx-datetime`, ví dụ trong `LocalGameHistoryRepository`            |
| `Class.forName`, reflection            | Kotlin/Native không có reflection kiểu JVM | Danh sách tường minh trong `AppFeatures.kt` |

Lỗi xuất hiện ở bước compile metadata của `commonMain`, chạy được trên mọi máy. Thử đặt `import java.io.File` vào một file trong `kernel/src/commonMain`, rồi chạy:

```bash
./gradlew :kernel:compileCommonMainKotlinMetadata
```

Build lỗi với `Unresolved reference 'java'`. Đây là cách nhanh nhất để kiểm tra code chung trước khi đẩy lên CI. Nhớ xóa dòng thử sau khi xem lỗi.

## Phần 6: Dispatcher Trên iOS

Mở `:kernel`, package `dispatchers`. Interface `QzDispatchers` có ba thuộc tính `io`, `main`, `mainImmediate`. `QzDispatchersImpl` cài đặt chúng:

| Thuộc tính      | Giá trị                                  |
| --------------- | ---------------------------------------- |
| `io`            | `platformIoDispatcher` (một `expect val`) |
| `main`          | `Dispatchers.Main`                       |
| `mainImmediate` | `Dispatchers.Main.immediate`             |

`platformIoDispatcher` là một `expect val` khai báo trong [`QzDispatchersImpl.kt`](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/kernel/src/commonMain/kotlin/com/creative/kta/kernel/dispatchers/QzDispatchersImpl.kt): `Dispatchers.IO` trên Android và `Dispatchers.Default` trên iOS. KDoc giải thích vì sao vẫn cần lớp gián tiếp này: mỗi nền tảng có đúng một chỗ quyết định "I/O" nghĩa là gì, và bỏ nó đi thì việc đọc ghi đĩa sẽ chạy trên thread gọi tới.

Nhiều tài liệu cũ nói Kotlin/Native không có `Dispatchers.IO`. Điều đó không còn đúng. Changelog của kotlinx.coroutines ghi `Dispatchers.IO` được thêm vào Kotlin/Native từ bản 1.7.0, và API reference khai báo nó cho nhóm target JVM cùng Native. Repo dùng kotlinx.coroutines 1.10.2, và KDoc của [`QzDispatchersImpl.ios.kt`](https://github.com/dan-tech-academy/kotlin-accelerator-ai/blob/d845c58aa0497a84c2151cd5969b66fc423ea568/kernel/src/iosMain/kotlin/com/creative/kta/kernel/dispatchers/QzDispatchersImpl.ios.kt) ghi đúng điều này: `Dispatchers.IO` đã có trên Kotlin/Native, nhưng bản iOS vẫn chọn `Dispatchers.Default`, vì worker pool của nó là nơi chạy việc đọc ghi DataStore. Trong `commonMain` của một project chỉ có Android và iOS, bạn dùng được `Dispatchers.IO` với một import riêng:

```kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO

val io = Dispatchers.IO
```

Đoạn này đã được compile thử trong `:kernel` bằng `compileCommonMainKotlinMetadata`. Trên JVM, `IO` là thành viên của `Dispatchers` nên không cần import thêm. Trong code chung, nó là extension, nên thiếu dòng `import kotlinx.coroutines.IO` là lỗi unresolved. Nếu project sau này thêm target JS hoặc Wasm, `Dispatchers.IO` không có ở đó và code chung sẽ không compile nữa.

Repo giữ `Dispatchers.Default` trên iOS là một lựa chọn có chủ đích, và nó chạy đúng. Điều đáng học là thiết kế: feature không gọi `Dispatchers.*` trực tiếp mà nhận `QzDispatchers` qua Koin, nên test thay được bằng dispatcher của `kotlinx-coroutines-test` và đổi dispatcher iOS chỉ chạm một file.

Còn `Dispatchers.Main` trên iOS chạy trên main queue của hệ điều hành, đúng chỗ UIKit yêu cầu cập nhật UI.

Tài liệu: [Dispatchers.IO API](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-i-o.html), [kotlinx.coroutines changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md).

## Phần 7: Đọc Một Project KMP Lạ

Khi nhận một project KMP mới, đi theo thứ tự này:

1. `settings.gradle.kts`: danh sách module.
2. `build-logic/` hoặc `buildSrc/`: convention plugin khai báo target, SDK, toolchain. Đọc một lần là hiểu mọi module.
3. `build.gradle.kts` của từng module: plugin thật sự được áp và dependency theo source set (`commonMain.dependencies`, `androidMain.dependencies`...).
4. Thư mục `src/` của từng module: source set nào có code thật.
5. Tìm từ khóa `expect ` để biết project phụ thuộc vào nền tảng ở đâu.
6. Tìm `gradle.properties` có dòng `kotlin.mpp.applyDefaultHierarchyTemplate=false` hay không, và build file có `dependsOn(` hay không.

## Bảng Xử Lý Lỗi

| Triệu chứng                                                   | Nguyên nhân thường gặp                                     | Cách xử lý                                                       |
| ------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------- |
| `Unresolved reference 'java'` hoặc `'android'` trong `commonMain` | Dùng API của JVM hoặc Android trong code chung          | Chuyển sang thư viện multiplatform hoặc tách ra interface        |
| `Unresolved reference 'IO'` trong `commonMain`                | Thiếu `import kotlinx.coroutines.IO`                       | Thêm import, hoặc dùng `QzDispatchers` như repo                  |
| Test xanh trên Linux nhưng app iOS lỗi                        | Chỉ chạy `testAndroidHostTest`, chưa chạy test trên iOS    | Chạy `iosSimulatorArm64Test` trên Mac                            |
| Compose Resources lỗi lúc chạy trên Android dù build xanh     | Thiếu cờ `enableAndroidResources` ở module mới tự cấu hình | Dùng `kta.kmp.library` thay vì tự cấu hình plugin                |
| Gradle cảnh báo default hierarchy template không được áp dụng | Build file có `dependsOn(...)` tự viết                     | Bỏ cấu hình tay hoặc gọi lại `applyDefaultHierarchyTemplate()`   |

## Checkpoint

Bạn hoàn tất bài này khi:

- chạy được `./gradlew :kernel:tasks --all | grep Metadata` và chỉ ra source set trung gian nào xuất hiện,
- giải thích được vì sao code trong `iosMain` chạy cả trên iPhone lẫn Simulator,
- nói được `testAndroidHostTest` chứng minh điều gì và không chứng minh điều gì,
- kể được ba thứ không đặt được trong `commonMain` cùng cách repo thay thế,
- giải thích được vì sao `Dispatchers.IO` dùng được trong `commonMain` của repo này nhưng sẽ không compile nếu thêm target web.

---

- Bài trước: [Thay Đổi An Toàn Đầu Tiên](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-05-make-your-first-change.md)
- Bài tiếp theo: [Platform Seams: expect/actual Hay Interface + DI](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di.md)
