Receita do módulo de recursos dinâmicos
Esta receita demonstra como integrar o módulo de recursos dinâmicos (DFM, na sigla em inglês) com a Navigation 3. Confira se você já conhece a Play Feature Delivery para continuar com esta receita.
Como funciona
Este exemplo define três chaves para telas em três módulos e opções de entrega diferentes:
Home: uma tela no módulo principal:appque mostra botões para navegar pelas telas dos módulos de recursos dinâmicos.InstallTimeModule.Home: uma tela no módulo:dynamicfeature:installtime, que é instalado no momento da entrega. Por exemplo, isso pode ser usado para um módulo de integração com muitos recursos que é necessário quando o app é instalado pela primeira vez, mas que pode ser excluído após a conclusão da integração.OnDemandModule.Home: uma tela no módulo:dynamicfeature:ondemand, que é instalado sob demanda. Por exemplo, isso pode ser usado para um módulo com um arquivo grande que apenas um pequeno subconjunto da base de usuários deve usar.
DynamicModule
DynamicModule é uma classe abstrata usada para modelar um módulo de recursos dinâmicos. Ou seja, ela armazena o moduleName e o entryBuilderClassName para o DynamicModuleEntryBuilder correspondente (descrito na próxima seção).
object ExampleModule : DynamicModule(
entryBuilderClassName = "com.example.module.ExampleEntryBuilder",
moduleName = "example"
) {
@Serializable
data object Screen : AppNavKey
}
DynamicModuleEntryBuilder
Cada módulo de recursos dinâmicos precisa incluir uma implementação de DynamicModuleEntryBuilder, que é usada para adicionar as entradas desse módulo ao entryProvider no módulo principal :app.
// Inside :example module
@Suppress("unused")
class ExampleEntryBuilder : DynamicModuleEntryBuilder {
override fun EntryProviderScope<NavKey>.build() {
appEntry<ExampleModule.Screen> {
ExampleScreen()
}
}
}
@Composable
private fun ExampleScreen() {
// ...
}
buildDynamicEntries()
Uma função de extensão de EntryProviderScope que resolve o DynamicModuleEntryBuilder para esse módulo e chama a função 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
Uma classe que gerencia a instalação do módulo de recursos dinâmicos.
Para navegar até uma chave contida em um módulo de recursos dinâmicos, use o método installModule, que processa automaticamente a instalação do módulo respectivo, caso ele ainda não esteja instalado, e executa um callback quando o módulo é instalado. Se o módulo já estiver instalado, esse callback será invocado imediatamente.
// 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)
}
)
Para monitorar o progresso da instalação, anexe o gerenciador ao elemento combinável DynamicFeatureDownloadProgressDialog:
DynamicFeatureDownloadProgressDialog(dynamicFeatureManager)
Regras do ProGuard
Para garantir que a classe referida pelo entryBuilderClassName possa ser acessada quando a minificação do R8 estiver ativada, adicione esta regra ao proguard-rules.pro:
-keep class * implements com.example.nav3recipes.dynamicfeature.DynamicModuleEntryBuilder {
public <init>();
}
Como testar localmente
Para testar se a implementação está funcionando, basta executar o app e navegar pelas entradas do módulo de destino. O Android Studio vai incluir todos os módulos de recursos dinâmicos na configuração de execução por padrão.
Para simular o download e a instalação do módulo, use bundletool.
Dica: se você instalou o
bundletoolpelo Homebrew (brew install bundletool), pode substituirjava -jar bundletool.jarpor apenasbundletoolnos comandos abaixo.
-
Crie o AAB do projeto.
./gradlew :app:bundleDebug -
Converta o AAB criado em APKS com os testes locais ativados usando
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 -
Instale os APKS convertidos usando
bundletool.java -jar bundletool.jar install-apks --apks app-debug.apks -
Execute o app pelo ícone do inicializador.
Agora você poderá ver a caixa de diálogo de progresso do download ao navegar até uma entrada de módulo sob demanda pela primeira vez.
Observações sobre implementação
DynamicFeatureManager
- Esta receita oferece suporte apenas a uma sessão de instalação por vez. Se o app precisar usar várias sessões simultaneamente, adapte o gerenciador para rastrear várias sessões ao mesmo tempo.
- Esta receita não implementa novas tentativas automáticas para erros de rede ou internos. Consulte Processar erros de solicitação para mais informações.
- Se o app acessar recursos e ativos de um módulo diferente, observe que esta receita não reinstala automaticamente o
SplitCompatdepois que um módulo é instalado.
/* * 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() } }