Duplicate assets are rarely an issue – unless you’re working on a large-scale app with multiple business lines. Unfortunately, that was exactly our situation. The impact on package size wasn’t huge (a few hundred KB), but for anyone doing size optimization, every byte counts.
How to Deduplicate?
The key is intercepting access to assets – specifically, AssetManager. Booster’s approach uses a Transformer to replace AssetManager method call instructions with an injected ShadowAssetManager. Here’s the code:
publicstatic InputStream open(final AssetManager am, final String shadow)throws IOException { finalStringname= DUPLICATED_ASSETS.get(shadow); return am.open(null != name && name.trim().length() > 0 ? name : shadow); }
privateShadowAssetManager() { }
}
Is that all? Not quite – the DUPLICATED_ASSETS map above is still empty. The next step is to build this duplicate assets mapping during the build process:
fun BaseVariant.removeDuplicatedAssets(): Map<String, String> { val output = mergeAssets.outputDir val assets = output.search().groupBy(File::md5).values.filter { it.size > 1 }.map { duplicates -> val head = duplicates.first() duplicates.takeLast(duplicates.size - 1).map { it to head }.toMap(mutableMapOf()) }.reduce { acc, map -> acc.putAll(map) acc }
assets.keys.forEach { it.delete() }
return assets.map { it.key.toRelativeString(output) to it.value.toRelativeString(output) }.toMap() }
Then, in the Transformer, we modify ShadowAssetManager by injecting the asset mapping into its clinit (static initializer), populating DUPLICATED_ASSETS:
The ShadowAssetManagerTransformer above rewrites the static initializer of ShadowAssetManager to populate DUPLICATED_ASSETS with the duplicate asset mappings. When decompiled, the result looks like this:
This approach handles most duplicate assets, but fonts are an exception – font loading doesn’t go through the Java-layer AssetManager. If you’re curious, take a look at Typeface.
Summary
Booster’s asset deduplication works in three steps:
Group assets by md5sum and build a mapping of duplicates;
Replace all AssetManager.open(String): InputStream call instructions with ShadowAssetManager.open(AssetManager, String): InputStream;
Modify the static initializer of ShadowAssetManager to populate ShadowAssetManager.DUPLICATED_ASSETS with the duplicate mapping;
Extended Use Cases
Intercepting AssetManager.open(String): InputStream opens the door to more than just deduplication – you can also compress assets to further reduce package size. The idea is straightforward: since AssetManager.open(String) returns an InputStream, you can substitute it with a ZipInputStream. Here’s the approach:
After mergeAssets, ZIP-compress the assets;
Intercept AssetManager.open() and return a ZipInputStream from ShadowAssetManager.open();
The entire process is completely transparent to the app – seamless.