Mobile Development 11 min read

Optimizing Android resources.arsc: Deduplication and Image Compression via a Custom Gradle Plugin

This article explains how Android's resources.arsc file is generated, identifies duplication and size issues in multi‑module projects, and presents a Gradle‑based solution that inserts a custom task after processReleaseResources to deduplicate resources and compress images, reducing the final APK size.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Optimizing Android resources.arsc: Deduplication and Image Compression via a Custom Gradle Plugin

The resources.arsc file in an APK stores compiled binary representations of all non‑code assets such as strings, images, arrays, and colors. It is produced by the Android Asset Packaging Tool (AAPT) during the build process, enabling fast runtime access to resources.

In large or componentized Android projects, duplicate image resources often accumulate, inflating the APK size. Manual compression with tools like TinyPNG cannot guarantee consistency across multiple developers, so an automated solution is needed.

To intervene, the article examines the Gradle build pipeline, focusing on the processReleaseResources task that packages resources and generates the resources.arsc file. By locating the process${variantName}Resource task, a custom task can be appended to process the generated .ap_ archive.

Solution workflow:

Identify the process${variantName}Resource task.

After this task, add a custom task.

Collect all .ap_ files from the task’s output directory.

For each .ap_ file, unzip, delete duplicate resources, compress images, re‑zip, and clean up.

The following Kotlin Gradle plugin code demonstrates the implementation (code kept unchanged):

override fun apply(p0: Project) {
    // 总配置
    p0.extensions.create(CONFIG_NAME, Config::class.java)
    // 重复资源配置
    p0.extensions.create(REPEAT_RES_CONFIG_NAME, RepeatResConfig::class.java)
    // 压缩图片配置
    p0.extensions.create(COMPRESS_IMG_CONFIG_NAME, CompressImgConfig::class.java)

    val hasAppPlugin = p0.plugins.hasPlugin(AppPlugin::class.java)
    if (hasAppPlugin) {
        p0.afterEvaluate {
            FileUtil.setRootDir(p0.rootDir.path)
            print("PluginTest Config " + p0.extensions.findByName(CONFIG_NAME))
            val config: Config? = p0.extensions.findByName(CONFIG_NAME) as? Config
            val repeatResConfig = p0.extensions.findByName(REPEAT_RES_CONFIG_NAME) as? RepeatResConfig
            val compressImgConfig = p0.extensions.findByName(COMPRESS_IMG_CONFIG_NAME) as? CompressImgConfig

            if (config?.enable == false) {
                return@afterEvaluate
            }

            val byType = p0.extensions.getByType(AppExtension::class.java)
            byType.applicationVariants.forEach {
                val variantName = it.name.capitalize()
                val processRes = p0.tasks.getByName("process${variantName}Resources")
                processRes.doLast {
                    val resourcesTask = it as LinkApplicationAndroidResourcesTask
                    val files = resourcesTask.resPackageOutputFolder.asFileTree.files
                    files.filter { file ->
                        file.name.endsWith(".ap_")
                    }.forEach { apFile ->
                        val mapping = "${p0.buildDir}${File.separator}ResDeduplication${File.separator}mapping${File.separator}"
                        File(mapping).takeIf { fileMapping -> !fileMapping.exists() }?.apply { mkdirs() }

                        val originalLength = apFile.length()
                        val resCompressFile = File(mapping, REPEAT_RES_MAPPING)
                        val unZipPath = "${apFile.parent}${File.separator}resCompress"
                        ZipFile(apFile).unZipFile(unZipPath)

                        // 删除重复图片
                        deleteRepeatRes(unZipPath, resCompressFile, apFile, repeatResConfig?.whiteListName)
                        // 压缩图片
                        compressImg(mapping, compressImgConfig, unZipPath)
                        apFile.delete()
                        ZipOutputStream(apFile.outputStream()).use { output ->
                            output.zip(unZipPath, File(unZipPath))
                        }

                        val lastLength = apFile.length()
                        print("优化结束缩减:${lastLength - originalLength}")
                        deleteDir(File(unZipPath))
                    }
                }
            }
        }
    }
}

Deleting duplicate resources involves writing a mapping of duplicated files to retained ones, removing the duplicates, and updating the ResourceTableChunk entries in the resources.arsc file so that references point to the kept assets.

