Solving an AAPT2 Auto-Versioning Mystery

The discovery

APK size is a core performance metric for us. We track it continuously, investigate regressions, and regularly inspect what is actually shipped, not only what exists in source control.

Continuous APK-size monitoring in CI : Every pull request is automatically compared with the develop branch for changes in minimum and maximum APK size, with build artifacts and a detailed APK diff available for investigation.

That habit led us to an unusual discovery: while reviewing the resource breakdown of our APK, we found two directories containing many of the same layout names.

res/layout/
res/layout-v22/

We had not intentionally created API-specific variants for those screens. There were no alternate designs and no API-specific behavior to preserve. Yet both copies were present in the APK delivered to users. The layouts had been duplicated during the build.

Our first step was to confirm this was a real delivery cost, not a local-build artifact. We opened the APK in Android Studio's APK Analyzer and compared the base layout/ directory with its version-qualified sibling. Multiple files appeared in both, with similar sizes.

Before optimization: A sample APK- Two deliberately large XML layouts containing API 26 shorthand attributes appear in both res/layout/ and the AAPT2-generated res/layout-v22/ directory.

We also checked a Play-delivered APK. The duplicate layouts were still there. Although Android App Bundles optimize delivery across dimensions such as ABI, screen density, and language, we could not assume that these version-qualified layouts would disappear. Our measurement showed they were part of what users actually received.

How one XML attribute duplicates an entire layout


AAPT2's automatic resource versioning

AAPT2 compiles and links an Android app's resources. During linking, it can automatically version styles and layouts to preserve compatibility across Android releases.

Suppose an app supports API 21 but a layout uses a framework attribute introduced in API 26. A device running API 21 does not understand that attribute, so AAPT2 protects older devices by producing resource variants: a base layout for API 21 with the unsupported attribute removed and compatible fallbacks generated, and a version-qualified variant that safely retains the newer attribute for devices that understand it. At runtime, Android loads whichever resource matches the device.

This is correct and useful when the newer attribute delivers real functionality older versions can't support. Our case was different: we were triggering the mechanism with convenience attributes whose behavior could be expressed just as well using older, equivalent ones.

Why v22, not v26?

One detail we skipped over above is worth a closer look for the curious reader: the shorthand attributes were introduced in API 26, so why did AAPT2 generate a layout-v22 folder instead of layout-v26?

API 22 is a compatibility boundary in AAPT2. Starting with Android 5.1, the framework can safely ignore unknown public XML attributes. AAPT2 therefore doesn't always use an attribute's introduction level as the generated resource qualifier; for layouts originating below API 22, it effectively chooses:

generated version = min(attribute API level, API 22)
paddingHorizontal = min(26, 22) = 22

This is explicit in the AAPT2 XML compatibility implementation, which records the lower of the attribute's SDK level and SDK_LOLLIPOP_MR1. The accompanying AAPT2 unit test uses paddingHorizontal (an API 26 attribute) and verifies that the generated document is versioned at API 22.

So v22 doesn't mean paddingHorizontal was introduced in API 22. It means API 22 is the earliest platform version on which AAPT2 can safely package that still-unknown public attribute alongside a generated fallback.

The resulting behavior across API levels looked like this:

Device API

Resource and behavior

API 21

Loads layout/. The API 26 shorthand is removed and generated fallback attributes are used.

API 22–25

Loads layout-v22/. The framework ignores the unknown API 26 shorthand and uses the generated fallbacks.

API 26+

Loads layout-v22/. The framework recognizes the shorthand attribute.

Which resource a device loads, and why, across the API-level boundary. 

The four shorthand attributes that triggered it

The common pattern in our layouts looked like this:

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:paddingHorizontal="16dp"
 android:paddingVertical="8dp" />

These attributes are concise and readable, but each was added only in API 26, and each has an older, equally capable explicit form:

Attribute

Compatible explicit form

android:paddingHorizontal

paddingStart + paddingEnd

android:paddingVertical

paddingTop + paddingBottom

android:layout_marginHorizontal

layout_marginStart + layout_marginEnd

android:layout_marginVertical

layout_marginTop + layout_marginBottom

Because our minimum supported API was lower than 26, using even one of these attributes could make AAPT2 generate a versioned copy of the entire layout.

AAPT2 also contains hardcoded degradation rules for these shorthands: for example, it converts paddingHorizontal into paddingLeft and paddingRight for compatibility.

One line in source, two layouts in the APK

