Skip to main content

App Bundles & Dynamic Delivery

Since 2021, Play Store requires Android App Bundles (AAB) for new apps. Beyond the format, Play's dynamic delivery lets you ship features and assets on-demand instead of bundling them into the base APK. This chapter covers AAB structure, split APKs, Play Feature Delivery, Play Asset Delivery, and Play Instant.

Why App Bundle replaced APK

One APK had to contain every variant:

  • Every ABI (arm64, armv7, x86, x86_64)
  • Every density (ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
  • Every language
  • Every feature

The result: 50+ MB APKs where a single user only used ~10 MB.

App Bundle is the blueprint. Google Play generates per-device APKs at install time — only the ABI, density, and language the user needs.

Typical size savings

AppAPK sizeBundle-generated APK
50 MB AAB, global app50 MB15-20 MB (per user)
200 MB game, global200 MB60-80 MB
Typical indie app15 MB6-8 MB

30-60% size reduction for free.


Building an AAB

./gradlew bundleRelease
# Output: app/build/outputs/bundle/release/app-release.aab
// app/build.gradle.kts
android {
bundle {
language {
enableSplit = true // split by language
}
density {
enableSplit = true // split by density
}
abi {
enableSplit = true // split by ABI (native code)
}
}
}

All three should be enableSplit = true (the default) for maximum size savings.


Testing AAB locally with bundletool

# Install bundletool: brew install bundletool

# Generate per-device APKs for your device
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app.apks \
--ks=release.keystore \
--ks-pass=pass:$KEYSTORE_PASSWORD \
--ks-key-alias=$KEY_ALIAS \
--key-pass=pass:$KEY_PASSWORD \
--mode=universal # or: default (per-device splits)

# Install on connected device
bundletool install-apks --apks=app.apks

--mode=universal produces a single APK containing everything — for CI debugging and Firebase Test Lab. default produces a zip of per-device APKs matching what Play would deliver.


Play Feature Delivery

Ship features as Dynamic Feature Modules that users download on demand:

Module setup

// feature/premium-analytics/build.gradle.kts
plugins {
alias(libs.plugins.android.dynamic.feature)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}

android {
namespace = "com.myapp.premium.analytics"
}

dependencies {
implementation(project(":app")) // dynamic features depend on :app
implementation(projects.core.ui)
implementation(projects.core.domain)
}
<!-- feature/premium-analytics/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution">

<dist:module
dist:instant="false"
dist:title="@string/premium_analytics_title">
<dist:delivery>
<dist:on-demand/> <!-- download at runtime -->
</dist:delivery>
<dist:fusing dist:include="true"/> <!-- include for pre-API 21 devices -->
</dist:module>
</manifest>

Delivery modes

ModeWhen downloaded
<install-time>At install; part of the base (not really dynamic)
<on-demand>When your app requests it
<conditional>If conditions match (device features, country, min SDK)
<instant>For Google Play Instant apps

Conditional example:

<dist:delivery>
<dist:install-time>
<dist:conditions>
<dist:min-sdk dist:value="26"/>
<dist:device-feature dist:name="android.hardware.camera.ar"/>
<dist:user-countries dist:include="true">
<dist:country dist:code="US"/>
<dist:country dist:code="CA"/>
</dist:user-countries>
</dist:conditions>
</dist:install-time>
</dist:delivery>

Requesting on-demand modules

implementation("com.google.android.play:feature-delivery-ktx:2.1.0")

class PremiumFeatureLoader @Inject constructor(
@ApplicationContext private val context: Context
) {
private val manager = SplitInstallManagerFactory.create(context)

suspend fun installAnalytics(): Result<Unit> = suspendCancellableCoroutine { cont ->
val request = SplitInstallRequest.newBuilder()
.addModule("premium-analytics")
.build()

manager.startInstall(request)
.addOnSuccessListener { sessionId ->
// Track progress via status updates (below)
cont.resume(Result.success(Unit)) {}
}
.addOnFailureListener { error ->
cont.resume(Result.failure(error)) {}
}
}

fun observeProgress(): Flow<InstallProgress> = callbackFlow {
val listener = SplitInstallStateUpdatedListener { state ->
trySend(InstallProgress.from(state))
}
manager.registerListener(listener)
awaitClose { manager.unregisterListener(listener) }
}
}

After install — use the module

// In dynamic feature module, register an activity
<activity android:name="com.myapp.premium.analytics.PremiumActivity"/>

// Main app — launch via reflection or Intent
context.startActivity(Intent().setClassName(
context.packageName,
"com.myapp.premium.analytics.PremiumActivity"
))

User-visible flow

Play downloads the module (~1-5 MB). The user sees a progress bar (no dismissable dialog). After install, your code proceeds.

If the network is slow, offer a meaningful UX:

lifecycleScope.launch {
loader.observeProgress().collect { progress ->
when (progress) {
is Downloading -> showBar(progress.percent)
is RequiresUserConfirmation -> manager.startConfirmationDialogForResult(progress.state, activity, REQ_CODE)
is Installed -> openFeature()
is Failed -> showRetry()
}
}
}

When to use dynamic features

Good candidates

When dynamic pays off

  • Large feature (>1 MB) used by minority
  • Premium / paid features
  • Region-specific (country-conditional)
  • AR / ML features that need extra libraries
  • Rarely-used power-user tools
  • Instant-app experience modules
Bad candidates

Skip dynamic

  • Core features (everyone uses them)
  • Small features (< 200 KB)
  • Frequently used — loading delay hurts UX
  • Teams < 3 engineers (complexity cost > benefit)
  • Apps distributed outside Play Store
  • When Play Instant / install-time conditions suffice

Play Asset Delivery (PAD)

For large assets (game textures, audio, video, ML models) — up to 4 GB total per asset pack. Three delivery modes:

ModeSize limitBehavior
install-time1 GBBundled at install
fast-follow512 MB total + per-pack 150 MBDownloads right after install
on-demand4 GB total + per-pack 1.5 GBOn request

Setup — asset pack module

// asset_pack/level_data/build.gradle.kts
plugins {
id("com.android.asset-pack")
}

assetPack {
packName = "level_data"
dynamicDelivery {
deliveryType = "on-demand" // or "install-time" / "fast-follow"
}
}
asset_pack/level_data/
├── build.gradle.kts
└── src/main/assets/level_data/ ← your huge files here
├── level_1.pak
├── level_2.pak
└── textures_hd.pkg
// app/build.gradle.kts
android {
assetPacks += listOf(":asset_pack:level_data")
}

Retrieving assets at runtime

implementation("com.google.android.play:asset-delivery-ktx:2.2.2")

class LevelAssetLoader @Inject constructor(
@ApplicationContext private val context: Context
) {
private val manager = AssetPackManagerFactory.getInstance(context)

suspend fun getLevelFile(): File? {
val state = manager.getPackStates(listOf("level_data")).await()
.packStates()["level_data"] ?: return null

if (state.status() != AssetPackStatus.COMPLETED) {
manager.fetch(listOf("level_data")).await()
}

val location = manager.getPackLocation("level_data")
val assetsFolder = location?.assetsPath()
return File(assetsFolder, "level_1.pak")
}
}

Asset packs ship via Play's CDN — orders of magnitude cheaper than Firebase Storage or a custom CDN for game-sized assets.


Play Instant

Lets users try your app without installing — a stripped-down version launches from a "Try now" button in the Play Store.

Requirements

  • Size limit: 15 MB base instant APK
  • Must declare at least one activity as android:exported="true" with android.intent.action.VIEW
  • App Links configured for instant URL patterns

Setup

<!-- AndroidManifest.xml -->
<manifest ...>
<dist:module dist:instant="true"/>

<application>
<activity
android:name=".InstantActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="myapp.com" android:pathPrefix="/try"/>
</intent-filter>
</activity>
</application>
</manifest>

Testing

# Upload instant-enabled AAB to Play Console → Internal App Sharing
# Click the Internal App Sharing link as "Try now"

Instant apps are ideal for:

  • Game demos
  • Shopping catalogs (click ad → instant cart)
  • One-time tools
  • Progressive "try before install" onboarding

Rare for full apps — the 15 MB limit is tight.


Baseline profiles in AAB

Baseline profiles (see Module 10: Baseline Profiles) ship inside the AAB as app/src/main/baseline-prof.txt. Play generates per-device APKs that include the profile — users get the cold-start boost automatically.


Minimizing AAB size

Beyond splits:

1. R8 full-mode

// gradle.properties
android.enableR8.fullMode=true

Full mode strips more aggressively. May reveal keep-rule gaps; add them as needed.

2. Shrink unused resources

buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
}
}

