Download size is a product decision disguised as a build setting. Every megabyte is a player who does not install, and the first place to look is rarely the one people tune first: compression settings move a download by a few percent, while texture formats move it by half.
What we started with
A 410 MB universal APK: every ABI, every texture format we had ever supported, and a mix of PNG atlases, ETC2 and raw PCM audio. We took it apart with zipinfo before changing anything.
| Content | Before | After | Change |
|---|---|---|---|
| Textures | 268 MB | 121 MB | ASTC 6x6, mips only where sampled, unused atlases removed |
| Audio | 74 MB | 31 MB | Opus at 64 kbps for effects, 96 kbps for music |
| Meshes | 31 MB | 14 MB | 16-bit indices, half-float positions, shared index buffers |
| Native code | 22 MB | 22 MB | One ABI per split, symbols stripped |
| Everything else | 15 MB | 9 MB | Fonts subset, two unused localisations dropped |
| Total | 410 MB | 197 MB | 52 percent smaller |
APK, AAB and splits
An APK is what the device installs. An AAB is what you upload: a bundle from which the store generates per-device APKs, splitting by ABI, screen density and language. If you ship an APK directly, you can still split by ABI yourself, and you should.
android {
defaultConfig {
ndk { abiFilters += listOf("arm64-v8a") }
}
bundle {
abi { enableSplit = true }
density { enableSplit = true }
language { enableSplit = true }
}
}
armeabi-v7a is still worth a second APK for devices older than 2018, but it should not live in the same file as arm64-v8a. On our build, dropping 32-bit ARM from the main artefact saved 9 MB of native code and let the linker drop a second copy of every third-party library.
Density splits matter less for a game that ships its own textures, but they matter for UI resources. Language splits matter only if you localise; a game with English only should not pay for the machinery.
Compression rules
Two rules cover most of it. Do not compress what is already compressed, and do not store what the GPU will not read directly.
android {
androidResources {
noCompress += listOf("astc", "ktx2", "ogg", "opus", "mp3")
}
}
An ASTC texture stored with deflate costs CPU at install time and saves almost nothing: the format is already close to random. The same is true of Opus and of any video file. Declaring them in noCompress makes installation faster and, on modern Android, allows them to be mapped directly instead of copied.
For everything else, the default deflate is fine. Raising the compression level to maximum cost us 40 seconds of build time per release and saved 1.1 MB. That is not nothing, but it is not where the 213 MB went either.
Textures, in detail
The single largest decision was moving from a mix of PNG and ETC2 to ASTC 6x6 with a mip chain on the assets that need one. ASTC is supported on every GPU that matters for a 2020-and-later game; ETC2 remains the fallback for old devices, and on those devices the download can be a separate split.
- Albedo and UI: ASTC 6x6, full mips. Visual difference at phone distance is negligible.
- Normal maps: ASTC 6x6 with a validation pass; some art shows banding at grazing angles, and those few textures stay at 4x4.
- Single-channel masks: ASTC 8x8, or a packed two-channel texture shared by four materials.
- Lightmaps: ASTC 8x8, no mips if the shader never samples at distance.
Mips are a third of the cost of a texture. A full chain on every texture is easy and wasteful; a chain on the 40 textures that are actually sampled at distance, and none on UI, saved 26 MB on its own.
Audio
Raw PCM is the default in many engines because it needs no decoder. It is also 10 MB per minute of stereo 48 kHz audio. Opus at 64 kbps is 0.5 MB per minute, and the decoder cost on a phone is under a millisecond per buffer.
Keep uncompressed audio only for the sounds that must start instantly and are shorter than a second. Everything else goes through Opus, with a preload of the first 200 ms of long tracks so the decoder never runs on the audio callback.
Meshes and code
Sixteen-bit indices are enough below 65 535 vertices per draw, which most mobile meshes are. Half-float positions cost 6 bytes per vertex instead of 12 and are accurate to about a millimetre at the scale of a level. Together they cut our mesh data by 55 percent.
Native code does not shrink much: 22 MB for two ABIs became 22 MB for one, with symbols stripped. Stripping is not optional in release; a 90 MB unstripped library is a bug in the build config, not a feature.
Verifying the result
bundletool build-apks --bundle=game.aab --output=game.apks --mode=universal
zipinfo -l game.apks | sort -k4 -n | tail -20
apkanalyzer apk summary game.apk
Build the bundle, list the largest entries, and check that each one is a format you meant to ship. On a 16 KB page device, run zipalign -P 16 -f -v 4 on the final artefact and confirm the alignment; a misaligned asset is copied at install and costs memory as well as start-up time.
What not to do
- Do not delete mips to hit a number. The shimmer at distance costs more in reviews than the megabytes save in downloads.
- Do not encrypt assets with a custom format unless you also measure the decode cost. A 4 MB asset that has to be decrypted into a 16 MB buffer is a memory regression disguised as protection.
- Do not ship a universal APK “for compatibility”. Split by ABI, and keep one universal build for internal testing only.
- Do not tune compression before you know the content. Our deflate work saved 1.1 MB; the texture work saved 147 MB.
If you want the memory side of the same assets, the memory article has the budgets by device tier. The October digest collects this piece with the thermal and memory work it depends on.