Inside Android Resource Loading: From resources.arsc Binary to Runtime Lookup

Last year I hit a subtle bug while building an in-app theming feature. After switching to an Arabic locale and applying a skin, some drawables loaded at the wrong size. The theming logic was fine — the real blind spot was the resource loading pipeline: configuration qualifier matching priority and AssetManager’s caching behavior are far more intricate than they look.

This article walks the full chain from compilation to runtime, focusing on two areas that get less attention: the binary structure of resources.arsc and the native configuration matching algorithm.

Resource Compilation: From XML to resources.arsc

AAPT2 (Android Asset Packaging Tool 2) does two things at compile time: compile and link. The compile phase flattens XML resources into a binary format. The link phase produces the final resources.arsc.

resources.arsc is a giant resource index table. Its internal structure is a three-level tree:

Package (package name)
  └── Type (resource type: string/drawable/layout...)
        └── Entry (resource entry: value + config info)

Each Entry carries a ResTable_config struct describing the configurations it applies to: locale, screen density, orientation, SDK version, and so on. During linking, AAPT2 sorts entries by priority before writing them in, and that sort order determines runtime matching precedence.

Resource IDs generated at compile time are 32-bit integers in the format 0xPPTTEEEE:

  • PP (8 bits): Package ID. System resources are always 0x01; app resources are 0x7F.
  • TT (8 bits): Type ID, e.g. string=0x01, drawable=0x04.
  • EEEE (16 bits): Entry ID, the resource’s index within its type.

This ID exists as a constant in R.java, but the actual index lookup relies on offset positioning inside the arsc, not a direct array subscript.

Runtime Entry Point: Resources and AssetManager

The typical way to fetch a resource:

String appName = context.getResources().getString(R.string.app_name);

Resources is a facade. The real lookup engine is the C++ layer’s AssetManager2 (introduced in Android 10, replacing the legacy AssetManager). The call chain:

Resources.getString(id)
  → Resources.getText(id)
    → AssetManager.getResourceValue(id)
      → Native AssetManager2::GetResource()

After receiving a resource ID, GetResource() parses out the Package ID and Type ID, locates the corresponding Type in the arsc index tree, then iterates the Entry list matching against the current device configuration.

One easily overlooked detail: Resources.getXxx() defaults to the current Activity’s Configuration, but a custom Context created via createConfigurationContext() shares the same ApkAssets collection at the native level — only the Java-layer Configuration object differs. Some vendor ROMs customize AssetManager caching, and resources may not refresh immediately after updateConfiguration(). The compatibility workaround is to explicitly call Resources.updateConfiguration() to force a native-layer config sync.

Configuration Matching: Scoring, Not Priority Lists

Android’s configuration matching follows a best-match principle, not a simple ordered selection. AssetManager2 uses a scoring system: each config dimension’s match quality is quantified, and the entry with the highest total score wins.

The core logic lives in ResourceUtils.cpp’s isMoreSpecificThan():

// Match precision (higher = preferred)
// MCC/MNC: exact match > unspecified
// Language+Region: exact > language-only > unspecified
// Density: exact > nearest bucket
// Screen size: exact > range > unspecified

When two entries tie on total score, the write order from AAPT2’s compile step breaks the tie — an implicit rule. I’ve hit this in practice: values-en and values-ldpi each had a dimens.xml, and on a low-density English device both scored identically. The winner was whichever AAPT2 wrote to the arsc first. The takeaway: do not let resource values overlap across different config dimensions, or the match result becomes unpredictable.

Caching: A Two-Tier Model

The resource system has two cache layers, addressing performance at different levels.

ResTable string pool and mmap

When resources.arsc is loaded, its string pool is mmap’d into memory in full. AssetManager2 uses memory mapping for zero-copy reads — the native ResStringPool returns a pointer directly, with no data copying. This pays off significantly when you have a large number of string resources.

Resources-layer resource cache

TypedArray.getXxx() goes through the full native lookup every time and does not cache results itself. But the Java layer’s ResourcesImpl keeps WeakReference caches for specific types:

// Inside ResourcesImpl.java
private final DrawableCache mDrawableCache = new DrawableCache();
private final ColorStateListCache mColorStateListCache;

Two consecutive requests for the same Drawable hit the Java cache on the second call and never descend to native. But ColorStateList does not go through this path — it re-parses the arsc every time.

On a real project I hit this: a page loading 50+ ImageViews on first frame took 400ms, because every android:src triggered a full native getDrawable() lookup. The fix was a preloading strategy — calling getDrawable() ahead of time during Application init to warm the cache, bringing first-frame latency under 80ms.

TypedArray Attribute Lookup Chain

Reading attrs in a custom View:

val ta = context.obtainStyledAttributes(attrs, R.styleable.CustomView)
val color = ta.getColor(R.styleable.CustomView_tintColor, Color.BLACK)
ta.recycle()

Behind obtainStyledAttributes() is a three-layer mapping: locating the Styleable’s attribute index, attribute-ID-to-theme-value lookup (first Theme, then defStyleAttr, then defStyleRes), and deserialization of the arsc binary value. Inside TypedArray.getColor(), the dataType field of RES_VALUE drives different branches — TYPE_INT_COLOR_RGB8, TYPE_REFERENCE, and so on each have their own parsing path.

Two Practical Debugging Techniques

Use aapt2 to inspect arsc contents directly and quickly spot resource conflicts:

aapt2 dump resources app-debug.apk | grep "app_name"

The output includes the resource ID, config qualifiers, and actual value — far more efficient than digging through source.

Print AssetManager’s configuration state at runtime:

val config = resources.configuration
Log.d("Res", "density=${config.densityDpi}, locale=${config.locales[0]}")

These two tricks have saved me enormous amounts of triage time when troubleshooting resource override issues in a cross-region team.


The configuration qualifier system is powerful — multi-device adaptation basically relies on it. But the matching algorithm’s implicit behaviors, especially when configs overlap at the boundaries, require developer awareness. AssetManager2’s memory mapping and two-tier caching are well-designed overall, though TypedArray re-querying the native layer on every call is a genuine performance shortcoming under high-frequency attribute reads.

If you need resource hot-reloading or dynamic theming, I recommend bypassing the system Resources framework entirely and building your own resource manager. AssetManager’s caching strategy is not friendly to external injection, and the compatibility cost of hacking it far exceeds the cost of rolling your own — that’s the conclusion I reached after several rounds of trying, offered for your reference.