3. Remove unused locales via resourceConfigurations

android {
defaultConfig {
resourceConfigurations += listOf("en", "es", "hi", "ar")
}
}

Drops resources for every other locale — saves MB if you have Android support libraries with translations for 80+ languages.

4. APK Analyzer

Android Studio → Build → Analyze APK. Shows per-file size, lets you compare versions, reveals surprise large assets.

5. classes.dex bloat

Check classes*.dex sizes — a large second DEX often indicates kapt overhead (Hilt, Room). Migrate to KSP for ~30-40% smaller DEXes.

6. Avoid fat libraries

Check AndroidManifest.xml of dependencies for huge activities you're not using. Use tools:node="remove" to strip them:

<application>
<activity android:name="com.example.UnusedActivity" tools:node="remove"/>
</application>

Distributing outside Play Store

For alternative stores (Amazon Appstore, Samsung Galaxy Store, Huawei AppGallery), upload a universal APK built from the AAB:

bundletool build-apks \
--bundle=app-release.aab \
--output=app-universal.apks \
--mode=universal

Some stores accept AAB; many still require APK. Check per-store docs.

For enterprise sideloading (MDM-deployed apps), APK works fine. AAB is Play-specific.


Common anti-patterns

Anti-patterns

AAB / dynamic mistakes

  • Treating dynamic features like install-time modules
  • No fusing for pre-API 21 devices
  • 15 MB game assets in base APK instead of asset pack
  • Testing AAB via adb install-multiple without bundletool
  • Splits disabled to "make debugging easier"
  • Universal APK for Play (loses all size benefits)
Best practices

Modern distribution

  • Dynamic modules reserved for large, optional features
  • <dist:fusing include="true"> for old devices
  • Play Asset Delivery for > 100 MB assets
  • bundletool build-apks → install-apks for realistic tests
  • All splits enabled by default
  • AAB for Play; universal APK only for alt stores

Key takeaways

Practice exercises

  1. 01

    Measure AAB savings

    Build your app as APK and AAB. Run bundletool build-apks on the AAB. Compare sizes — you should see 30%+ reduction.

  2. 02

    Add a dynamic feature

    Extract one optional feature (analytics dashboard, AR mode, admin tools) into a dynamic module with on-demand delivery.

  3. 03

    Asset pack

    Move a large asset (>50 MB) out of base assets and into a fast-follow asset pack. Verify it downloads after install.

  4. 04

    Resource shrink audit

    Enable isShrinkResources, add resourceConfigurations for supported locales. Re-measure AAB size.

  5. 05

    Play Instant test

    Configure your launcher activity for instant delivery (if your app fits in 15 MB). Upload to Internal App Sharing and test via the "Try now" flow.

Next

Return to Module 11 Overview or continue to Module 12 — Advanced & Trending.