private fun deleteRepeatRes(
    unZipPath: String,
    mappingFile: File,
    apFile: File,
    ignoreName: MutableList
?
) {
    val fileWriter = FileWriter(mappingFile)
    val groupsResources = ZipFile(apFile).groupsResources()
    val arscFile = File(unZipPath, RESOURCE_NAME)
    val newResource = FileInputStream(arscFile).use { input ->
        val fromInputStream = ResourceFile.fromInputStream(input)
        groupsResources.asSequence().filter { it.value.size > 1 }
            .filter { entry ->
                val name = File(entry.value[0].name).name
                ignoreName?.contains(name)?.let { !it } ?: true
            }.forEach { zipMap ->
                val zips = zipMap.value
                val coreResources = zips[0]
                for (index in 1 until zips.size) {
                    val repeatZipFile = zips[index]
                    fileWriter.synchronizedWriteString("${repeatZipFile.name} => ${coreResources.name}")
                    File(unZipPath, repeatZipFile.name).delete()
                    fromInputStream.chunks.asSequence()
                        .filter { it is ResourceTableChunk }
                        .map { it as ResourceTableChunk }
                        .forEach { chunk ->
                            val stringPoolChunk = chunk.stringPool
                            val idx = stringPoolChunk.indexOf(repeatZipFile.name)
                            if (idx != -1) {
                                stringPoolChunk.setString(idx, coreResources.name)
                            }
                        }
                }
            }
        fileWriter.close()
        fromInputStream
    }
    arscFile.delete()
    FileOutputStream(arscFile).use { it.write(newResource.toByteArray()) }
}

Image compression supports PNG (via pngquant ), JPG (via guetzli ), and conversion to WebP (via cwebp ). The plugin scans drawable and mipmap directories, skips whitelisted files, and applies the appropriate compression or conversion, recording size reductions in a mapping file.

private suspend fun CoroutineScope.compressionImg(
    mappingFile: File,
    unZipDir: String,
    config: CompressImgConfig,
    webpsLsit: CopyOnWriteArrayList
) {
    val mappginWriter = FileWriter(mappingFile)
    launch {
        val file = File("$unZipDir${File.separator}res")
        file.listFiles()
            .filter { it.isDirectory && (it.name.startsWith("drawable") || it.name.startsWith("mipmap")) }
            .flatMap { it.listFiles().toList() }
            .asSequence()
            .filter { config.whiteListName?.contains(it.name)?.let { !it } ?: true }
            .filter { ImageUtil.isImage(it) }
            .forEach { img ->
                launch(Dispatchers.Default) {
                    when (config.optimizeType) {
                        OPTIMIZE_COMPRESS_PICTURE -> {
                            val originalPath = img.absolutePath.replace("$unZipDir${File.separator}", "")
                            val reduceSize = compressImg(img)
                            if (reduceSize > 0) {
                                mappginWriter.synchronizedWriteString("$originalPath => 减少[$reduceSize]")
                            } else {
                                mappginWriter.synchronizedWriteString("$originalPath => 压缩失败")
                            }
                        }
                        OPTIMIZE_WEBP_CONVERT -> {
                            val webp0K = ImageUtil.securityFormatWebp(img, config)
                            webp0K?.apply {
                                val originalPath = original.absolutePath.replace("$unZipDir${File.separator}", "")
                                val webpFilePath = webpFile.absolutePath.replace("$unZipDir${File.separator}", "")
                                mappginWriter.synchronizedWriteString("$originalPath => $webpFilePath => 减少[$reduceSize]")
                                webpsLsit.add(this)
                            }
                        }
                        else -> println("图片优化类型 opt...不存在,使用 $OPTIMIZE_COMPRESS_PICTURE 类型压缩图片!")
                    }
                }
            }
        }.join()
        mappginWriter.close()
    }
}

After applying the plugin, the resources.arsc size dropped from 736.4 KB to 162.2 KB and the overall APK size decreased from 16.8 MB to 12.8 MB, demonstrating significant space savings.

Source code: https://github.com/fuusy/ResCompressPlugin

References include Android app size‑optimization case studies and documentation on resources.arsc structure and image compression tools.

mobile developmentAndroidGradleimage compressionresource deduplicationresources.arsc
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.