Công thức cho mô-đun tính năng động
Công thức này minh hoạ cách tích hợp Mô-đun tính năng động (DFM) với tính năng Điều hướng 3. Hãy đảm bảo rằng bạn đã làm quen với tính năng Phân phối tính năng trên Play để tiếp tục với công thức này.
Cách hoạt động
Ví dụ này xác định 3 khoá cho các màn hình trong 3 mô-đun và tuỳ chọn phân phối khác nhau:
Home: Màn hình trong mô-đun:appchính, hiển thị các nút để chuyển đến các màn hình từ mô-đun tính năng động.InstallTimeModule.Home: Màn hình trong mô-đun:dynamicfeature:installtime, được cài đặt tại thời điểm phân phối. Ví dụ: màn hình này có thể được dùng cho một mô-đun giới thiệu có nhiều thành phần, cần thiết khi ứng dụng được cài đặt lần đầu, nhưng có thể bị xoá sau khi quá trình giới thiệu hoàn tất.OnDemandModule.Home: Màn hình trong mô-đun:dynamicfeature:ondemand, được cài đặt theo yêu cầu. Ví dụ: màn hình này có thể được dùng cho một mô-đun có kích thước tệp lớn mà chỉ một nhóm nhỏ người dùng dự kiến sẽ sử dụng.
DynamicModule
DynamicModule là một lớp trừu tượng được dùng để mô hình hoá một mô-đun tính năng động. Cụ thể, lớp này lưu trữ cả moduleName và entryBuilderClassName cho DynamicModuleEntryBuilder tương ứng (được mô tả trong phần tiếp theo).
object ExampleModule : DynamicModule(
entryBuilderClassName = "com.example.module.ExampleEntryBuilder",
moduleName = "example"
) {
@Serializable
data object Screen : AppNavKey
}
DynamicModuleEntryBuilder
Mỗi mô-đun tính năng động phải bao gồm một cách triển khai DynamicModuleEntryBuilder. Cách triển khai này được dùng để thêm các mục cho mô-đun đó vào entryProvider trong mô-đun :app chính.
// Inside :example module
@Suppress("unused")
class ExampleEntryBuilder : DynamicModuleEntryBuilder {
override fun EntryProviderScope<NavKey>.build() {
appEntry<ExampleModule.Screen> {
ExampleScreen()
}
}
}
@Composable
private fun ExampleScreen() {
// ...
}
buildDynamicEntries()
Hàm mở rộng của EntryProviderScope giúp phân giải DynamicModuleEntryBuilder cho mô-đun đó và gọi hàm build() của mô-đun.
// Inside :app module
val ALL_DYNAMIC_MODULES_MAP = listOf(
ExampleModule,
// ...
).associateBy { it.moduleName }
NavDisplay(
// ...
entryProvider = entryProvider {
// ...
dynamicFeatureManager.installedModules
.mapNotNull { ALL_DYNAMIC_MODULES_MAP[it] }
.forEach { buildDynamicEntries(it) }
}
)
DynamicFeatureManager
Một lớp quản lý quá trình cài đặt mô-đun tính năng động.
Để chuyển đến một khoá có trong mô-đun tính năng động, hãy dùng phương thức installModule. Phương thức này sẽ tự động xử lý việc cài đặt mô-đun tương ứng nếu mô-đun đó chưa được cài đặt và thực thi lệnh gọi lại khi mô-đun được cài đặt. Nếu mô-đun đã được cài đặt, lệnh gọi lại này sẽ được gọi ngay lập tức.
// Inside :app module
// Initialize the manager
val dynamicFeatureManager = retainDynamicFeatureManager()
// E.g. in a Button's onClick
dynamicFeatureManager.installModule(
moduleName = ExampleModule.moduleName,
onModuleInstalled = {
backStack.add(ExampleModule.Screen)
}
)
Để theo dõi tiến trình cài đặt, hãy đính kèm trình quản lý vào thành phần kết hợp DynamicFeatureDownloadProgressDialog:
DynamicFeatureDownloadProgressDialog(dynamicFeatureManager)
Quy tắc Proguard
Để đảm bảo rằng bạn có thể truy cập vào lớp được tham chiếu bởi entryBuilderClassName khi bật tính năng thu gọn R8, hãy thêm quy tắc này vào proguard-rules.pro:
-keep class * implements com.example.nav3recipes.dynamicfeature.DynamicModuleEntryBuilder {
public <init>();
}
Cách kiểm thử cục bộ
Để kiểm thử xem quá trình triển khai có hoạt động hay không, bạn chỉ cần chạy ứng dụng và chuyển đến các mục của mô-đun đích. Theo mặc định, Android Studio sẽ bao gồm tất cả các mô-đun tính năng động trong cấu hình chạy.
Để mô phỏng quá trình tải xuống và cài đặt mô-đun, hãy dùng bundletool.
Lưu ý: Nếu bạn đã cài đặt
bundletoolthông qua Homebrew (brew install bundletool), bạn có thể thay thếjava -jar bundletool.jarbằngbundletooltrong các lệnh bên dưới.
-
Tạo AAB của dự án.
./gradlew :app:bundleDebug -
Chuyển đổi AAB đã tạo thành APKS khi bật tính năng kiểm thử cục bộ bằng
bundletool.java -jar bundletool.jar build-apks --local-testing --bundle <project-path>/app/build/outputs/bundle/debug/app-debug.aab --output app-debug.apks --overwrite -
Cài đặt APKS đã chuyển đổi bằng
bundletool.java -jar bundletool.jar install-apks --apks app-debug.apks -
Chạy ứng dụng thông qua biểu tượng trình chạy.
Giờ đây, bạn sẽ thấy hộp thoại tiến trình tải xuống khi chuyển đến một mục mô-đun theo yêu cầu lần đầu tiên.
Lưu ý khi triển khai
DynamicFeatureManager
- Công thức này chỉ hỗ trợ một phiên cài đặt tại bất kỳ thời điểm nào. Nếu ứng dụng của bạn cần sử dụng nhiều phiên đồng thời, bạn nên điều chỉnh trình quản lý để theo dõi nhiều phiên cùng lúc.
- Công thức này không triển khai tính năng tự động thử lại cho các trường hợp như lỗi mạng hoặc lỗi nội bộ. Hãy xem bài viết Xử lý lỗi yêu cầu để biết thêm thông tin.
- Nếu ứng dụng của bạn truy cập vào tài nguyên và thành phần từ một mô-đun khác, hãy lưu ý rằng công thức này không tự động cài đặt lại
SplitCompatsau khi một mô-đun được cài đặt.
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.dynamicfeature import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.ui.setEdgeToEdgeConfig import kotlinx.serialization.Serializable class DynamicFeatureActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { val backStack = rememberNavBackStack(Home) val dynamicFeatureManager = retainDynamicFeatureManager() DynamicFeatureDownloadProgressDialog(dynamicFeatureManager) NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, modifier = Modifier.fillMaxSize(), entryProvider = entryProvider { appEntry<Home> { HomeScreen( onNavigateToInstallTime = { dynamicFeatureManager.installModule( moduleName = InstallTimeModule.moduleName, onModuleInstalled = { backStack.add(InstallTimeModule.Home) } ) }, onNavigateToOnDemand = { dynamicFeatureManager.installModule( moduleName = OnDemandModule.moduleName, onModuleInstalled = { backStack.add(OnDemandModule.Home) } ) }, ) } dynamicFeatureManager.installedModules .mapNotNull { ALL_DYNAMIC_MODULES_MAP[it] } .forEach { buildDynamicEntries(it) } } ) } } } @Composable fun HomeScreen( onNavigateToInstallTime: () -> Unit, onNavigateToOnDemand: () -> Unit, modifier: Modifier = Modifier, ) { ContentGreen( title = "Home screen", modifier = modifier, ) { Column { Button(onClick = dropUnlessResumed { onNavigateToInstallTime() }) { Text(text = "Go to Install Time Module Screen") } Button(onClick = dropUnlessResumed { onNavigateToOnDemand() }) { Text(text = "Go to On Demand Module Screen") } } } } @Serializable private data object Home : AppNavKey private val ALL_DYNAMIC_MODULES_MAP = listOf( InstallTimeModule, OnDemandModule ).associateBy { it.moduleName } object InstallTimeModule : DynamicModule( entryBuilderClassName = "com.example.dynamicfeature.installtime.InstallTimeEntryBuilder", moduleName = "installtime", ) { @Serializable data object Home : AppNavKey { override fun toContentKey(): Any { return "InstallTimeHome" } } } object OnDemandModule : DynamicModule( entryBuilderClassName = "com.example.dynamicfeature.ondemand.OnDemandEntryBuilder", moduleName = "ondemand" ) { @Serializable data object Home : AppNavKey { override fun toContentKey(): Any { return "OnDemandHome" } } }
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.dynamicfeature import android.content.Context import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.google.android.play.core.ktx.bytesDownloaded import com.google.android.play.core.ktx.errorCode import com.google.android.play.core.ktx.sessionId import com.google.android.play.core.ktx.status import com.google.android.play.core.ktx.totalBytesToDownload import com.google.android.play.core.splitinstall.SplitInstallManagerFactory import com.google.android.play.core.splitinstall.SplitInstallRequest import com.google.android.play.core.splitinstall.SplitInstallSessionState import com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus @Composable fun retainDynamicFeatureManager(): DynamicFeatureManager { val applicationContext = LocalContext.current.applicationContext val manager = retain { DynamicFeatureManager(applicationContext) } DisposableEffect(manager) { onDispose { manager.dispose() } } return manager } sealed interface InstallStatus { data object Idle : InstallStatus data class Pending(val sessionId: Int) : InstallStatus data class Downloading(val sessionId: Int, val progress: Float) : InstallStatus data class Installing(val sessionId: Int) : InstallStatus data class RequiresUserConfirmation(val state: SplitInstallSessionState) : InstallStatus data class Failed(val sessionId: Int, val errorCode: Int) : InstallStatus } @Stable class DynamicFeatureManager(context: Context) { private val splitInstallManager = SplitInstallManagerFactory.create(context) // Maintain a single listener for the lifetime of the manager private val listener = SplitInstallStateUpdatedListener { state -> val sessionId = state.sessionId // Capture session ID if we're expecting an install but haven't got the ID yet (race condition) if (activeSessionId == null && activeModuleName != null) { if (state.moduleNames().contains(activeModuleName)) { activeSessionId = sessionId } } if (sessionId == activeSessionId) { updateStatus(state) } } private var activeSessionId: Int? = null private var activeModuleName: String? = null private var onModuleInstalledCallback: (() -> Unit)? = null var status by mutableStateOf<InstallStatus>(InstallStatus.Idle) private set var installedModules: Set<String> by mutableStateOf(splitInstallManager.installedModules.toSet()) private set init { splitInstallManager.registerListener(listener) } fun dispose() { splitInstallManager.unregisterListener(listener) } fun installModule(moduleName: String, onModuleInstalled: () -> Unit) { if (splitInstallManager.installedModules.contains(moduleName)) { onModuleInstalled() return } // Avoid starting multiple installs if one is already in progress if (status !is InstallStatus.Idle && status !is InstallStatus.Failed) return activeModuleName = moduleName onModuleInstalledCallback = onModuleInstalled // Use a placeholder ID to indicate we are waiting for a session status = InstallStatus.Pending(-1) splitInstallManager.startInstall( SplitInstallRequest.newBuilder().addModule(moduleName).build() ).addOnSuccessListener { sessionId -> if (activeSessionId == null) { activeSessionId = sessionId } // Only update status if it hasn't progressed beyond our placeholder if (status is InstallStatus.Pending && (status as InstallStatus.Pending).sessionId == -1) { status = InstallStatus.Pending(sessionId) } }.addOnFailureListener { status = InstallStatus.Failed(-1, -1) onModuleInstalledCallback = null } } private fun clearSessionState() { activeSessionId = null activeModuleName = null onModuleInstalledCallback = null } private fun updateStatus(state: SplitInstallSessionState) { val sessionId = state.sessionId when (state.status) { SplitInstallSessionStatus.PENDING -> { status = InstallStatus.Pending(sessionId) } SplitInstallSessionStatus.DOWNLOADING -> { val progress = if (state.totalBytesToDownload > 0) { state.bytesDownloaded.toFloat() / state.totalBytesToDownload } else 0f status = InstallStatus.Downloading(sessionId, progress) } SplitInstallSessionStatus.DOWNLOADED -> { status = InstallStatus.Installing(sessionId) } SplitInstallSessionStatus.INSTALLING -> { status = InstallStatus.Installing(sessionId) } SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> { status = InstallStatus.RequiresUserConfirmation(state) } SplitInstallSessionStatus.INSTALLED -> { status = InstallStatus.Idle installedModules = splitInstallManager.installedModules.toSet() onModuleInstalledCallback?.invoke() clearSessionState() } SplitInstallSessionStatus.FAILED -> { status = InstallStatus.Failed(sessionId, state.errorCode) clearSessionState() } SplitInstallSessionStatus.CANCELING -> { status = InstallStatus.Pending(sessionId) } SplitInstallSessionStatus.CANCELED -> { status = InstallStatus.Idle clearSessionState() } SplitInstallSessionStatus.UNKNOWN -> { status = InstallStatus.Failed(sessionId, -1) clearSessionState() } } } fun startConfirmationDialogForResult( state: SplitInstallSessionState, launcher: ActivityResultLauncher<IntentSenderRequest> ) { splitInstallManager.startConfirmationDialogForResult(state, launcher) } fun cancelInstallModule() { activeSessionId?.let { id -> splitInstallManager.cancelInstall(id) } status = InstallStatus.Idle clearSessionState() } }
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.dynamicfeature import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey interface AppNavKey : NavKey { // By default, `toString()` is used to generate the `contentKey` for NavEntries. // Instead of overriding `toString()`, we provide a dedicated `toContentKey()` function to make // it possible to uniquely identify keys for nested objects with the same simple name // (e.g., `ModuleA.Home` vs `ModuleB.Home`), without polluting the `toString()` representation. fun toContentKey(): Any = this.toString() } /** * An extension on [EntryProviderScope] specifically for [AppNavKey]s that overrides the default * `contentKey` resolution. It explicitly resolves the key via `it.toContentKey()` instead of the default * string representation. */ inline fun <reified K : AppNavKey> EntryProviderScope<NavKey>.appEntry( noinline clazzContentKey: (key: @JvmSuppressWildcards K) -> Any = { it.toContentKey() }, metadata: Map<String, Any> = emptyMap(), noinline content: @androidx.compose.runtime.Composable (K) -> Unit, ) { addEntryProvider(K::class, clazzContentKey, { metadata }, content) }
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.dynamicfeature import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun DynamicFeatureDownloadProgressDialog( dynamicFeatureManager: DynamicFeatureManager, modifier: Modifier = Modifier, ) { val status = dynamicFeatureManager.status if (status is InstallStatus.Idle) return val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult() ) { // The session status will be updated automatically via the // SplitInstallStateUpdatedListener in DynamicFeatureManager, // so no explicit handling is required here. } val progress = if (status is InstallStatus.Downloading) status.progress else 0f val progressPercentage = "${(progress * 100).roundToInt()}%" AlertDialog( onDismissRequest = {}, modifier = modifier, title = { Text(text = getStatusTitle(status)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(), ) { if (status is InstallStatus.Downloading) { CircularProgressIndicator( progress = { progress }, modifier = Modifier.semantics { contentDescription = "Downloading: $progressPercentage" } ) Text( text = progressPercentage, style = MaterialTheme.typography.labelSmall, ) } else if (status !is InstallStatus.Failed && status !is InstallStatus.RequiresUserConfirmation) { CircularProgressIndicator() } if (status is InstallStatus.Failed) { Text( text = "Error code: ${status.errorCode}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) } } }, confirmButton = { if (status is InstallStatus.RequiresUserConfirmation) { Button(onClick = { dynamicFeatureManager.startConfirmationDialogForResult( status.state, launcher ) }) { Text(text = "Confirm Download") } } }, dismissButton = { TextButton(onClick = dynamicFeatureManager::cancelInstallModule) { Text(text = "Cancel") } } ) } private fun getStatusTitle(status: InstallStatus): String { return when (status) { is InstallStatus.Pending -> "Requesting..." is InstallStatus.Downloading -> "Downloading..." is InstallStatus.Installing -> "Installing..." is InstallStatus.RequiresUserConfirmation -> "Requires Confirmation" is InstallStatus.Failed -> "Installation Failed" else -> "" } }
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.dynamicfeature import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey /** * Base class for defining a dynamic feature module. * * Caches the [DynamicModuleEntryBuilder] instance to guarantee that the reflection instantiation * via [Class.forName] and [Class.newInstance] occurs exactly once per application process lifetime, * optimizing performance during recompositions when the `entryProvider` block is re-evaluated. */ abstract class DynamicModule( val entryBuilderClassName: String, val moduleName: String, ) { private var dynamicModuleEntryBuilder: DynamicModuleEntryBuilder? = null internal fun getDynamicModuleEntryBuilder(): DynamicModuleEntryBuilder { return dynamicModuleEntryBuilder ?: (Class.forName(entryBuilderClassName) .getConstructor() .newInstance() as DynamicModuleEntryBuilder).also { dynamicModuleEntryBuilder = it } } } /** * Interface that every dynamic feature module must implement to register its navigation entries. * * It is invoked by [buildDynamicEntries] to add the module's routes into the base [EntryProviderScope]. * * **Example:** * ```kotlin * class ExampleEntryBuilder : DynamicModuleEntryBuilder { * override fun EntryProviderScope<NavKey>.build() { * appEntry<ExampleModule.Screen> { * ExampleScreen() * } * } * } * ``` */ fun interface DynamicModuleEntryBuilder { fun EntryProviderScope<NavKey>.build() } fun EntryProviderScope<NavKey>.buildDynamicEntries( module: DynamicModule, ) { with(module.getDynamicModuleEntryBuilder()) { build() } }