Repository navigation
perf: Faster split merge with less memory, and resource table encode speed-ups - #1
Merged
LisoUseInAIKyrios merged 8 commits intoSep 29, 2026
Merged
Conversation
BlockList.sort() registered a swap listener that called setIndex() on both items of every quicksort swap. For items that maintain back references, such as StringItem, each setIndex() triggers onIndexChanged() which rewrites every reference to the item, so a single sort of a large string pool performed O(swaps x references) work while all intermediate index values were immediately overwritten. The updateIndex() pass that already runs after the sort assigns each item its final index exactly once, firing onIndexChanged() a single time per moved item, so the per-swap updates are redundant. Sorting a resource table string pool of a large apk gets several times faster; the resulting order and all reference values are unchanged.
BlockList.sort() ran the full quicksort even when the list was already sorted, costing O(n log n) comparisons per call. The string pools re-sort on every refresh whenever the sort-required flag is set, and operations that preserve order, like removing unused strings, set that flag too, so a resource table refreshed several times during encoding paid for the same sort repeatedly. Use the existing needsSort() linear scan to detect the already-sorted case and return false, matching the previous return value since the quicksort performs no swaps on a sorted list. needsSort() also covers the size() < 2 check. A genuinely unsorted list pays one extra O(n) scan, which is small against the O(n log n) sort that follows.
encodeValues() refreshed every package block right after encoding it, which recomputes sizes, offsets and header fields of the whole package, and re-sorts its string pools. All of that is thrown away: the table is refreshed at the end of scanResourceFiles() and again by ApkModuleEncoder.refreshTable(), and nothing between the per-package refresh and those passes reads the computed sizes or offsets. Keep sortTypes(), which later steps do rely on, and let the table-level refresh do the byte-level bookkeeping once.
TypeBlock.getOrCreateDefinedEntry() started with getEntry(name), which walks every entry of the whole package referencing the spec string with that name and tests each for membership in this type block. While encoding values XML the lookup almost always misses, because the current type block is the one being filled, so each encoded item paid for a walk over all previously encoded configurations of the same resource: quadratic in the number of configurations, and the dominant cost of building a resource table with many locales. The subsequent resolveResourceId() walked the same references again. Resolve the id first (the reference walk stops at the first entry of this type), then address the entry array directly by entry id and compare the name. The scan by name is kept only for the ambiguous case where the defined id already holds a different name. Behavior is unchanged: a defined entry is returned when present, created at its defined id when not, and an undefined name still returns null.
add(index, item) went through slideRight(), which reserved exactly the missing slot, so every insertion reallocated and copied the whole array. Building a type block entry by entry, as merging tables does, was quadratic because of it. Also fix ensureCapacity(int) allocating too little when some capacity is already free: it subtracted the free slots from the request and then added the result to the size instead of to the current length, which could leave fewer free slots than asked for.
ApkBundle.mergeModules() merged the base into an empty module, which copied the whole base table entry by entry into a new TableBlock while the base kept its own parsed copy, and TableBlock.merge() refreshed the whole table after every module. On a large app (Google app, 87 MB resources.arsc) that is two full tables on the heap and one full refresh per split. A table that is only parsed for the merge is now taken over as the merged table when the target has none, a split's parsed table is dropped once merged, and TableBlock.merge(table, refresh) lets the bundle refresh once at the end. The public merge() methods still refresh. The largest table is picked by entry size, so a bundle without a base does not parse every table up front. Taking the base table over also keeps its strings as they are: the copy merged the string pool by XML text, which could turn a plain string containing markup into the styled string of the same text. Also keep a stored dex stored under the name it is renumbered to, instead of deflating it while writing.
Every string and spec string keeps its references in a HashSetStore, which became a java.util.HashSet once it held more than one. A large table has millions of references, so the 32 byte HashMap node per element added up (about 130 MB on an 87 MB resources.arsc). The set is now an open addressing table with linear probing and backward shift deletion, costing one array slot per element, with the same equals/hashCode semantics.
There is an OffsetItem per entry and per string of every table, and each one carried its own 2 or 4 byte array next to the object. The offset (and index for sparse entries) now lives in int fields and is encoded when the item is written, which drops an array per item for every table load. The written bytes are unchanged.
rotundtapir
force-pushed
the
morphe/arsclib-sync
branch
from
September 28, 2026 08:35
0e43605 to
c1d965d
Compare
|
Split skipping before unpacking is already on dev in MorpheApp/morphe-manager@ab112d7, so no need to open a PR for it. It decides from the archive's entry names, but a module whose manifest declares no split is never skipped. On my device (arm64) bundle preparation went from 6.38 s to 5.94 s on Reddit and from 3.89 s to 3.56 s on the Travian .xapk from MorpheApp/morphe-manager#996. Merged output is identical in both. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings in REAndroid#104 and three changes that are being submitted upstream, per MorpheApp/morphe-patcher#225. All 8 commits apply directly on upstream main (b9c2266).
Google app bundle merge: 75 s to 25 s, and it now works in a 640m heap instead of 1280m. Tested against current dev across multiple apps and patch sources with no regressions; details in the issue. Builds on JitPack (checked on 0e43605).
Once merged, I'll open the patcher and manager PRs to switch the pin to the fork.