This is the part that makes the cost easy to miss: the size penalty isn't proportional to the one attribute. A single shorthand property in a large, data-binding-heavy layout can cause the entire compiled layout to be packaged twice. Conceptually:

<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp" />

...can lead to two packaged resources:

res/layout/example.xml
res/layout-v22/example.xml

A single shorthand attribute is enough to duplicate the entire compiled layout.

AAPT2 doesn't know we'd be equally happy authoring the layout directly with paddingStart and paddingEnd. It only sees a framework attribute that requires compatibility processing, so the duplication was not an AAPT2 bug. It was the correct output for an unnecessary input.

The fix

We replaced the API 26 shorthand with explicit attributes already supported by our minimum SDK:

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />

For symmetric spacing this is a direct swap, and using start/end also preserves correct behavior in right-to-left layouts. In a few places, the audit revealed that the shorthand spacing was redundant because a parent container or style already supplied it, so we removed those declarations instead of mechanically expanding them.

We made the changes in small batches. The first focused change covered 11 high-impact layouts. Follow-up changes expanded the cleanup to other features. For each batch, we checked:

  • The before-and-after APK resource tree.
  • The APK size under the same build configuration, not just source-file size.
  • Visual parity for the affected screens.
  • That the unnecessary layout-v22 entries had disappeared.
After optimization: The same sample layouts after replacing shorthand attributes with explicit directional attributes. Each file is now packaged only once under res/layout/, and the generated layout-v22 copies are no longer present.

The broader cleanup ultimately reduced the APK by around 350 KB. Our initial sample had suggested an 80-100 KB opportunity; expanding the audit beyond the first set of layouts showed the pattern was more widespread, and the verified result was roughly 3.5 times the upper end of that early estimate. That number came from removing small, repeated costs that no user benefited from, rather than from deleting a major feature.

The verified reduction came in well above the initial sample-based estimate.

Why we didn't just disable auto-versioning

AAPT2 exposes a --no-auto-version option, but disabling the mechanism globally would have been the wrong abstraction for this problem. Automatic versioning exists for compatibility: other resources may legitimately use newer framework behavior and need an older fallback. Turning it off globally would trade a visible size reduction for subtle rendering differences or compatibility bugs.

We also considered relying on the App Bundle, but our Play-delivered APK inspection had already shown the duplicate layouts reached users. A build-time transformation was another option, but it would hide the authored resource from developers and add build-system complexity. Changing the XML at the source was transparent, testable, and easy to review.

Prevention: a lint rule, not a one-time fix

A one-time migration would save size today and slowly lose it again as new layouts were added. So we encoded the learning in a custom Android lint rule: MarginPaddingShorthand.

The rule reports an error when a layout uses any of the four API 26 shorthand attributes and offers an auto-fix to expand it to the compatible directional form. Because the check runs in CI, a pull request cannot quietly reintroduce the same duplication.

<!– Reported by MarginPaddingShorthand -->
<TextView android:paddingHorizontal="16dp" />

<!– Accepted -->
<TextView

 android:paddingStart="16dp"
android:paddingEnd="16dp" />

This turned an APK-size investigation into a repository-wide invariant.

From a single anomaly to a durable, CI-enforced guardrail.

Lessons

  • Inspect outputs, not just inputs. The source tree looked entirely normal; the problem was only visible in the packaged artifact. For APK-size work, the APK is the ground truth, not source-level intuition.
  • Convenience syntax can have package-level consequences. Two XML forms can render identical pixels and still produce different artifacts, because resource qualifiers and AAPT2 transformations sit between source and the final APK.
  • A fix that isn't automated doesn't last. Documentation helps, but a CI lint error with an auto-fix meets developers at the exact moment they'd reintroduce the pattern, which is what makes a saving durable.

Closing the case

The duplicated layouts weren't checked into the repository, and they weren't created by a rogue dependency. They were generated by AAPT2 to solve a legitimate compatibility problem, one that our choice of XML attributes had accidentally requested.

Replacing four convenient API 26 shorthands with explicit, min-SDK-safe attributes removed the need for those generated variants: approximately 350 KB less APK size, no user-facing behavior change, and a lint rule that keeps the saving intact.

Sometimes APK optimization means rethinking architecture or removing heavy dependencies. Sometimes it means opening APK Analyzer, following a duplicate filename, and discovering that a single line of XML is larger than it looks.

If you're working through similar APK-size or build-output investigations, we'd be glad to compare notes.

References