وصفة وحدة الميزات الديناميكية
توضّح هذه الوصفة كيفية دمج وحدة الميزات الديناميكية (DFM) مع Navigation 3. يُرجى التأكّد من الإلمام بميزة Play Feature Delivery لمتابعة هذه الوصفة.
آلية العمل
يحدّد هذا المثال ثلاثة مفاتيح للشاشات في ثلاث وحدات مختلفة وخيارات تسليم مختلفة:
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" جميع وحدات الميزات الديناميكية في إعداد التشغيل تلقائيًا.
لمحاكاة عملية تنزيل الوحدة وتثبيتها، استخدِم أداة bundletool.
ملاحظة: إذا ثبَّتّ
bundletoolمن خلال Homebrew (brew install bundletool)، يمكنك استبدالjava -jar bundletool.jarبـbundletoolفقط في الأوامر أدناه.
-
أنشئ ملف AAB للمشروع.
./gradlew :app:bundleDebug -
حوِّل ملف AAB الذي تم إنشاؤه إلى ملفات APKS مع تفعيل الاختبار المحلي باستخدام
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 -
ثبِّت ملفات APKS التي تم تحويلها باستخدام
bundletool.java -jar bundletool.jar install-apks --apks app-debug.apks -
شغِّل التطبيق من خلال رمز المشغّل.
من المفترض أن يظهر لك مربّع حوار تقدّم التنزيل عند الانتقال إلى إدخال وحدة عند الطلب للمرة الأولى.
ملاحظات حول عملية التنفيذ
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() } }