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

Đọc Module Graph

Đọc project Kotlin Multiplatform như một hệ thống: module, chiều dependency và source set.

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 đọ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:

text
: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.ktsKhô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ì

ModuleSở hữu phần gìFile nên mở đầu tiên
:appAndroid entry point: Application, Activity, WorkManager, notification channelsApp.kt, LauncherActivity.kt
iosApp/iOS entry point bằng Swift, rất mỏngiOSApp.swift, ContentView.swift
:shared-appPhầ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 iOSStartSharedApp.kt, AppFeatures.kt, AppNavHost.kt
:coreContract dùng chung giữa các module: route data, feature registries, base types, Compose resourcesFeatureRegistry.kt, Routes.kt
:kernelNền tảng kỹ thuật thấp nhất, ví dụ dispatchersQzDispatchers.kt
:dre-core, :dre-viewmodelThư viện state management nội bộ: Reducer, SideEffectHandler, DreStoreViewModelReducer.kt, DreStoreViewModel.kt
:domainUse cases, repository và service contracts, business modelsdomain/.../usecase/, domain/.../service/
:dataImplementation cho các contract: Supabase, Ktor, DataStore, RevenueCat...DataModule.kt
:secretsRanh giới truy cập secretsSecretManager.kt
:themeDesign system tokens và app themeQzdsTheme.kt, QzdsColorTokens.kt
:ui-componentsCompose components dùng chung giữa nhiều screenui-components/.../ui/component/
:feature:*User flows: screens, feature ViewModels, navigation entry của featurefeature/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:

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

  • :app không depend vào feature module nào (trừ debugImplementation(project(":feature:paywall")) cho một màn hình debug). :app cũng không khai báo :data: :data vào APK qua :shared-app. :shared-app mớ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 :data và không depend vào nhau. Feature chỉ biết contract trong :domain và :core. Menu mở được màn game mà không import class nào của game, nhờ navigation contract trong :core và 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 setChạy ở đâuVí dụ trong repo
commonMainAndroid và iOSToàn bộ screens, ViewModels, use cases, DataModule.kt
androidMainChỉ AndroidDataModule.android.kt: Firebase, SoundPoolAudioManager
iosMainChỉ iOSDataModule.ios.kt: AvAudioManager, NoOpAnalyticsManager
commonTestJVM và iOS SimulatorTest của logic dùng chung
androidHostTestJVM trên máy bạnTest 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/:

text
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ầuModule
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-components sở hữu building blocks dùng lại: buttons, dialogs, auth form components, game answer options, menu widgets.
  • :theme sở 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:

kotlin
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 task testAndroidHostTest,
  • 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:

  1. Đây là entry point của một nền tảng? :app cho Android, iosApp/ cho iOS. Giữ cả hai thật mỏng.
  2. Đây là wiring dùng chung cho cả app (khởi động, danh sách feature, root navigation)? :shared-app.
  3. Đây là contract kỹ thuật dùng chung giữa các module? :core, hoặc :kernel nếu nó ở tầng thấp nhất.
  4. Đây là business rule hoặc use case? :domain.
  5. Đây là implementation gọi dịch vụ bên ngoài? :data.
  6. Đây là UI primitive dùng lại? :ui-components.
  7. Đây là product flow hoặc screen? :feature:* tương ứng.
  8. Đây là visual token? :theme.
  9. Đâ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ó trong settings.gradle.kts,
  • vì sao danh sách feature nằm trong :shared-app thay vì :app,
  • màn hình leaderboard nằm trong Gradle module nào,
  • vì sao DataModule.android.kt và DataModule.ios.kt cùng tồn tại,
  • vì sao build file của các feature module ngắn như vậy.
Đăng nhập