- Trace hai đường đăng ký: appFeatureInitializers nạp Koin modules và appFeatureNavigationProviders nạp routes
- Giải thích vì sao feature request navigation qua callbacks thay vì import feature khác
- Giải thích vì sao reflection không dùng được trong project KMP và AppFeaturesTest thay thế vai trò kiểm tra
Feature Registration Và Navigation Boundaries
Hiểu app biết có những feature nào qua danh sách tường minh, và điều hướng giữa chúng mà không tạo coupling.
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
Câu hỏi mở đầu: AppNavHost lấy menu screen từ đâu?
Bài này trả lời hai câu: app biết có những feature nào bằng cách nào, và một feature mở feature khác ra sao mà không biến chúng thành dependency của nhau. Bạn cũng sẽ thấy vì sao repo bỏ cách "tự động phát hiện feature bằng reflection" khi chuyển sang Kotlin Multiplatform, và bài học đó áp dụng cho mọi project KMP.
Nhìn Toàn Cảnh Trước
App có ba feature module:
:feature:game
:feature:menu
:feature:paywall
:feature:paywall đi theo đúng cơ chế bên dưới, nhưng premium gate mặc định tắt nên bài này lấy game và menu làm ví dụ.
Một feature muốn xuất hiện trong app phải đóng góp hai thứ:
- Koin modules để tạo ViewModels và helpers của feature.
- Navigation registration để root
NavHostbiết hiển thị screen nào.
feature module
-> cung cấp FeatureModuleInitializer (Koin modules)
-> cung cấp FeatureNavigationProvider (routes)
-> :shared-app liệt kê cả hai trong AppFeatures.kt
-> feature dùng được trên Android và iOS
Phần 1: Danh Sách Feature Viết Tường Minh
Mở shared-app/src/commonMain/kotlin/com/creative/kta/shared/AppFeatures.kt:
val appFeatureInitializers: List<FeatureModuleInitializer> = listOf(
GameFeatureInitializer(),
MenuFeatureInitializer(),
PaywallFeatureInitializer(),
)
val appFeatureNavigationProviders: List<FeatureNavigationProvider> = listOf(
GameNavigationProvider(),
MenuNavigationProvider(),
PaywallNavigationProvider(),
)
Hai list này là toàn bộ tuyên bố của app về việc có những feature nào. Thêm feature nghĩa là thêm một dòng vào mỗi list.
File nằm ở :shared-app vì đây là module duy nhất depend vào mọi feature, và cả Android lẫn iOS đều đi qua nó. :app không depend vào feature nào.
Phần 2: Đường Thứ Nhất, Koin Modules
Mở:
core/src/commonMain/kotlin/com/creative/kta/core/di/FeatureModuleInitializer.kt
core/src/commonMain/kotlin/com/creative/kta/core/di/FeatureRegistry.kt
Contract rất nhỏ:
interface FeatureModuleInitializer {
val koinModules: List<Module>
}
FeatureRegistry.koinModules(initializers) gộp koinModules của mọi initializer thành một list. Nó require list không rỗng và không có initializer trùng, nên lỗi cấu hình nổ ngay lúc khởi động thay vì âm thầm. appKoinModules trong AppKoinModules.kt gọi hàm này với appFeatureInitializers.
Mở feature/game/src/commonMain/kotlin/com/creative/kta/feature/game/di/GameFeatureModule.kt và feature/menu/.../menu/di/MenuFeatureModule.kt:
| Feature | Provides |
|---|---|
game | GameTimer, LifelineHelper, GameOverlayQueueManager, GameFeedbackController, GameStateMachine, GameViewModel |
menu | MenuViewModel, SettingViewModel, LeaderboardViewModel, MenuOnboardingViewModel |
Cuối GameFeatureModule.kt:
class GameFeatureInitializer : FeatureModuleInitializer {
override val koinModules: List<Module> = listOf(gameFeatureModule)
}
gameFeatureModule là internal, nên ngoài :feature:game không ai đụng được vào nó. Thứ duy nhất feature công khai ra ngoài là initializer. ViewModel của feature nằm trong feature module, nên khi một ViewModel không inject được, bạn biết ngay feature nào chịu trách nhiệm.
:feature:menu chứa bốn sub-feature (menu, leaderboard, settings, onboarding), nên MenuFeatureModule provide bốn ViewModel. Một Gradle module, một initializer, nhiều flow.
Phần 3: Đường Thứ Hai, Navigation
Mở core/src/commonMain/kotlin/com/creative/kta/core/navigation/FeatureNavigationRegistry.kt. File chứa ba contract:
interface FeatureNavigationRegistrar {
fun NavGraphBuilder.registerComposable(callbacks: FeatureNavigationCallbacks)
}
interface FeatureNavigationProvider {
val featureNavigationRegistrar: FeatureNavigationRegistrar
}
cùng FeatureNavigationCallbacks ở Phần 5, và object FeatureNavigationRegistry với hàm registrars(providers) kiểm tra không rỗng, không trùng.
Cơ chế:
- Mỗi feature có một
FeatureNavigationProvidertrả về mộtFeatureNavigationRegistrar. - Registrar biết cách thêm routes của feature vào
NavGraphBuilder. AppNavHostgọiFeatureNavigationRegistry.registrars(appFeatureNavigationProviders).- Trong
NavHost, mỗi registrar gọiregisterComposable(navigationCallbacks).
Providers nằm ở GameNavigation.kt và MenuNavigation.kt trong package navigation của từng feature. Feature sở hữu cách đăng ký destination của nó. AppNavHost sở hữu NavController duy nhất.
Phần 4: Route Data Nằm Trong :core
Mở core/src/commonMain/kotlin/com/creative/kta/core/navigation/Routes.kt:
@Serializable
data object MenuRouteData
@Serializable
data object GameRouteData
@Serializable
data object PaywallRouteData
Chúng @Serializable vì app dùng typed navigation của Navigation Compose (bản multiplatform của JetBrains) thay vì string route.
:core là nơi đặt route contract mà nhiều module cần hiểu. :feature:menu cần biết GameRouteData tồn tại, nhưng không cần biết GameScreen trông ra sao.
Phần 5: Callbacks Ngăn Feature Phụ Thuộc Lẫn Nhau
interface FeatureNavigationCallbacks {
fun navigateToMenu()
fun navigateToGame(category: String? = null, isPractice: Boolean = false)
fun navigateToDailyGame()
fun navigateToPaywall()
fun navigateBack()
}
Feature chỉ nói ý định. Root host quyết định cách thực hiện. Phần thực hiện nằm trong shared-app/src/commonMain/kotlin/com/creative/kta/shared/navigation/AppNavigationCallbacks.kt: class AppNavigationCallbacks implement contract này, và AppNavHost tạo nó qua rememberNavigationCallbacks(navController, premiumGate).
override fun navigateToGame(category: String?, isPractice: Boolean) {
navController.navigate(GameRouteData)
}
navigateToPaywall còn là nơi duy nhất kiểm tra PremiumGateConfig.isEnabled. Gate tắt thì callback bỏ qua lệnh điều hướng. Một chỗ kiểm tra áp dụng cho mọi nút mở paywall, trên cả hai nền tảng.
Theo Dấu Một Lần Chuyển Màn Hình
User bấm Play ở Menu
-> MenuViewModel, qua MenuNavigationSideEffectHandler, emit MenuUiEvent.NavigateToGame
-> MenuRoute gọi onNavigateToGame(...)
-> MenuNavigationProvider đã nối onNavigateToGame = callbacks::navigateToGame
-> AppNavigationCallbacks.navigateToGame chạy navController.navigate(GameRouteData)
-> GameNavigationProvider đã register composable<GameRouteData>
-> GameRoute render game
Những gì không xảy ra trong flow trên:
:feature:menukhông importGameScreen. Bạn viết lại toàn bộ UI của game mà menu không phải sửa dòng nào.:feature:menukhông tạoNavControllerriêng. Chỉ có mộtNavHostControllerở root, nên back stack không bị rối.:feature:menukhông biết game có transition riêng.GameNavigation.kttự khai báoenterTransitionvàpopExitTransitionchoGameRouteData.
Phần 6: Vì Sao Repo Bỏ Reflection
Trước khi chuyển sang KMP, app tìm feature bằng reflection: FeatureRegistry giữ tên class dạng chuỗi và gọi Class.forName(...), kèm keep rules trong app/proguard-rules.pro để R8 không xoá các class đó.
Cách đó có ba vấn đề:
| Vấn đề | Hậu quả |
|---|---|
| Compiler không kiểm tra chuỗi | Rename GameFeatureInitializer thì chuỗi vẫn giữ tên cũ. Code compile sạch, feature biến mất lúc chạy |
| R8 không thấy ai gọi class | Thiếu keep rule thì release build mất feature trong khi debug vẫn chạy |
Kotlin/Native không có Class.forName | Trên iOS, cách này không chạy được |
Vấn đề thứ ba quyết định tất cả. Code trong commonMain không dùng được API reflection của JVM. List tường minh trong AppFeatures.kt giải quyết cả ba: compiler kiểm tra tên class, R8 thấy class được tham chiếu nên giữ lại mà không cần keep rule, và code chạy giống nhau trên mọi nền tảng.
Bài học mang theo được: khi một cơ chế dựa vào JVM (reflection, ServiceLoader, annotation processing lúc runtime), nó không đi theo bạn sang Kotlin/Native. Trong project KMP, ưu tiên wiring tường minh mà compiler kiểm tra được.
Cái giá của list tường minh là quên thêm một dòng thì feature không xuất hiện. AppFeaturesTest (shared-app/src/commonTest/) chặn chuyện đó: nó kiểm tra mỗi list có đúng ba entry, chứa game, menu, paywall, và không entry nào trùng.
Phần 7: Debug Theo Hành Vi
| Hành vi | Nơi kiểm tra trước |
|---|---|
| ViewModel không inject được | Koin module của feature, appFeatureInitializers, rồi AppGraphTest |
| Một screen không bao giờ xuất hiện | *Navigation.kt của feature, rồi appFeatureNavigationProviders |
| App crash lúc khởi động với lỗi trùng hoặc rỗng | FeatureRegistry hoặc FeatureNavigationRegistry vừa require fail |
| Bấm nút paywall không có gì xảy ra | Premium gate đang tắt, xem navigateToPaywall trong AppNavigationCallbacks.kt |
| Feature khác chỉ mở được route bằng cách import UI | Thay import trực tiếp bằng callback cộng route contract trong :core |
Phần 8: Checklist Khi Thêm Feature Mới
- Tạo module với
alias(libs.plugins.kta.kmp.library), thêm hai plugin Compose nếu có UI, và đặtnamespacetrongkotlin { androidLibrary { ... } }. - Thêm
include(":feature:<tên>")vàosettings.gradle.kts. - Khai báo dependency của feature:
:core,:domain,:theme,:ui-components... Không depend vào feature khác. - Thêm
implementation(project(":feature:<tên>"))vàoshared-app/build.gradle.kts. - Tạo Koin module
internalvà một class implementFeatureModuleInitializer. - Thêm route data vào
Routes.kttrong:corenếu module khác cần điều hướng tới feature. - Implement
FeatureNavigationProvider. - Thêm initializer và provider vào hai list trong
AppFeatures.kt. - Cập nhật
AppFeaturesTest, rồi chạy./gradlew :shared-app:testAndroidHostTest.
Bỏ bước 4 thì AppFeatures.kt không compile. Bỏ bước 8 thì feature không bao giờ xuất hiện, và chỉ AppFeaturesTest đã cập nhật ở bước 9 mới bắt được lỗi này.
Anti-Patterns
| Anti-pattern | Vì sao hại |
|---|---|
| Import screen của feature khác | Tạo coupling giữa các feature |
Viết mọi route registration trong AppNavHost | :shared-app phình to mỗi khi feature thay đổi |
Truyền NavController vào mọi screen | UI biết quá nhiều về navigation |
| Tạo string route ở nhiều file | Mất kiểm tra kiểu của typed navigation |
Đặt danh sách feature trong :app | iOS không có :app, nên iOS sẽ không có feature |
| Tìm feature bằng reflection trong code chung | Không có trên Kotlin/Native, và compiler không kiểm tra được |
Checkpoint
Bạn hiểu bài này khi trả lời được:
- Class nào provide Koin bindings của game, và file nào đưa nó vào graph?
- Class nào register navigation của game, và hàm nào gọi nó?
GameRouteDatanằm ở module nào, và vì sao không nằm trong:feature:game?- Menu mở game bằng cách nào mà không import class nào của game?
- Vì sao
Class.forNamekhông dùng được trong project này, và cái gì thay thế nó?