Lesson 13 / 19
App Package Formats
The app bundle, the APK and the split APK decide which files reach a device. The lesson compares the number of files that land on a configuration with a budget and shows that the universal package overruns it on every configuration.
Published Updated
In this lesson
- Distinguishes the app bundle, the APK and the split APK in terms of publishing and installation
- Explains how the ABI and the screen density decide which files a device receives
- Compares the number of files that land on a configuration with a budget and classifies the overrun as within, minority, majority or all
- Measures that the universal package delivers every file to every configuration while the split package delivers only the matching ones
Contents
The previous lesson measured code shrinking and obfuscation: how many classes remain in a module after a keep rule. That work happened inside the build. This lesson looks at the build’s output: when the finished package reaches a device, how many files does it carry? The course’s question has not changed: in how many samples does this measurement exceed its budget? The measurement is a number, the budget is the limit on that number, and a sample is the same measurement taken on a different input. The samples that exceed the limit strictly are counted, and the count gives one of four answers: within, minority, majority, all.
// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)
class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
val total: Int = samples.size
val over: Int = samples.count { it > budget.limit }
val verdict: String = when {
over == 0 -> "within"
over == total -> "all"
over * 2 < total -> "minority"
else -> "majority"
}
fun line() {
println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
}
}
fun countOverruns(overruns: List<Overrun>) {
for (name in listOf("within", "minority", "majority", "all")) {
println("$name: ${overruns.count { it.verdict == name }}")
}
}
Overrun counts the samples that exceed the budget. If none exceed it, the answer is within; if all exceed it, the answer is all; if fewer than half exceed it, the answer is minority; every other case is majority, and exactly half is majority. The rule sees how many samples overrun, not by how much or why. The course’s opening lesson classified four measurements with this rule and saw each answer once; this lesson only refers to that distribution.
Three Formats, One Release
In Android, three different things are called a package, and they are often confused. The APK is the file that is installed on a device. The app bundle is the publishing format uploaded to the app store; it is not installed on a device itself. From this bundle the store produces a split APK for each device configuration: a small set of files containing the native libraries that match the device’s processor architecture (ABI), the resources that match its screen density, and the shared code. The universal APK is the format in which this selection is never made: a single set of files carries the libraries of every ABI and the resources of every density, and the same set lands on every device.
Splitting by configuration has three axes: language, screen density and ABI. In the build system, the module build script turns them on or off. The script fragment looks like this.
// Platform code: module build script
android {
bundle {
language { enableSplit = true }
density { enableSplit = true }
abi { enableSplit = true }
}
}
This fragment is platform code; we claim no output, duration or size here. The model sets up the rule of this measurement; the model is not the tool itself. In the model, a package is a list of files and a device configuration is a pair of an ABI and a density.
Each of the three lines in the script answers a separate question. With the abi axis on, native libraries are split into separate files by processor architecture; the device takes the library that matches its architecture and leaves the others. With the density axis on, the resources that depend on screen density are separated in the same way. With the language axis on, language resources are separated and the device asks only for its own languages. If one of the axes is off, that axis’s files stay in the shared part without being separated and land on every device. The lesson does not run this script; in the model it turns the distinction the script expresses into a wants policy and counts the result of the policy.
File List and Configuration
The helper file below builds the package. Each PackageFile is tied either to one ABI, to one density, or to neither. filesForConfig counts how many files land on a configuration; the wants policy decides which files land.
// PackageModel.kt — models the package files and the files that land on a configuration
class PackageFile(val name: String, val abi: String? = null, val density: String? = null)
class DeviceConfig(val label: String, val abi: String, val density: String)
fun buildPackage(abis: List<String>, densities: List<String>): List<PackageFile> =
listOf(PackageFile("code.dex"), PackageFile("manifest.xml")) +
abis.map { PackageFile("library-$it.so", abi = it) } +
densities.map { PackageFile("resource-$it.bin", density = it) }
fun samplePackage(): List<PackageFile> =
buildPackage(listOf("arm64", "arm32", "x86"), listOf("mdpi", "hdpi", "xhdpi", "xxhdpi"))
fun sampleConfigs(): List<DeviceConfig> = listOf(
DeviceConfig("narrow-phone", "arm32", "mdpi"),
DeviceConfig("mid-phone", "arm64", "hdpi"),
DeviceConfig("wide-phone", "arm64", "xhdpi"),
DeviceConfig("tablet", "arm64", "xxhdpi"),
DeviceConfig("old-device", "arm32", "hdpi"),
DeviceConfig("emulator", "x86", "xhdpi"),
)
fun filesForConfig(
files: List<PackageFile>,
config: DeviceConfig,
wants: (PackageFile, DeviceConfig) -> Boolean,
): Int = files.count { wants(it, config) }
val everything: (PackageFile, DeviceConfig) -> Boolean = { _, _ -> true }
val matching: (PackageFile, DeviceConfig) -> Boolean = { file, config ->
(file.abi == null || file.abi == config.abi) && (file.density == null || file.density == config.density)
}
The package is nine files: two shared files, three ABI libraries and four density resources. The configuration list gives six samples. The everything policy ignores the configuration; this is the definition of the universal package. The matching policy asks only for the files that match the configuration’s ABI and density.
The Universal Package
We set the budget at five files. This number is an input to the model: it is not a platform threshold or a store rule, but the limit the lesson chose to ask the question. We measure how many files the universal package delivers to the six configurations.
// Main.kt — the universal package delivers every file to every configuration
fun main() {
val files = samplePackage()
val samples = sampleConfigs().map { filesForConfig(files, it, everything) }
println("package files: ${files.size}")
println("delivered files: $samples")
Overrun("universal-package", Budget("file", 5), samples).line()
}
package files: 9 delivered files: [9, 9, 9, 9, 9, 9] universal-package: budget=file<=5 | over=6/6 -> all
All six samples exceeded the budget: the answer is all. We take this result not from the rule being generous but from the policy ignoring the configuration. An old phone with a narrow screen and a wide tablet both received nine files; this is why the samples list contains no number other than nine. Most of the density resources will never be used on that device.
DI1. The model counts a file as one unit of work and weighs every file equally. In reality a density resource file can be of a very different size than a shared code file; the model does not set up size, compression or download conditions. The file count is a unit of work standing in for size and is not a byte measurement.
The Budget Is an Input to the Model
The same function giving two different overruns under two budgets is not a contradiction; it is the nature of the measurement. We raise the budget to nine: the universal package delivers at most nine files, so no sample strictly exceeds this limit. For the split package, we lower the budget to three: every configuration that receives four files exceeds it this time.
// Main.kt — the same measurement gives two overruns under two budgets
fun main() {
val files = samplePackage()
val configs = sampleConfigs()
val loose = Overrun("universal-package", Budget("file", 9), configs.map { filesForConfig(files, it, everything) })
val strict = Overrun("split-package", Budget("file", 3), configs.map { filesForConfig(files, it, matching) })
loose.line()
strict.line()
countOverruns(listOf(loose, strict))
}
universal-package: budget=file<=9 | over=0/6 -> within split-package: budget=file<=3 | over=6/6 -> all within: 1 minority: 0 majority: 0 all: 1
A sample equal to the limit is not counted as an overrun: the universal package, which delivers nine files, stayed within the budget of nine. The split package delivered four files and came out as all against the budget of three. These two results do not say that a package is good or bad; they only show that the number was compared with the budget. This is why we set the budget at the start of the lesson and state its reason: the file count represents the download work of a configuration, not a package format.
Half Splitting
It is not mandatory to split all three axes together. A package that splits only by ABI still delivers all of the density resources to every device. In the model, this is a policy that matches the ABI but ignores the density.
// Main.kt — a package split only by ABI still delivers the density files
fun main() {
val files = samplePackage()
val abiOnly: (PackageFile, DeviceConfig) -> Boolean = { file, config -> file.abi == null || file.abi == config.abi }
val samples = sampleConfigs().map { filesForConfig(files, it, abiOnly) }
println("delivered files: $samples")
Overrun("abi-only", Budget("file", 5), samples).line()
}
delivered files: [7, 7, 7, 7, 7, 7] abi-only: budget=file<=5 | over=6/6 -> all
Every configuration received seven files: two shared files, a single ABI library and four density resources. Partial splitting reduced nine to seven, but the overrun did not change. The rule does not see that nine is worse than seven; it only sees that both are greater than five.
The Language Axis
The third axis is language, and as we will note in DI2, it is absent from the main model. We imitate it in a separate block, by file name: we add three language files to the package and give each device one or two languages. With the language axis on, a device receives only the files of its own languages; the ABI and density rule stays the same in the matching policy.
// Main.kt — with the language axis on, a device with more than one language receives one more file
fun main() {
val languages = listOf("tr", "en", "de")
val files = samplePackage() + languages.map { PackageFile("lang-$it.bin") }
val deviceLanguages = listOf(
listOf("tr"), listOf("tr", "en"), listOf("en"),
listOf("de", "en"), listOf("tr"), listOf("en"),
)
val samples = sampleConfigs().mapIndexed { index, config ->
files.count { file ->
if (file.name.startsWith("lang-")) file.name.removePrefix("lang-").removeSuffix(".bin") in deviceLanguages[index]
else matching(file, config)
}
}
println("delivered files: $samples")
Overrun("language-axis", Budget("file", 5), samples).line()
}
delivered files: [5, 6, 5, 6, 5, 5] language-axis: budget=file<=5 | over=2/6 -> minority
Four files come from the shared part, the ABI library and the density resource; one language adds one file, two languages add two files. The four single-language devices stayed at five files and did not exceed the budget. The two devices with two languages rose to six and exceeded it. Because two of the six samples overrun, the answer is minority: the count stayed below half. This result shows something interesting. Splitting the package fully does not reduce the file count to a single number independent of the device; the device’s own properties, here how many languages it uses, move the number. Because the limit is set at five, even a single extra language changes the answer.
DI3. The language files are parsed from their names (such as lang-tr.bin) and each language is a single file. In reality a language’s resources can be spread over more than one file; in the model, a language is a file.
Why the Universal Package Exists
The universal package is not a wrong choice; it is a convenience that comes with a different cost. Because it is a single file, it can be handed as is to a teammate, a test device or a distribution channel outside the store, and the question of which configuration will be installed never arises. The store producing split APKs, on the other hand, depends on the store having this configuration information. That is why the two formats live together within the same release: one is for easy hand-to-hand transfer, the other for device-specific download.
The question that decides the choice in practice is this: who will pick the package, and for which configuration? If the answer is “the store, per device,” the split package is the natural one. If the answer is “no one, it will be installed as it is,” the universal package is the only way. The measurement puts the cost of these two cases side by side in the same unit, the file count.
Common Misconceptions
“The app bundle is installed on the device.” What is installed is the APK. The app bundle is uploaded to the store and the store converts it into split APKs according to the configuration. They are not the same file and they are not produced in the same step.
“A split package always delivers fewer files.” It depends on which of the split axes are on. In the half-splitting example seven files landed, in the full split four. If only the ABI axis is left on, all of the density resources still land.
“A split package delivers the same number of files to every device.” For the ABI and density resources in a fully split package this is true, because every device has one ABI and one density. It stops being true when the language axis is turned on: a device with two languages received six files, a device with one language five. As the number of values a device has grows, the number of delivered files grows too.
“The universal package is just a wrong choice.” The universal package is still required for distribution outside the store; the section above explained this with its reason. What is wrong is treating the universal package as the only way in a store release that has a budget, without ever weighing which files should land on which device.
“If the file count shrank, the package shrank.” These two statements are not the same thing. The count shows the effect of the configuration selection; size needs a separate measurement, and it was not made in this lesson.
The Bounding Measurement
This measurement is called bounding because it looks for the best answer the lesson’s question can receive: the budget is the same, the package is the same, the samples are the same, and only the policy that decides which files land changes. The universal package coming out all should not be read as a defect of the package; when the policy ignores the configuration, no other result is possible. The reason we place the bounding measurement separately is to prevent a reader who sees the answer all from concluding that “this budget can never be met.” Showing that it can be met under the same budget also proves that the budget is realistic.
A fully split package for the configuration delivers only the matching files to each configuration. The budget is again five files.
// Main.kt — the split package delivers only the matching files to each configuration
fun main() {
val files = samplePackage()
val samples = sampleConfigs().map { filesForConfig(files, it, matching) }
println("delivered files: $samples")
Overrun("split-package", Budget("file", 5), samples).line()
}
delivered files: [4, 4, 4, 4, 4, 4] split-package: budget=file<=5 | over=0/6 -> within
The same package, the same six configurations, the same budget: the only difference is the policy, and the answer went from all to within. The universal package was this lesson’s all example; the split package is its within counterpart. The format of the package did not decide the result on its own: the same app bundle gave two answers depending on the policy that decides which files land.
There is something the rule cannot see in this result: the size of the files. Four small files and four large files count the same, and nine small files may be less download work than four large files. The model counts the file count and does not set up file size. The file count only makes the effect of the configuration-dependent selection visible.
DI2. The model sets up the configuration as a pair of an ABI and a density. In reality language is also a split axis and a device can have more than one language; the main model has no language axis, and the The Language Axis section above sets it up separately in a single block.
Summary
- This lesson’s overrun: all. The universal package exceeded the budget on six of six configurations, because the policy ignored the configuration and delivered all nine of nine files.
- The app bundle is the publishing format uploaded to the store; the APK is the file installed on a device; the split APK is the set of files that matches a configuration.
- The same measurement changed with the budget: under the budget of nine the universal package came out within, under the budget of three the split package came out all. The budget is an input to the model.
- A package split only by ABI delivered seven files to every configuration and stayed at all.
- With the language axis on, single-language devices received five files and two-language devices six, and the answer was minority; a device’s property moves the number.
- The package split by configuration delivered four files to every configuration and stayed within under the budget of five; the rule sees the file count, not the size.
Next Step
In this lesson we counted which files a build delivered to a device. But not all devices use the latest build: some downloaded the new version, some stayed on the old one. The next lesson, Version Management, measures how many versions behind the latest release the installed version codes of eight devices are.
To keep your progress and take notes, Log in
My notes
Log in to take notes.