Mobile Computing · Mobile Release
Split APKs and App Bundles: How One Build Becomes Many Downloads
Why an Android App Bundle is not installable, how configuration splits are selected at install time, and what the bundletool size model assumes about the target device.
A universal APK carries every resource variant the developer built. A single build supports all screen densities, all CPU architectures, all languages, and ships all of them to every device, which means the average install contains a majority of bytes the device will never read. An App Bundle replaces that with a description of what was built, from which a per-device set of APKs is generated at delivery time.
The bundle is a container of modules, not an app
An .aab file is a ZIP archive, but not one the platform can install. Its contents are:
BundleConfig.pb, a protobuf describing compression settings and the bundle’s version;base/— the mandatory module, holding manifest, DEX, and resources;- one directory per feature module or asset pack, each with its own manifest fragment;
BUNDLE-METADATA/, an unvalidated area for build metadata such as ProGuard mappings.
Each module directory holds manifest/AndroidManifest.xml (binary XML), dex/, res/, lib/, and assets/, plus resources.pb — a protobuf table mapping each resource to its configuration variants. The bundle is a build artifact for a server-side tool, and no device code path reads it. That is why signing works differently: the bundle is signed with a key for upload identity, and the APKs generated from it are signed for installation. The two signatures cover different byte strings, which is why a bundle can be re-split without invalidating the identity that the store uses to accept updates.
Splitting along the axes the platform can answer
The splitter has to decide, before the app is ever launched, which variants a device needs. It can do that only for axes the device declares through its manifest-visible properties. Three are standard:
ABI. ro.product.cpu.abilist lists the supported instruction sets in preference order. The splitter emits one lib/ split per ABI present in the bundle that the device lists, and for most devices that is one. A 64-bit-only device gets no 32-bit libraries.
Screen density. Density is a bucket rather than a continuous value. Resource qualifiers group densities into ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi, and tvdpi, with a numeric DPI in the resource system. The splitter selects the closest bucket and, depending on the split dimension configuration, may include a neighbouring bucket to cover density changes at runtime, such as a foldable unfolding or a user changing display size.
Language. The device’s locale list, not a single locale, determines which resources.pb entries are needed. Splitting by language produces one split per locale, and an app supporting forty languages ships one of them.
These axes are chosen because they are knowable from device properties at install time and immutable for the lifetime of the install. Anything that can change while the app runs — orientation, available memory, whether the user has a keyboard — cannot drive splitting, because the split set is fixed once the install completes.
{
"optimizations": {
"splitsConfig": {
"splitDimension": [
{ "value": "ABI", "negate": false, "suffixStripping": {} },
{ "value": "SCREEN_DENSITY", "negate": false },
{ "value": "LANGUAGE", "negate": false }
]
}
}
}
The JSON form above is what a split-dimension configuration expresses: a list of dimensions, each optionally negated. Negation is how a bundle suppresses a dimension entirely and produces a universal APK for that axis, which is the escape hatch for devices the splitter cannot classify.
Delivery modes and what they cost at install
A module declares when it must be present. Install-time modules are split and delivered with the base, and their code is in the initial install. On-demand modules are not delivered until the app requests them, which requires the app to handle a missing module: the split install API returns a deferred result that may require user confirmation for a large download, and the app must handle the case where the request is refused. Fast-follow asset packs begin downloading immediately after install completes but do not block the first launch.
The distinction matters for size accounting. A feature delivered on demand does not reduce the install size of the base, but it does reduce the time to first launch, because the initial download is smaller and the deferred bytes are paid later, at the moment the feature is used. It also changes the failure surface: an on-demand module can fail to install for reasons the base never encounters — no network, insufficient storage, a cancelled request — and the app must render something sensible in that state.
What the size estimate actually models
Store listings show a download size computed from the bundle, and that number is a model, not a measurement of the device in the user’s hand. It assumes a representative device profile — a common ABI, a common density, a common locale set — and reports the size of the split set that profile would receive. A device at an unusual density, or one that needs two ABI splits because it supports both, downloads a different number of bytes.
The model also excludes what the store adds: the signature block, any store-level compression the transport applies, and metadata. It is therefore a lower bound on what the user’s storage reports after install, and the gap is typically small but not zero.
Compression interacts with splitting in a way that is easy to get wrong. Splitting already removes bytes, so the marginal benefit of compressing the remainder is lower, and some content compresses badly. Native libraries are the standard example: they are compressed in the APK by default, which means the loader must decompress them into memory or into a temporary file at first use. Marking them uncompressed with the appropriate manifest and bundle configuration trades download size for install size and launch latency, and it is only possible because the splitter knows which lib/ directory belongs to which ABI.
Manifests merge downward, never sideways
Every module carries a manifest fragment, and the merge is one-directional: base is the root, and a feature module’s fragment contributes to the merged manifest of the install that includes it. A feature module cannot declare a component that the base does not have room for in its own manifest, and it cannot raise minSdkVersion above the base’s. What it can do is add activities, services, and intent filters that exist only when the module is installed.
That asymmetry has a consequence for how the app is written. Components in an on-demand module are not present in the package manager’s view until the module is installed, so an intent that names one fails to resolve rather than launching and erroring. Apps that offer a deferred feature therefore have to route through their own dispatch: check availability, request installation, then start the component. Declaring the component in the base manifest and gating its implementation is not equivalent, because the class it names lives in the module.
The resource namespace follows the same rule. Each module can be given its own resource package, in which case its resources are referenced through a generated identifier rather than merged into the base’s table, and the module’s resources cannot be reached by name from the base. Sharing a namespace means sharing a resource table, which means the base must be rebuilt whenever the module’s resources change; separate namespaces decouple the two at the cost of an extra indirection at every lookup.
Where splitting breaks
The first failure is a stale split set. An app installed from a bundle has several APKs whose paths are recorded in the package manager’s state. If an update delivers a different set — a language split removed, an ABI split added because the device was migrated — the package manager must reconcile the old set with the new one rather than install the new over the old. That reconciliation is why install sessions are transactional: a partial replacement leaves an app whose base and splits disagree about resource IDs.
Resource IDs are the second failure. Each split’s resources are assigned IDs from a shared pool at split time, so a split generated for one base is not interchangeable with a split generated for another. An app that keeps a cached copy of an on-demand module across an app update and reinstalls it will reference IDs that no longer exist.
Third, the splitter’s knowledge of the device is fixed at delivery. A device that gains an ABI through a system update, or whose locale list changes when the user adds a language, has a split set that no longer matches its configuration. The platform tolerates this by falling back to resources in the base for the missing configuration, which is why a split app must still contain a default variant of every resource — the split is an optimisation, not a requirement.
Finally, sideloading. An .aab cannot be installed by copying it to a device; producing an installable set requires the splitter, run either by the store or locally by the tool that ships with the Android SDK. That tool can emit a universal APK, a device-specific set given a connected device, or a set of APKs plus a JSON description of the device configuration they target. The last form is what automated test infrastructure consumes, and its existence is a reminder that the mapping from bundle to install is data, not a build step with a fixed output.
References
- About Android App Bundles — Android Developers
- bundletool — Build, manipulate, and inspect Android App Bundles — Android Developers
- APK Signature Scheme v2 — Android Open Source Project
- Android App Bundle format — Android Developers