How we speed up the Chromium engine startup: a resource-remapping story
Remapping 2,450 Chromium resource IDs at startup cost our Android engine about 300 ms. Here's how we moved that work to build time, reducing it to 12 ms.
Our own engine — with blackjack and…
At Aloha, we created a browser designed for private, secure browsing. It includes an ad blocker and powerful features for viewing and downloading media. To ensure reliability and ease of use, these functions are built directly into the engine, either fully or partially.
For desktop and Android, we chose Chromium as the most convenient and popular engine available. This article is about the Android branch, where we work with the android_webview source code. The source code of our engine lives on GitHub. Nobody wants to rebuild Chromium for every minor change. Our main goal was to quickly add new features and iterate fast. Since building Chromium takes a lot of time, we aimed to make the development process faster and more convenient. So we decided to take the android_webview source, customize it, and build it for ourselves as an AAR engine called aloha-core. The engine is built once, and on top of it the browser developers write features in Kotlin — far faster and more comfortably.
If you want the broader history first, read Aloha Core: I love and hate you. The Aloha Core repository is public, and includes the build guide used by the project.
Building the engine
We build the APK as android_webview and then repackage it into an AAR. An AAR is just a zip with a structure AGP (Android Gradle Plugin) understands: AndroidManifest.xml, libs/*.jar, jni/<abi>/*.so, res/, assets/.In short:
- all
*.javac.jarfiles fromobj/for a single architecture are collected recursively intolibs/; - the APK of each architecture is unpacked, and from it we extract:
lib/<abi>/*.so→jni/<abi>/(native libraries, per-arch);- the V8 snapshot
*.bin→assets/with an architecture suffix; - all
*.resources.zipare unpacked into a singleres/;
- the AAR's Android manifest.
After that, everything is published to a Maven repository.

The integration issue
On the browser side (from here on — the host app), this AAR is loaded, and all processes are initialized and started. And this is where it gets interesting.
Because aloha-core is built simply as android_webview.apk, after aapt2 (Android Asset Packaging Tool) the resources will have IDs in this format:

The host app runs its own aapt2 to build its APK. Its resource merger throws everything into one pile: the app's own resources + appcompat + material + ... + our res/ from the AAR. Then link numbers it all together - sequentially, in merge order. min_screen_width_bucket, which got 0x7f0c0008 in the Chromium build, ends up as, say, 0x7f0c0038 in the host's table: the type is still the same - 0c (integer)-, but the entry and the ordering within the type changed, because there are now thousands of foreign records next to it.
However, the bytecode remains unaware of these changes and still references resources using the outdated identifiers. To resolve this, we needed a method to substitute the class's fields with the correct values during runtime.
The simple solution
The first solution was simple and stable: at runtime, right after startup, fetch the new IDs via getIdentifier. It looked like this:
javajavaClass<?> rClass = Class.forName("org.chromium.ui.R$integer").getSuperclass();for (Field f : rClass.getDeclaredFields()) {
f.setAccessible(true);int id = resources.getIdentifier(f.getName(), "integer", "com.alohamobile.browser");
f.setInt(null, id); // 0x7f0c0008 → 0x7f0c0038}
Logically flawless: ask the system, "What is the ID of the resource with this name right now?" and enter the answer into the field. The main issue is the cost associated with each question.
One getIdentifier call is a lookup of a resource by string. There's no reverse name→ID index in .arsc, so under the hood it's a linear scan over the type's records with string-key comparisons (a miss is the worst case - you have to scan the whole type to the end). On a device that's ~120 µs per call. For 2450 fields: 2450 × ~120 µs ≈ 294 ms of pure getIdentifier - and all of it on the main thread, before WebView is first used (noticeably more on low-end devices).
And then, at last, there was time and energy to work on speeding up app startup - and the method that performs the resource remap was the first candidate.
Attempt #1 — shared library (package id 0x00)
The first idea was straightforward. It all starts with building resources.arsc - we do it with the --shared-lib flag.A reminder: a value inside .arsc is always a Res_value structure, not a bare number. It records "what type I am" and "my data". A reference to another resource is a special case: type TYPE_REFERENCE, with the target ID in the data. The file is split into chunks. The chunks are nested; the hierarchy looks like this (simplified but essentially accurate):

ResTable_package is the "resource book of one package". It has a .id field - that top byte, the "package base". This is exactly what --shared-lib zeroes out. In a normal APK it's 0x7f; in a shared-lib it's 0x00.An ID is laid out as 0xPPTTEEEE, which is why lookup is O(1):
PP(package) → pick the rightResTable_package(by matching.id);TT(type) → pick the rightResTable_type(of the right type and the current configuration);EEEE(entry) → this is a direct index into the value array insideResTable_type.
All we need is to correctly control PP.
What --shared-lib gives you
Here's what --shared-lib gave us:ResTable_package.id = 0x00. In a normal APK it's 0x7f. 0x00 is an agreed-upon marker: "base not assigned, it'll be assigned at load time". By itself it "does" nothing - it's a signal for the loader.All internal references are written with a top byte of 0x00. This is needed because a table can contain values that reference another resource within the same table. That's a Res_value of type TYPE_REFERENCE, and its data holds the full target ID - all four bytes 0xPPTTEEEE. In a normal 0x7f-APK the linker would write 0x7f, since the target lives in the same 0x7f package. And that's exactly why such an APK is non-relocatable: its internal references are nailed down to the 0x7f base; load it into a process where it would get a different base, and the references would point into a foreign package.--shared-lib writes 0x00 into these references instead of 0x7f. The meaning of a 0x00 byte in a reference is: "the target is inside me, in this same library, and whatever my base turns out to be is unknown at build time — substitute it at read time". So the references become position-independent too. That's why in the file they stay 0x00 forever, and 0x02 only appears at resolve time.
A RES_TABLE_LIBRARY chunk is added — the "dynamic library export table":
ResTable_lib_header { ResChunk_header; u32 count; } // number of entries
ResTable_lib_entry[count] {
u32 packageId; // build-time id: 0x00 for us
u16 packageName[128]; // UTF-16, "com.android.webview"
}
The meaning: "my name is com.android.webview, and my id at build time was 0x00"

Runtime: four steps
Next we put bromium_webview_apk.ap_ (~1.3 MB) into our AAR under assets/ with the name bromium-resources.apk. During the build the host neither unpacks nor touches it - it just carries it as-is in assets/. All work with the table happens at runtime.
Step 1 - extract(). The APK sits inside assets/, but the resource loader can only work with a file on disk. So we copy bromium-resources.apk from assets/ to filesDir, cached by lastUpdateTime (the second launch skips the copy).
Step 2 - addAssetPath(path) via reflection adds our table to the AssetManager. At exactly that moment, inside native code, BuildDynamicRefTable fires: the loader sees our package (id == 0x00, flagged IsDynamic() because of RES_TABLE_LIBRARY) and assigns it the next free runtime byte, starting from 0x02. Our dynamic package is the only one → it gets 0x02. Right there a DynamicRefTable is set up with the entry "build-time 0x00 → runtime 0x02".
Step 3 - getAssignedPackageIdentifiers(). We ask the AssetManager: which byte did you hand us? It answers 0x2 -> com.android.webview.
Step 4 - onResourcesLoaded(0x02). We rewrite all the fields of the R class. Recall the arithmetic laid out earlier: the fields are baked with 0x7f, the transform is (0x02 ^ 0x7f) << 24 = 0x7d000000, and we apply XOR to every field - 0x7f0c0008 ^ 0x7d000000 = 0x020c0008. Compiled arithmetic, no strings and no reflection - the whole step is ~2 ms.
Note the asymmetry - it's what decides everything in a moment. The R fields are one per process: they're rewritten exactly once (guarded by the sResourcesDidLoad flag) and to a single base. The DynamicRefTable, however, is separate in each AssetManager. The first is global, the second is per-namespace. In attempt #1, these two drift apart, and that's exactly where it breaks.
After launch, a check, and everything works. Or so it seemed to us.
Attempt #1: what went wrong
After a quick manual check, everything functioned correctly and was handed off to the testing team. During testing, a crash occurred when a long-press was made on the text. The issue was caused by the context menu needing to be drawn. The stack trace appears as follows:
longtap text selection
→ SelectionPopupControllerImpl.onCreateActionMode (build the selection menu)
→ DeviceFormFactor.isWindowOnTablet(windowAndroid (phone or tablet? menu layout depends on it)
→ detectScreenWidthBucket(windowAndroid.getContext()) ← here the WINDOW CONTEXT is taken
→ activityResources.getInteger(0x020c0008) (read min_screen_width_bucket)
→ crash: Resources$NotFoundException: Resource ID #0x020c0008
Chromium wants to figure out whether it's looking at a tablet or a phone, to pick the menu layout. For that, it reads the min_screen_width_bucket resource. And it reads it through the window context -windowAndroid.getContext() - which is an Activity context, not the Application one. Almost all the logic went through Application, and here, out of nowhere - A.
The Activity context
But why isn't the ID found through an Activity context? Here's the thing. The AssetManager is not one per process: the Application context has its own, and every Activity has its own separate one. And our addAssetPath (step 2) added the 0x02 table to exactly ONE AssetManager - the one belonging to the Application context, through whose Resources we made the call. Resolution always starts by picking the package by the top byte. The byte 0x02 is correct, but in this namespace there is no package it routes to. The miss is not on the entry - it's on the package itself. And here's the key subtlety: the ID in the crash, 0x020c0008, is already correct - onResourcesLoaded did its job, the top byte 0x02 is in place. It's not the constant that went stale, it's the absence of the table in this AssetManager. This is the inversion of the original bug: before, the constant was wrong and the table was in place; now the constant is right and the table isn't in the required namespace. Exactly the asymmetry from step 4: we rewrote R globally, but addAssetPath'd the table into only one of many AssetManagers.We could manually addAssetPath the table into the AssetManager of every context we can reach. But the set of contexts is unenumerable: createConfigurationContext, an external display / DeX, split-screen - you can't cover them all.
The second defect: the base isn't guaranteed
Worse, even having reached every context, we'd run into a second defect: the 0x02 base isn't guaranteed. Each AssetManager assigns it independently, in the order dynamic packages load within that particular manager. Where a vendor RRO overlay landed earlier (overlays live in the same 0x02–0x7e range), our package would get 0x03 — while the R fields are rewritten globally to a single base. The same "global R vs. per-namespace base" drift, just from the other side. There's no way to patch this hole, so we started looking for another way.

Attempt #2 - merge into the host + a link-time index (the winner)
Since the only way to be guaranteed present in every AssetManager is to be part of the host's 0x7f, let's build on that. We hand the host the raw res/ (not the .arsc, but the source XML), and its aapt2 merges our resources into its own 0x7f package. From that point on, Chromium's resources are ordinary host-app resources, indistinguishable from its own. Since we're now part of 0x7f, whenever the system creates any AssetManager (Application, each Activity, createConfigurationContext, a future one - any at all) from the ApplicationInfo paths, it pulls in 0x7f in its entirety, and us inside it. Both defects of attempt #1 vanish by construction: there are no namespaces left to patch, and there's simply no dynamic base that could drift to 0x03.
But we've come right back to what we were running away from. The host's aapt2, merging our res/ into 0x7f, hands out IDs anew - to everyone together. Our dimen/foo, which got 0x7f07001A in the chromium build, becomes, say, 0x7f070123 in the host's table - because there are now thousands of foreign records next to it (the app itself + appcompat + material…), and the numbering is different. Yet chromium's bytecode still executes getstatic R$dimen.foo, where the field holds the chromium-baked 0x7f07001A.We already rejected the naive approach: getIdentifier on every field is a string lookup, 2450 fields ≈ 294 ms. We need another way.
Piggybacking on the host's aapt2
Here's what we know: the host's aapt2 already turns every name into a final ID. It does this for any @-reference in any layout - that's its regular job, a single pass at build time, with indexes and no runtime cost. So let's slip it a file that references all our resources and pick up the finished numbers at runtime. We're not speeding up the name lookup - we're moving it to the host's build stage, where it already happens for free.
Step 1.
At build time, we generate an index and place a file res/xml/bromium_res_index.xml into the shipped res/:
xml
<index><i n="integer/min_screen_width_bucket" r="@integer/min_screen_width_bucket"/><i n="attr/select_dialog_multichoice" r="?attr/select_dialog_multichoice"/>
... <!– 1216 entries --></index>
n- the key: theRfield name intype/nameform (integer/min_screen_width_bucket). This is what we'll look up later when rewriting. (For styleables, theRfield is calledFoo_Barwhile the resource isFoo.Bar; that's accounted for.)r- a reference to the real resource by its real name. Usually an@-reference (@integer/...), but sometimes?attr/...- we'll come back to this difference in step 3. This is the bait for the host's linker.
Step 2 (the host's aapt2) - the linker stamps the final IDs into the index.
The host builds the app, its resource merger merges our res/ with its own, and aapt2 link compiles everything, including our bromium_res_index.xml, into binary AXML. And here's the interesting part. When aapt2 compiles the XML, the attribute r="@integer/min_screen_width_bucket" stops being a string. In binary AXML an attribute value is stored as Res_value{size, dataType, data}. And @integer/... compiles into dataType = TYPE_REFERENCE, with the final merged ID of that resource placed into data. The passes that are literally named this way in the aapt2 sources - ReferenceLinker / XmlReferenceLinker - resolve every symbolic type/name reference against the symbol table of the merged resources and stamp in the number. Exactly like a static linker: symbol → address. So after the host's build, inside the compiled bromium_res_index.xml, each entry holds: n = "integer/min_screen_width_bucket" (the string key) and r = Res_value{TYPE_REFERENCE, data = 0x7f070123} (the correct host ID).

Step 3 (engine initialization) - AlohaCoreResources.init() rewrites the R fields.
- One!
getIdentifier("bromium_res_index", "xml", pkg)- the single name lookup in the whole remapping. The same expensive one that used to be called 2450 times. res.getXml(id)→XmlResourceParseron top of the nativeResXMLParser. Critically: the AXML blob ismmap'd from the APK and traversed in place - this is not text parsing, but a walk over a ready binary structure with interned strings.- For each entry,
getAttributeResourceValue(null, "r", 0)- takesRes_value.dataof therattribute straight from themmap'd blob; aTYPE_REFERENCEis returned as a number. Zero lookups into the resource table - we don't resolve the resource, we read the ID already stamped into the attribute. We put it into aHashMap<"type/name", id>. A special case is the?attr/...entries: they compile not intoTYPE_REFERENCEbut intoTYPE_ATTRIBUTE, andgetAttributeResourceValuereturns a default for them. For those we usegetAttributeValue, get a string of the form"?<decimal id>", and parse it. A minor fork that doesn't change the essence. - Reflection - we rewrite the
Rfields. For each type:Class.forName("org.chromium.ui.R$string").getSuperclass()yieldsgen.base_module.R$string(per-library R classes are empty subclasses of a common base where the fields are declared once), thenfield.setInt(null, id).
The runtime total: one getIdentifier + a linear scan over the mmap'd blob + reflection (which was in the original too). ~12 ms.

The bottom line
|
Approach |
remap at startup |
|
Original (getIdentifier ×2450) |
300–770 ms |
|
merge + index (compiled XML) |
~12 ms |
Attempt #1 optimized relocation but lost on visibility. Attempt #2 gave up relocation in favor of merging into 0x7f (visibility becomes free and absolute), and it pays down the cost of the merge — the renumbering — by offloading name resolution onto the host's linker and picking up the finished result almost for nothing.