From fb5bd06dfaa31c6077f795d3223b00ceeb325494 Mon Sep 17 00:00:00 2001 From: rotund_tapir Date: Sun, 27 Sep 2026 14:52:39 +0100 Subject: [PATCH] Merge bundle tables without copying the base table 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. --- .../java/com/reandroid/apk/ApkBundle.java | 18 ++-- .../java/com/reandroid/apk/ApkModule.java | 67 ++++++++++--- .../com/reandroid/arsc/chunk/TableBlock.java | 11 ++- .../com/reandroid/apk/ApkBundleMergeTest.java | 94 +++++++++++++++++++ 4 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 src/test/java/com/reandroid/apk/ApkBundleMergeTest.java diff --git a/src/main/java/com/reandroid/apk/ApkBundle.java b/src/main/java/com/reandroid/apk/ApkBundle.java index f8753ac52..fb4b725e4 100644 --- a/src/main/java/com/reandroid/apk/ApkBundle.java +++ b/src/main/java/com/reandroid/apk/ApkBundle.java @@ -51,7 +51,7 @@ public ApkModule mergeModules(boolean force) throws IOException { if(base == null){ base = getLargestTableModule(); } - result.merge(base, force); + result.merge(base, force, false); AndroidManifestBlockMerger manifestMerger = getManifestMerger(); if (manifestMerger != null) { manifestMerger.reset(); @@ -69,7 +69,7 @@ public ApkModule mergeModules(boolean force) throws IOException { if(signatureBlock == null){ signatureBlock = asb; } - result.merge(module, force); + result.merge(module, force, false); if (manifestMerger != null) { manifestMerger.merge(module.getAndroidManifest()); } @@ -107,17 +107,19 @@ private String generateMergedModuleName(){ } return name; } - private ApkModule getLargestTableModule(){ + private ApkModule getLargestTableModule() throws IOException { + // Compared by entry size, so the tables are not all parsed just to pick one ApkModule apkModule=null; - int chunkSize=0; + long largest=0; for(ApkModule module:getApkModuleList()){ if(!module.hasTableBlock()){ continue; } - TableBlock tableBlock=module.getTableBlock(); - int size=tableBlock.getHeaderBlock().getChunkSize(); - if(apkModule==null || size>chunkSize){ - chunkSize=size; + TableBlock loaded=module.getLoadedTableBlock(); + long size=loaded != null ? loaded.getHeaderBlock().getChunkSize() + : module.getInputSource(TableBlock.FILE_NAME).getLength(); + if(apkModule==null || size>largest){ + largest=size; apkModule=module; } } diff --git a/src/main/java/com/reandroid/apk/ApkModule.java b/src/main/java/com/reandroid/apk/ApkModule.java index 084c78d2d..0fcb3f6dc 100644 --- a/src/main/java/com/reandroid/apk/ApkModule.java +++ b/src/main/java/com/reandroid/apk/ApkModule.java @@ -1154,9 +1154,7 @@ TableBlock loadTableBlock() throws IOException { tableBlock = (TableBlock) ((BlockInputSource) inputSource).getBlock(); } else { setTableOriginalSource(inputSource); - InputStream inputStream = inputSource.openStream(); - tableBlock = TableBlock.load(inputStream); - inputStream.close(); + tableBlock = parseTableBlock(inputSource); } BlockInputSource blockInputSource = new BlockInputSource<>( inputSource.getName(), tableBlock); @@ -1220,13 +1218,20 @@ public void merge(ApkModule module) throws IOException { merge(module, false); } public void merge(ApkModule module, boolean force) throws IOException { + merge(module, force, true); + } + /** + * @param refreshTable whether to refresh the merged table afterwards. {@link ApkBundle} + * merges every module first and refreshes the table once at the end. + */ + void merge(ApkModule module, boolean force, boolean refreshTable) throws IOException { if (module == null || module == this) { return; } logMessage("Merging: " + module.getModuleName()); validateMerge(module, force); mergeDexFiles(module); - mergeTable(module); + mergeTable(module, refreshTable); mergeFiles(module); getUncompressedFiles().merge(module.getUncompressedFiles()); mergeFusedModules(module); @@ -1291,20 +1296,50 @@ private void validateMerge(ApkModule apkModule, boolean force) throws IOExceptio } logMessage(msg); } - private void mergeTable(ApkModule module) { + private void mergeTable(ApkModule module, boolean refresh) throws IOException { if (!module.hasTableBlock()) { return; } + TableBlock coming = module.getLoadedTableBlock(); + if (coming == null) { + // Parsed for this merge only, so nothing else holds it: it can become the merged + // table as is, or be dropped once merged + coming = module.loadDetachedTableBlock(); + if (coming != null && !hasTableBlock()) { + addInputSource(new BlockInputSource<>(TableBlock.FILE_NAME, coming)); + return; + } + if (coming == null) { + coming = module.getTableBlock(); + } + } TableBlock exist; if (!hasTableBlock()) { - exist=new TableBlock(); - BlockInputSource inputSource=new BlockInputSource<>(TableBlock.FILE_NAME, exist); - addInputSource(inputSource); + exist = new TableBlock(); + addInputSource(new BlockInputSource<>(TableBlock.FILE_NAME, exist)); } else { - exist=getTableBlock(); + exist = getTableBlock(); + } + exist.merge(coming, refresh); + } + /** + * Parses resources.arsc without caching it on this module, or returns null when the + * table source is already a block rather than an archive entry. + */ + private TableBlock loadDetachedTableBlock() throws IOException { + InputSource inputSource = getInputSource(TableBlock.FILE_NAME); + if (inputSource == null || inputSource instanceof BlockInputSource) { + return null; + } + return parseTableBlock(inputSource); + } + private static TableBlock parseTableBlock(InputSource inputSource) throws IOException { + InputStream inputStream = inputSource.openStream(); + try { + return TableBlock.load(inputStream); + } finally { + inputStream.close(); } - TableBlock coming=module.getTableBlock(); - exist.merge(coming); } private void mergeFiles(ApkModule module) { ZipEntryMap entryMapExist = getZipEntryMap(); @@ -1328,7 +1363,8 @@ private void mergeFiles(ApkModule module) { } } private void mergeDexFiles(ApkModule module) { - UncompressedFiles uncompressedFiles = module.getUncompressedFiles(); + UncompressedFiles comingUncompressed = module.getUncompressedFiles(); + UncompressedFiles uncompressed = getUncompressedFiles(); List existList = listDexFiles(); List comingList = module.listDexFiles(); ZipEntryMap zipEntryMap = getZipEntryMap(); @@ -1342,8 +1378,13 @@ private void mergeDexFiles(ApkModule module) { } } for (DexFileInputSource source : comingList) { - uncompressedFiles.removePath(source.getAlias()); String name = DexFileInputSource.getDexName(index); + if (comingUncompressed.isUncompressed(source.getAlias())) { + // Keep a stored dex stored under its new name, so it is copied as is + // instead of being deflated while writing + uncompressed.addPath(name); + } + comingUncompressed.removePath(source.getAlias()); DexFileInputSource add = new DexFileInputSource(name, source.getInputSource()); zipEntryMap.add(add); logMessage("Added [" + module.getModuleName() +"] " diff --git a/src/main/java/com/reandroid/arsc/chunk/TableBlock.java b/src/main/java/com/reandroid/arsc/chunk/TableBlock.java index 5bd4aa4a9..5ed1d262a 100755 --- a/src/main/java/com/reandroid/arsc/chunk/TableBlock.java +++ b/src/main/java/com/reandroid/arsc/chunk/TableBlock.java @@ -795,12 +795,21 @@ public void fromJson(JSONObject json) { refresh(); } public void merge(TableBlock tableBlock){ + merge(tableBlock, true); + } + /** + * @param refresh whether to refresh this table afterwards. A caller merging several tables + * can skip it for all but the last, as refreshing a large table is costly. + */ + public void merge(TableBlock tableBlock, boolean refresh){ if(tableBlock == null || tableBlock == this){ return; } getStringPool().merge(tableBlock.getStringPool()); getPackageArray().merge(tableBlock.getPackageArray()); - refresh(); + if(refresh){ + refresh(); + } } @Override public byte[] getBytes(){ diff --git a/src/test/java/com/reandroid/apk/ApkBundleMergeTest.java b/src/test/java/com/reandroid/apk/ApkBundleMergeTest.java new file mode 100644 index 000000000..c39489ab5 --- /dev/null +++ b/src/test/java/com/reandroid/apk/ApkBundleMergeTest.java @@ -0,0 +1,94 @@ +package com.reandroid.apk; + +import com.reandroid.TestUtils; +import com.reandroid.archive.ByteInputSource; +import com.reandroid.archive.ZipEntryMap; +import com.reandroid.arsc.chunk.PackageBlock; +import com.reandroid.arsc.chunk.TableBlock; +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock; +import com.reandroid.arsc.value.Entry; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; + +public class ApkBundleMergeTest { + + private static final String PACKAGE = "com.reandroid.merge"; + + @Test + public void testMergeKeepsBaseResourcesAndAddsSplitConfigs() throws IOException { + ApkModule merged = merge(); + + PackageBlock packageBlock = merged.getTableBlock().pickOne(); + Assert.assertEquals("Hello", value(packageBlock, "", "hello")); + Assert.assertEquals("Hallo", value(packageBlock, "-de", "hello")); + Assert.assertEquals("Only in base", value(packageBlock, "", "base_only")); + } + + @Test + public void testMergeKeepsStoredDexStored() throws IOException { + ApkModule merged = merge(); + + Assert.assertTrue(merged.getUncompressedFiles().isUncompressed("classes.dex")); + Assert.assertTrue(merged.getUncompressedFiles().isUncompressed("classes2.dex")); + } + + /** Merges, writes and reloads the result, as callers use it */ + private static ApkModule merge() throws IOException { + ApkBundle bundle = new ApkBundle(); + bundle.addModule(createBase()); + bundle.addModule(createSplit()); + ApkModule merged = bundle.mergeModules(); + File file = new File(TestUtils.getTempDir(), "bundle_merged.apk"); + merged.writeApk(file); + merged.close(); + bundle.close(); + return ApkModule.loadApkFile(file); + } + + private static ApkModule createBase() throws IOException { + TableBlock tableBlock = new TableBlock(); + PackageBlock packageBlock = tableBlock.newPackage(0x7f, PACKAGE); + packageBlock.getOrCreate("", "string", "hello").setValueAsString("Hello"); + packageBlock.getOrCreate("", "string", "base_only").setValueAsString("Only in base"); + tableBlock.refreshFull(); + return createModule("base", tableBlock, null); + } + + private static ApkModule createSplit() throws IOException { + TableBlock tableBlock = new TableBlock(); + PackageBlock packageBlock = tableBlock.newPackage(0x7f, PACKAGE); + // Created first, so it gets the id "hello" has in the base + packageBlock.getOrCreate("-de", "string", "hello").setValueAsString("Hallo"); + tableBlock.refreshFull(); + return createModule("split_config.de", tableBlock, "config.de"); + } + + private static ApkModule createModule(String name, TableBlock tableBlock, String split) { + ApkModule module = new ApkModule(name, new ZipEntryMap()); + module.setLoadDefaultFramework(false); + + AndroidManifestBlock manifest = new AndroidManifestBlock(); + manifest.setPackageName(PACKAGE); + if (split != null) { + manifest.setSplit(split, true); + } + manifest.refresh(); + module.add(new ByteInputSource(manifest.getBytes(), AndroidManifestBlock.FILE_NAME)); + + // A table the module has not parsed yet, as when loaded from an archive + module.add(new ByteInputSource(tableBlock.getBytes(), TableBlock.FILE_NAME)); + + module.add(new ByteInputSource(new byte[]{'d', 'e', 'x', '\n'}, "classes.dex")); + module.getUncompressedFiles().addPath("classes.dex"); + return module; + } + + private static String value(PackageBlock packageBlock, String qualifiers, String name) { + Entry entry = packageBlock.getEntry(qualifiers, "string", name); + Assert.assertNotNull(qualifiers + "/" + name, entry); + return entry.getResValue().getValueAsString(); + } +}