สูตรโมดูลฟีเจอร์แบบไดนามิก
สูตรนี้แสดงวิธีผสานรวมโมดูลฟีเจอร์แบบไดนามิก (DFM) กับ Navigation 3 โปรดตรวจสอบว่าคุณคุ้นเคยกับ Play Feature Delivery อยู่แล้วก่อนที่จะทำตามสูตรนี้
วิธีการทำงาน
ตัวอย่างนี้กำหนดคีย์ 3 รายการสำหรับหน้าจอในโมดูลและตัวเลือกการนำส่ง 3 รายการที่แตกต่างกัน ดังนี้
Home: หน้าจอในโมดูล:appหลักที่แสดงปุ่มเพื่อไปยังหน้าจอจากโมดูลฟีเจอร์แบบไดนามิกInstallTimeModule.Home: หน้าจอในโมดูล:dynamicfeature:installtimeซึ่งติดตั้งในเวลาที่นำส่ง ตัวอย่างเช่น อาจใช้สำหรับโมดูลการเริ่มต้นใช้งานที่มีเนื้อหามากมายซึ่งจำเป็นเมื่อติดตั้งแอปเป็นครั้งแรก แต่สามารถลบได้หลังจากที่การเริ่มต้นใช้งานเสร็จสมบูรณ์OnDemandModule.Home: หน้าจอในโมดูล:dynamicfeature:ondemandซึ่งติดตั้งตามความต้องการ ตัวอย่างเช่น อาจใช้สำหรับโมดูลที่มีขนาดไฟล์ใหญ่ซึ่งคาดว่าจะมีผู้ใช้เพียงส่วนน้อยเท่านั้นที่จะใช้
DynamicModule
DynamicModule เป็นคลาสแอบสแตรกต์ที่ใช้สร้างโมเดลโมดูลฟีเจอร์แบบไดนามิก กล่าวคือ จะจัดเก็บทั้ง moduleName และ entryBuilderClassName สำหรับ DynamicModuleEntryBuilder ที่เกี่ยวข้อง (อธิบายไว้ในส่วนถัดไป)
object ExampleModule : DynamicModule(
entryBuilderClassName = "com.example.module.ExampleEntryBuilder",
moduleName = "example"
) {
@Serializable
data object Screen : AppNavKey
}
DynamicModuleEntryBuilder
โมดูลฟีเจอร์แบบไดนามิกแต่ละโมดูลต้อง มีการใช้งาน DynamicModuleEntryBuilder ซึ่งใช้เพื่อเพิ่มรายการสำหรับโมดูลนั้นลงใน entryProvider ในโมดูล :app หลัก
// Inside :example module
@Suppress("unused")
class ExampleEntryBuilder : DynamicModuleEntryBuilder {
override fun EntryProviderScope<NavKey>.build() {
appEntry<ExampleModule.Screen> {
ExampleScreen()
}
}
}
@Composable
private fun ExampleScreen() {
// ...
}
buildDynamicEntries()
ฟังก์ชันส่วนขยายของ EntryProviderScope ซึ่งแก้ปัญหา DynamicModuleEntryBuilder สำหรับโมดูลนั้นและเรียกใช้ฟังก์ชัน build()
// 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
คลาสที่จัดการการติดตั้งโมดูลฟีเจอร์แบบไดนามิก
หากต้องการไปยังคีย์ที่อยู่ในโมดูลฟีเจอร์แบบไดนามิก ให้ใช้เมธอด installModule ซึ่งจะจัดการการติดตั้งโมดูลที่เกี่ยวข้องโดยอัตโนมัติหากยังไม่ได้ติดตั้ง และเรียกใช้การเรียกกลับเมื่อติดตั้งโมดูลแล้ว หากติดตั้งโมดูลแล้ว ระบบจะเรียกใช้การเรียกกลับนี้ทันที
// 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)
}
)
หากต้องการตรวจสอบความคืบหน้าในการติดตั้ง ให้แนบตัวจัดการลงในคอมโพสได้ DynamicFeatureDownloadProgressDialog ดังนี้
DynamicFeatureDownloadProgressDialog(dynamicFeatureManager)
กฎ Proguard
หากต้องการให้แน่ใจว่าสามารถเข้าถึงคลาสที่อ้างอิงโดย entryBuilderClassName ได้เมื่อเปิดใช้การลดขนาด R8 ให้เพิ่มกฎนี้ลงใน proguard-rules.pro
-keep class * implements com.example.nav3recipes.dynamicfeature.DynamicModuleEntryBuilder {
public <init>();
}
วิธีทดสอบในเครื่อง
หากต้องการทดสอบว่าการใช้งานได้ผลหรือไม่ ให้เรียกใช้แอปและไปยังรายการของโมดูลเป้าหมาย โดยค่าเริ่มต้น Android Studio จะรวมโมดูลฟีเจอร์แบบไดนามิกทั้งหมดไว้ในการกำหนดค่าการเรียกใช้
หากต้องการจำลองการดาวน์โหลดและการติดตั้งโมดูล ให้ใช้ bundletool
เคล็ดลับ: หากคุณติดตั้ง
bundletoolผ่าน Homebrew (brew install bundletool) คุณสามารถแทนที่java -jar bundletool.jarด้วยbundletoolในคำสั่งด้านล่างได้
-
สร้าง AAB ของโปรเจ็กต์
./gradlew :app:bundleDebug -
แปลง AAB ที่สร้างเป็น APKS โดยเปิดใช้การทดสอบในเครื่องโดยใช้
bundletooljava -jar bundletool.jar build-apks --local-testing --bundle <project-path>/app/build/outputs/bundle/debug/app-debug.aab --output app-debug.apks --overwrite -
ติดตั้ง APKS ที่แปลงแล้วโดยใช้
bundletooljava -jar bundletool.jar install-apks --apks app-debug.apks -
เรียกใช้แอปผ่านไอคอน Launcher
ตอนนี้คุณควรจะเห็นกล่องโต้ตอบความคืบหน้าในการดาวน์โหลดเมื่อไปยังรายการโมดูลตามความต้องการเป็นครั้งแรก
หมายเหตุเกี่ยวกับการใช้งาน
DynamicFeatureManager
- สูตรนี้รองรับเซสชันการติดตั้งเพียงเซสชันเดียวในแต่ละครั้ง หากแอปของคุณต้องใช้หลายเซสชันพร้อมกัน คุณควรปรับตัวจัดการเพื่อติดตามหลายเซสชันพร้อมกัน
- สูตรนี้ไม่ได้ใช้การลองใหม่โดยอัตโนมัติสำหรับข้อผิดพลาดต่างๆ เช่น ข้อผิดพลาดของเครือข่ายหรือข้อผิดพลาดภายใน ดูข้อมูลเพิ่มเติมได้ที่ จัดการข้อผิดพลาดของคำขอ
- หากแอปของคุณ เข้าถึงทรัพยากรและเนื้อหาจากโมดูลอื่น โปรดทราบว่าสูตรนี้จะไม่ติดตั้ง
SplitCompatอีกครั้งโดยอัตโนมัติหลังจากติดตั้งโมดูลแล้ว
/* * 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() } }