- Đọc settings.gradle.kts để thấy 14 module thật và nhận ra folder nào không thuộc build
- Giải thích mỗi module sở hữu phần gì, vì sao :shared-app giữ danh sách feature, và chiều dependency giữa các module
- Phân biệt commonMain, androidMain, iosMain và nhận ra module được khai báo qua convention plugin kta.kmp.library
Đọc Module Graph
Đọc project Kotlin Multiplatform như một hệ thống: module, chiều dependency và source set.
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 đọc được bản đồ của một production app Kotlin Multiplatform (KMP): module nào tồn tại, mỗi module sở hữu phần gì, dependency đi theo chiều nào, và code nào chạy trên nền tảng nào.
Sau bài này, nhìn vào một screen mới, một use case, một repository implementation hay một shared UI component, bạn biết module nào nên sở hữu nó. Khi nhận một project KMP khác, bạn biết đọc file nào trước để dựng lại bản đồ đó.
Vì Sao Phải Đọc Module Graph Trước
Trước khi tìm hiểu một dự án, tôi luôn xem họ tổ chức module thế nào. Có dự án dùng single module, có dự án chia module theo business domain, có dự án chia theo layer (feature, domain, data). Mỗi cách có ưu và nhược điểm riêng, và lựa chọn phụ thuộc vào dự án và đội ngũ.
Project KMP có thêm một trục nữa: code nào dùng chung cho mọi nền tảng, code nào chỉ dành cho Android hoặc iOS. Folder trên đĩa không trả lời được hai câu hỏi này. Build file mới trả lời được.
Phần 1: Folder Không Phải Module
Mở settings.gradle.kts. Danh sách include(...) có 14 module:
:app
:dre-core
:dre-viewmodel
:kernel
:core
:data
:domain
:secrets
:theme
:ui-components
:feature:game
:feature:menu
:feature:paywall
:shared-app
Nhìn cây thư mục ở root, bạn còn thấy build-logic/, iosApp/, ai/, scripts/, graphics/, docs/... Trong số đó, chỉ build-logic/ tham gia build Gradle, qua dòng includeBuild("build-logic"). iosApp/ là Xcode project: nó dùng output của Gradle nhưng không phải Gradle module.
| Loại folder | Ý nghĩa |
|---|---|
Có trong include(...) | Gradle module thật |
includeBuild("build-logic") | Composite build cung cấp convention plugin |
iosApp/ | App shell iOS, Xcode build nó và link với framework Kotlin |
Không xuất hiện trong settings.gradle.kts | Không thuộc build. Đừng đọc chúng khi đang trace một bug của app |
Quy tắc: settings.gradle.kts là source of truth. Với mọi project Gradle (Android thuần hay KMP), đọc file này đầu tiên.
Phần 2: Mỗi Module Sở Hữu Phần Gì
| Module | Sở hữu phần gì | File nên mở đầu tiên |
|---|---|---|
:app | Android entry point: Application, Activity, WorkManager, notification channels | App.kt, LauncherActivity.kt |
iosApp/ | iOS entry point bằng Swift, rất mỏng | iOSApp.swift, ContentView.swift |
:shared-app | Phần app dùng chung cho cả hai nền tảng: khởi động, danh sách feature, root navigation, Splash. Build ra framework Shared cho iOS | StartSharedApp.kt, AppFeatures.kt, AppNavHost.kt |
:core | Contract dùng chung giữa các module: route data, feature registries, base types, Compose resources | FeatureRegistry.kt, Routes.kt |
:kernel | Nền tảng kỹ thuật thấp nhất, ví dụ dispatchers | QzDispatchers.kt |
:dre-core, :dre-viewmodel | Thư viện state management nội bộ: Reducer, SideEffectHandler, DreStoreViewModel | Reducer.kt, DreStoreViewModel.kt |
:domain | Use cases, repository và service contracts, business models | domain/.../usecase/, domain/.../service/ |
:data | Implementation cho các contract: Supabase, Ktor, DataStore, RevenueCat... | DataModule.kt |
:secrets | Ranh giới truy cập secrets | SecretManager.kt |
:theme | Design system tokens và app theme | QzdsTheme.kt, QzdsColorTokens.kt |
:ui-components | Compose components dùng chung giữa nhiều screen | ui-components/.../ui/component/ |
:feature:* | User flows: screens, feature ViewModels, navigation entry của feature | feature/game, feature/menu |
:feature:paywall có trong build nhưng premium gate mặc định tắt, nên app không điều hướng tới paywall.
Phần 3: Chiều Dependency
Mở app/build.gradle.kts và shared-app/build.gradle.kts, rồi so phần dependencies:
:app -> :shared-app, :domain, :secrets
(debug: :theme, :ui-components, :feature:paywall; test: :core)
:shared-app -> :feature:game, :feature:menu, :feature:paywall, :core, :data, :domain, ...
:feature:* -> :core, :domain, :kernel, :theme, :ui-components
(game và menu thêm :dre-core, :dre-viewmodel)
:data -> :domain, :core, :kernel, :secrets
:domain -> :dre-core
Hai điểm đáng nhớ:
:appkhông depend vào feature module nào (trừdebugImplementation(project(":feature:paywall"))cho một màn hình debug).:appcũng không khai báo:data::datavào APK qua:shared-app.:shared-appmới là nơi biết danh sách feature. Lý do: iOS không có:app. Nếu danh sách feature nằm trong:app, iOS sẽ không có feature nào.- Feature không depend vào
:datavà không depend vào nhau. Feature chỉ biết contract trong:domainvà:core. Menu mở được màn game mà không import class nào của game, nhờ navigation contract trong:corevà danh sách provider ở:shared-app.
Mũi tên chỉ đi một chiều, từ app shell xuống feature, xuống domain. Khi Gradle từ chối cho bạn import một class, thường đó là boundary đang làm đúng việc của nó.
Phần 4: Một Module, Nhiều Source Set
Mở feature/menu/src/. Bạn thấy commonMain, commonTest và androidHostTest. Mở data/src/, bạn thấy thêm androidMain và iosMain.
| Source set | Chạy ở đâu | Ví dụ trong repo |
|---|---|---|
commonMain | Android và iOS | Toàn bộ screens, ViewModels, use cases, DataModule.kt |
androidMain | Chỉ Android | DataModule.android.kt: Firebase, SoundPoolAudioManager |
iosMain | Chỉ iOS | DataModule.ios.kt: AvAudioManager, NoOpAnalyticsManager |
commonTest | JVM và iOS Simulator | Test của logic dùng chung |
androidHostTest | JVM trên máy bạn | Test cần thư viện chỉ có trên JVM, ví dụ MockK |
Phần lớn code nằm trong commonMain. Code chỉ rơi xuống androidMain hoặc iosMain khi nó phải gọi SDK của một nền tảng. Ngay cả khi đó, contract vẫn nằm trong commonMain và mỗi nền tảng cung cấp một implementation qua Koin.
Feature module của repo này không có androidMain hay iosMain. Toàn bộ UI viết một lần bằng Compose Multiplatform.
Phần 5: Tên Module Có Thể Nói Dối Về Scope
Mở feature/menu/src/commonMain/kotlin/com/creative/kta/feature/. Bạn sẽ không chỉ thấy menu/:
feature/menu/.../feature/
├── menu/ Menu screen, MenuViewModel, dialog layer
├── settings/ Settings screen, time picker, SettingViewModel
├── leaderboard/ Leaderboard popup, LeaderboardViewModel
└── onboard/ Onboarding overlay, MenuOnboardingViewModel
Một Gradle module đang chứa bốn sub-feature. Cả bốn flow đều bắt đầu từ menu, dùng chung dependency và luôn ship cùng nhau. Tách thành bốn module sẽ tăng build time và độ phức tạp mà không đổi lại gì.
Một module có thể chứa nhiều feature, miễn là chúng gắn chặt với nhau.
Phần 6: Hiểu Boundary Giữa :core, :domain Và :data
| Nhu cầu | Module |
|---|---|
| Route data class, feature registry, base type dùng chung | :core |
| Dispatchers và các primitive kỹ thuật thấp nhất | :kernel |
| Business operation hoặc interface mà feature cần | :domain |
| Code nói chuyện với Supabase, Ktor, DataStore, SDK nền tảng | :data |
Ví dụ: AuthService là contract, nằm ở :domain. AuthServiceSupabaseImpl là implementation, nằm ở :data. Feature yêu cầu authentication mà không cần biết Supabase.
:domain được giữ thuần Kotlin: không Compose, không Koin, không Ktor, không Supabase. Nhờ vậy nó compile cho mọi target mà không cần gì thêm, và test của nó chạy nhanh.
Trong thực tế, ranh giới domain và data không phải lúc nào cũng tuyệt đối. Quá khắt khe dễ dẫn tới over-engineering. Hãy cân nhắc giữa maintainability và tốc độ ship feature.
Phần 7: Hiểu UI Ownership
:feature:*sở hữu product flows: menu, game, settings, leaderboard, onboarding.:ui-componentssở hữu building blocks dùng lại: buttons, dialogs, auth form components, game answer options, menu widgets.:themesở hữu color, typography, spacing, shape, elevation, motion.
Composable chỉ có nghĩa trong một flow thì giữ trong feature module. Nhiều flow cần nó thì đưa vào :ui-components.
Phần 8: Module Được Khai Báo Như Thế Nào
Mở feature/game/build.gradle.kts:
plugins {
alias(libs.plugins.kta.kmp.library)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.compose)
}
kta.kmp.library không phải plugin của Google hay JetBrains. Nó là convention plugin của chính repo, class KmpLibraryConventionPlugin trong build-logic/. Plugin này áp cho mọi module trừ :app, và đặt một lần cho tất cả:
- plugin Kotlin Multiplatform và plugin Android KMP library của AGP 9,
jvmToolchain(17),compileSdk,minSdk,- target Android cùng hai target iOS
iosArm64()vàiosSimulatorArm64(), - host test cho Android (
withHostTest), nguồn gốc của tasktestAndroidHostTest, - detekt.
Vì vậy build file của mỗi module rất ngắn: nó khai báo namespace trong kotlin { androidLibrary { ... } } và danh sách dependency. Module UI thêm hai plugin Compose.
build-logic/ vẫn còn các plugin kta.android.library, kta.android.feature và kta.android.compose từ thời project còn là Android thuần. Hiện không module nào dùng chúng. Khi đọc một project lạ, kiểm tra plugin nào thật sự được áp trong build file, đừng suy ra từ những gì tồn tại trong build-logic/.
Muốn hiểu cấu hình của mọi module, đọc KmpLibraryConventionPlugin một lần. Đây là lợi ích chính của convention plugin, và bạn sẽ gặp lại mô hình này ở hầu hết project KMP nhiều module.
Phần 9: Phân Loại Code Trước Khi Thêm Code
Trước khi thêm code mới, tự hỏi:
- Đây là entry point của một nền tảng?
:appcho Android,iosApp/cho iOS. Giữ cả hai thật mỏng. - Đây là wiring dùng chung cho cả app (khởi động, danh sách feature, root navigation)?
:shared-app. - Đây là contract kỹ thuật dùng chung giữa các module?
:core, hoặc:kernelnếu nó ở tầng thấp nhất. - Đây là business rule hoặc use case?
:domain. - Đây là implementation gọi dịch vụ bên ngoài?
:data. - Đây là UI primitive dùng lại?
:ui-components. - Đây là product flow hoặc screen?
:feature:*tương ứng. - Đây là visual token?
:theme. - Đây là secret handling? Phía sau
:secrets.
Sau khi chọn module, hỏi thêm: code này có gọi SDK của riêng một nền tảng không? Không thì đặt trong commonMain. Có thì đặt contract trong commonMain và implementation trong androidMain/iosMain.
Checklist này tránh lỗi phổ biến: bỏ mọi thứ vào :app chỉ vì dễ tìm. Trong một project KMP, code bỏ vào :app là code iOS không bao giờ thấy.
Checkpoint
Mở settings.gradle.kts, rồi mở một file từ :app, :shared-app, :domain, :data và một module :feature:*.
Bạn hoàn tất bài này khi giải thích được bằng lời của mình:
- vì sao
scripts/vàiosApp/không có trongsettings.gradle.kts, - vì sao danh sách feature nằm trong
:shared-appthay vì:app, - màn hình leaderboard nằm trong Gradle module nào,
- vì sao
DataModule.android.ktvàDataModule.ios.ktcùng tồn tại, - vì sao build file của các feature module ngắn như vậy.