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

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

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:

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

  1. Koin modules để tạo ViewModels và helpers của feature.
  2. Navigation registration để root NavHost biết hiển thị screen nào.
text
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:

kotlin
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ở:

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

kotlin
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:

FeatureProvides
gameGameTimer, LifelineHelper, GameOverlayQueueManager, GameFeedbackController, GameStateMachine, GameViewModel
menuMenuViewModel, SettingViewModel, LeaderboardViewModel, MenuOnboardingViewModel

Cuối GameFeatureModule.kt:

kotlin
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:

kotlin
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ế:

  1. Mỗi feature có một FeatureNavigationProvider trả về một FeatureNavigationRegistrar.
  2. Registrar biết cách thêm routes của feature vào NavGraphBuilder.
  3. AppNavHost gọi FeatureNavigationRegistry.registrars(appFeatureNavigationProviders).
  4. Trong NavHost, mỗi registrar gọi registerComposable(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:

kotlin
@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

kotlin
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).

kotlin
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

text
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:menu không import GameScreen. 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:menu không tạo NavController riêng. Chỉ có một NavHostController ở root, nên back stack không bị rối.
  • :feature:menu không biết game có transition riêng. GameNavigation.kt tự khai báo enterTransition và popExitTransition cho GameRouteData.

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ỗiRename 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 classThiếu keep rule thì release build mất feature trong khi debug vẫn chạy
Kotlin/Native không có Class.forNameTrê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 viNơi kiểm tra trước
ViewModel không inject đượcKoin 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ỗngFeatureRegistry hoặc FeatureNavigationRegistry vừa require fail
Bấm nút paywall không có gì xảy raPremium gate đang tắt, xem navigateToPaywall trong AppNavigationCallbacks.kt
Feature khác chỉ mở được route bằng cách import UIThay import trực tiếp bằng callback cộng route contract trong :core

Phần 8: Checklist Khi Thêm Feature Mới

  1. Tạo module với alias(libs.plugins.kta.kmp.library), thêm hai plugin Compose nếu có UI, và đặt namespace trong kotlin { androidLibrary { ... } }.
  2. Thêm include(":feature:<tên>") vào settings.gradle.kts.
  3. Khai báo dependency của feature: :core, :domain, :theme, :ui-components... Không depend vào feature khác.
  4. Thêm implementation(project(":feature:<tên>")) vào shared-app/build.gradle.kts.
  5. Tạo Koin module internal và một class implement FeatureModuleInitializer.
  6. Thêm route data vào Routes.kt trong :core nếu module khác cần điều hướng tới feature.
  7. Implement FeatureNavigationProvider.
  8. Thêm initializer và provider vào hai list trong AppFeatures.kt.
  9. 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-patternVì sao hại
Import screen của feature khácTạ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 screenUI biết quá nhiều về navigation
Tạo string route ở nhiều fileMất kiểm tra kiểu của typed navigation
Đặt danh sách feature trong :appiOS không có :app, nên iOS sẽ không có feature
Tìm feature bằng reflection trong code chungKhô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ó?
  • GameRouteData nằ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.forName không dùng được trong project này, và cái gì thay thế nó?
Đăng nhập