The 150MB Problem
Modern Android apps shipping ML models, localized assets, and rich media routinely breach 100MB APK sizes. Google Play enforces a 150MB compressed APK limit, but users on metered connections abandon downloads above 40MB. The Android App Bundle (AAB) format with Dynamic Feature Modules (DFMs) offers surgical code splitting—yet most teams ship monolithic bundles that miss 60% of the optimization potential.
Dynamic Feature Modules let you defer non-critical features until first use, split by device configuration, or install conditionally. A healthcare app shipping speech models, OCR engines, and video consultation can reduce initial download from 95MB to 18MB by isolating these capabilities into on-demand modules. The architectural cost: explicit module boundaries, dependency inversion, and navigation indirection.
Install-Time vs On-Demand: The Tradeoff Matrix
DFMs support three delivery modes. Install-time modules ship with the base APK for specific device configurations (screen density, ABI). Google Play assembles split APKs automatically—a user with an arm64-v8a device never downloads armeabi-v7a natives, saving 8-12MB per library. This requires zero runtime code changes but offers no user-initiated deferral.
On-demand modules fetch via Play Core Library after install. A photo editor might defer advanced filters, a news app might defer video playback, a fintech app might defer biometric enrollment. The module arrives as a split APK within 2-8 seconds on LTE, installed atomically by the system. Your app must handle three states: not installed, installing, installed. Typical pattern:
val manager = SplitInstallManagerFactory.create(context)
val request = SplitInstallRequest.newBuilder()
.addModule("advanced_filters")
.build()
manager.startInstall(request)
.addOnSuccessListener { sessionId -> /* track progress */ }
.addOnFailureListener { exception -> /* fallback UI */ }Progress callbacks report download bytes and installation phase. You render a skeleton UI during fetch, then navigate to the feature once SplitInstallSessionStatus.INSTALLED fires. The Play Core Library handles retries, WiFi-only constraints, and background downloads if the user navigates away.
Conditional modules install only for users matching device capabilities or user properties—geofence features for GPS-enabled devices, Bluetooth LE modules for compatible hardware, premium features post-purchase. This requires Fused Delivery via Play Console configuration and runtime capability checks.
Module Dependency Architecture
The base module depends on nothing; feature modules depend on base or other features. Gradle enforces this DAG at compile time. A video consultation module might depend on a WebRTC signaling module, which depends on base. Circular dependencies are prohibited—if two features need shared code, extract a library module that both depend on.
Navigation becomes indirect. A base activity can't directly instantiate a feature activity (it's in a separate APK that might not exist). Use Class.forName() reflection or define interfaces in base with implementations in features:
// base module
interface VideoCallLauncher {
fun launch(context: Context, roomId: String)
}
// feature module
class VideoCallLauncherImpl : VideoCallLauncher {
override fun launch(context: Context, roomId: String) {
context.startActivity(Intent(context, VideoCallActivity::class.java).apply {
putExtra("roomId", roomId)
})
}
}
// base module discovers via ServiceLoader or manual registry
ServiceLoader.load(VideoCallLauncher::class.java).first().launch(context, roomId)Dependency injection frameworks like Hilt support DFMs via @InstallIn scoping. Define a feature component that merges into the singleton graph only when the module installs. Resources (layouts, strings, drawables) in feature modules require SplitCompat to resolve at runtime—call SplitCompat.installActivity(this) in feature activities before super.onCreate().
Dex Method Count and Multidex Wins
Android's 64K method reference limit per DEX file forces multidex for large apps. Each DFM ships its own DEX, resetting the counter. A monolithic app with 120K methods requires two DEX files; splitting into base + three features might yield four DEX files of 35K, 28K, 22K, 18K methods. Startup time improves because the primary DEX (base module) is smaller—Dalvik/ART loads and verifies fewer methods before launching the main activity.
Measure method count with ./gradlew app:assembleRelease --scan and inspect the build scan's APK analyzer. Libraries like Firebase, ExoPlayer, and ML Kit contribute 15K-30K methods each. Isolating video playback into a DFM moves ExoPlayer's methods off the critical path, shaving 200-400ms from cold start on mid-range devices.
Testing and CI Pipeline Changes
Local builds via Android Studio assemble all modules by default—enable/disable modules in run configurations for faster iteration. Instrumented tests must install feature modules before exercising feature code. Use SplitInstallHelper in test setup:
@Before
fun installFeatures() {
val manager = SplitInstallManagerFactory.create(context)
val request = SplitInstallRequest.newBuilder()
.addModule("feature_ocr")
.build()
val task = manager.startInstall(request)
Tasks.await(task, 30, TimeUnit.SECONDS)
}CI pipelines must upload AAB artifacts to internal tracks for device-specific testing. Google Play's internal testing track generates split APKs per configuration—test on arm64, x86, hdpi, xxhdpi combinations to catch missing resources or ABI-specific crashes. The Firebase Test Lab supports AAB uploads and dynamic module installation during test execution.
User Experience Patterns
Users tolerate 2-3 second module fetches if you telegraph the delay. A "Preparing advanced filters..." progress bar with determinate percentage is preferable to a spinner. For features requiring >10MB modules (ML models, video codecs), offer a settings toggle to pre-download on WiFi. A speech therapy app shipping 40MB of phoneme models can prompt "Download offline models? 42MB, WiFi recommended" during onboarding, installing the module in the background while the user explores lightweight features.
Handle installation failures gracefully. Network errors, storage exhaustion, and Play Store service interruptions occur. Provide a retry button and a fallback: a news app might defer video playback but offer article text; a healthcare app might defer on-device speech recognition but offer cloud-based alternatives. Monitor failure rates in Firebase Crashlytics—high failure rates in specific regions indicate network infrastructure issues or Play Store availability problems.
Build Configuration and Gradle Setup
Enable dynamic features in the base module's build.gradle:
android {
dynamicFeatures = setOf(":feature_ocr", ":feature_video")
}Each feature module's build.gradle applies com.android.dynamic-feature plugin and declares dependencies:
plugins {
id 'com.android.dynamic-feature'
}
dependencies {
implementation project(':app')
implementation 'com.google.android.play:core:1.10.3'
}Configure delivery in the feature module's manifest:
<manifest xmlns:dist="http://schemas.android.com/apk/distribution">
<dist:module dist:onDemand="true">
<dist:fusing dist:include="true" />
</dist:module>
</manifest>The dist:fusing attribute controls whether the module merges into the universal APK for sideloading or older devices. Set to false for truly optional features; set to true for features that should always be available on non-Play Store installs.
Performance Metrics and Monitoring
Track module installation success rates, download times, and feature adoption in analytics. Key metrics: install success rate (target >98%), P50/P95 download duration, feature usage rate post-install. A low usage rate after installation suggests poor feature discoverability or user regret—consider merging the module back into base or improving the value proposition.
Monitor APK size reductions in Play Console's release dashboard. A well-architected split saves 40-60% of initial download size for users who never activate deferred features. For an e-commerce app, video product tours, AR try-on, and barcode scanning might represent 55MB—deferring these to on-demand modules reduces base size from 78MB to 23MB, increasing install conversion by 15-20% in emerging markets.
When Not to Use DFMs
Avoid DFMs for core user journeys. Splitting authentication, onboarding, or primary navigation into on-demand modules introduces unacceptable latency. Reserve DFMs for features used by