Android资源优化探索

501 阅读9分钟

前言

在最近项目开发过程中碰到了一些问题,如下 :

  • 多人协作或者多Moudle开发时,不同的开发各自不同的命名同一资源引入项目,例如使用了同一资源(图片、字体等等)a 经过不同的开发引入项目后命名为bc
  • 在早些年开发的项目中res/drawable目录可能会在drawable-xhdpidrawable-xxhdpidrawable-xxxhdpi文件夹中引入多份资源,随着手机硬件、性能等各方面的提升,通常我们只需要添加一份drawable-xxxhdpi 的资源即可,但是旧的资源如果让我们手动去逐个查找删除的话,这个工作量恐怕是耗时且巨大的
  • 项目中直接引入jpgpng等较大的图片资源,需要手动的使用TinyPng压缩或转换为webp格式图片 ,甚至有可能会忘记压缩
  • 业务需要,例如添加一些模型so等一些离线文件 等等...

以上的这些问题都有可能会造成apk包体积变大,而有的变大对产品是没有任何收益,甚至是副作用,这对产品本身来说是难以接受的 。

理想 & 目标

那能不能编写一个gradle plugin,帮助我们解决上面的问题,这个插件有如下特点 :

  • 检测项目中所有的重复资源 ,以可视化的格式输出重复的资源文件名或路径,自动对重复的资源进行移除和引用替换
  • 同名图片资源在 drawable-xhdpidrawable-xxhdpidrawable-xxxhdpi 目录下只会保留一份,其他的会被自动移除
  • 对项目中图片可以进行自动压缩优化
  • 织入打包流程中,持久化和自动化对资源进行优化处理,无需每次或者周期性的手动去处理。

现实

为了实现上述目标,首先必须解决一个关键问题,即获取项目中的全部资源。因此,本文主要探讨获取项目资源的可行性方案。只有解决了这个问题,我们才有可能实现目标。

Demo 获取应用Resources资源

tree app/src/main/res/drawable-xxxhdpi app/libs  testlibrary/src/main/res/drawable-xxxhdpi  testAar/src/main/res/drawable-xxxhdpi  
// demo 资源项目结构
app/src/main/res/drawable-xxxhdpi
└── tiger_test_main.webp
app/libs
└── testAar-release.aar
testlibrary/src/main/res/drawable-xxxhdpi
└── horse_test_library.webp
testAar/src/main/res/drawable-xxxhdpi
└── horse_test_aar.webp

md5 app/src/main/res/drawable-xxxhdpi/tiger_test_main.webp  testlibrary/src/main/res/drawable-xxxhdpi/horse_test_library.webp  testAar/src/main/res/drawable-xxxhdpi/horse_test_aar.webp 
// 资源md5
MD5 (app/src/main/res/drawable-xxxhdpi/tiger_test_main.webp) = 28e71398b2e252046ffdd8aaf965c258
MD5 (testlibrary/src/main/res/drawable-xxxhdpi/horse_test_library.webp) = 8bcb0207b1ff0334fa80c7e1fef2b388
MD5 (testAar/src/main/res/drawable-xxxhdpi/horse_test_aar.webp) = 8bcb0207b1ff0334fa80c7e1fef2b388

Android 项目结构图.drawio.png

测试Demo结构如上,主工程app分别引用了 testAar.aar testLibrary 依赖库,这是大多数场景下对依赖库的引用方式。通过上面的md5打印可以看出testlibrary/src/main/res/drawable-xxxhdpi/horse_test_library.webptestAar/src/main/res/drawable-xxxhdpi/horse_test_aar.webp 为同一资源

方式一

通过源码方式获取res资源算是一种最为简单的方式,在ResourceMonitorPlugin内定义PrintlnAllDrawableResBySourceTask 读取项目/src/main/res/drawable-*目录下webpjpg资源的gradle task

class ResourceMonitorPlugin : Plugin<Project> {


    companion object {
        private val DRAWABLE_REGEX = "tiger_test_main|horse_test_library|horse_test_aar".toRegex()
    }


    override fun apply(project: Project) {
        printlnAllDrawableResBySource(project)
    }

    // 源码方式打印所有drawable资源
    private fun printlnAllDrawableResBySource(project: Project) {
        project.extensions.getByType(AppExtension::class.java) ?: throw IllegalStateException(
            "Not an Android project!"
        )
        project.tasks.create(
            "printlnAllDrawableResBySource",
            PrintlnAllDrawableResBySourceTask::class.java
        )
    }

    abstract class PrintlnAllDrawableResBySourceTask : DefaultTask() {
        @TaskAction
        fun doAction() {
            val projectDir = project.rootDir
            projectDir.walk().forEach {
                // 路径类型直接跳过
                if (it.isDirectory) {
                    return@forEach
                }
                // 忽略 testAar 目录下的文件
                val toRelativeString = it.toRelativeString(projectDir)
                if (toRelativeString.startsWith("testAar")) {
                    return@forEach
                }
                // 忽略非 drawable 目录下的文件
                if (!toRelativeString.contains("/src/main/res/drawable-")) {
                    return@forEach
                }
                // 只打印后缀名为 webp 和 png 文件
                if (it.nameWithoutExtension.matches(DRAWABLE_REGEX)) {
                    println(it.absolutePath)
                }
            }
        }
    }
}

执行 ./gradlew printlnAllDrawableResBySource 命令 , 输出如下

截屏2023-08-18 16.42.20.png

缺点

  1. 无法获取整个项目的完整资源,通过Aar方式引入的资源,无法被打印,实现功能时存在一定局限性
方式二 :

官方提供了一个可以访问构建所有相关依赖的资源apiBaseVariant.getAllRawAndroidResources(),注释信息如下

/**
 * Returns file collection containing all raw Android resources, including the ones from
 * transitive dependencies.
 *
 * <p><strong>This is an incubating API, and it can be changed or removed without
 * notice.</strong>
 */
@Incubating
@NonNull
FileCollection getAllRawAndroidResources();

大致意思是会收集所有的Android资源 ,相关依赖库的资源也会被添加进来 。下面我们使用该API尝试一下,在 printAllRawAndroidResources方法内,BaseVariant.getAllRawAndroidResources()方法会在 mergeResourcesTask任务开始时优先执行 ,并打印相关资源文件录径。

private fun printAllRawAndroidResources(project: Project) {
    project.afterEvaluate {
        val appExtension = project.extensions.getByType(AppExtension::class.java)
        appExtension.applicationVariants.all { variant ->
            variant.mergeResourcesProvider.get().doFirst {
                variant.allRawAndroidResources.files.forEach { it ->
                    val baseFile = it
                    it.walk().forEach {
                        // 路径类型直接跳过
                        if (it.isDirectory) {
                            return@forEach
                        }
                        val toRelativeString = it.toRelativeString(baseFile)
                        // 忽略非 drawable 目录下的文件
                        if (!toRelativeString.startsWith("drawable-x")) {
                            return@forEach
                        }
                        // 只打印后缀名为 webp 和 png 文件
                        if (it.nameWithoutExtension.matches(DRAWABLE_REGEX)) {
                            println(it.absolutePath)
                        }
                    }
                }
            }
        }
    }
}

执行 ./gradlew assembleRelease 命令,打印结果如下 :

截屏2023-08-18 16.47.02.png

优点

  1. 能够获取到完整的资源,为自定义功能提供更多的可能性
  2. 织入编译流程,无需手动调用,自定义实现随应用构建而运行,具有实时、持久、自动化特性

缺点

  1. com.android.build.gradle.api.BaseVariant @Deprecated 标记为废弃getAllRawAndroidResources@Incubating标记为不稳定API
  2. 一些资源最终可能不会被打入到apk包内,但是也会读取或者修改
  3. 从截图中输出的资源路径可以看到,主工程资源路径是原文件路径,testLibrary 依赖库则来自于对应项目 build 目录下构建缓存,aar依赖库资源则来自 .gradle/caches缓存 。如果直接对对应路径资源做修改、删除等处理操作的话 ,由于有的是删除的缓存,有的是源文件,可能会出现效果不一致的问题
方式三

除了上面的两种方式外,我有了一种新的思路 : 从应用的的构建过程入手,尝试获取到构建过程中合并后的资源目录,尝试对目录下的资源做一些增、删、改、查操作,应用的构建产物一般都是在 project/build 目录下,通过观察发现了三个相关的资源目录 ,如下:

截屏2023-08-21 12.17.27.png

app/build/intermediates/merged_res/debug/drawable-xxxhdpi*(MergeResources Task 构建缓存目录) 图片资源如下 :

截屏2023-08-21 12.23.30.png

app/build/intermediates/packaged_res/debug/drawable-xxxhdpi-v4 (PackageAndroidArtifact Task 构建缓存目录)图片资源如下 : 截屏2023-08-21 12.27.39.png

/app/build/intermediates/processed_res/debug/out(ProcessAndroidResources Task 构建缓存目录)资源如下 : 截屏2023-08-21 12.29.50.png 可以看到processed_res目录下资源被打包为后缀名为ap_资源文件 ,这个文件是一个zip格式的文件,使用命令查询文件内图片资源 截屏2023-08-21 13.15.12.png 也可以通过代码方式访问 processed_res 目录下资源 ,通过逐步查找ProcessAndroidResources的引用路径,发现可以通过如下方式获取到processed_res目录下图片资源文件

private fun printAllDrawableResByProcessResTask(project: Project) {
    project.afterEvaluate { it ->
        val appExtension = it.extensions.getByType(AppExtension::class.java)
        // 获取输出集合
        appExtension.buildOutputs.all { it ->
            if (it is ApkVariantOutput) {
                // 获取到 ProcessAndroidResources Task 任务
                it.processResourcesProvider.get().doLast { it ->
                    if (it is LinkApplicationAndroidResourcesTask) {
                        // 资源输出根路径
                        val resPackageOutputFolder = it.resPackageOutputFolder.get()
                        // 输出压缩文件名
                        // https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:common/src/main/java/com/android/SdkConstants.java?q=com.android.SdkConstants
                        //   val resOutBaseNameFile = File(
                        //                parameters.resPackageOutputDirectory.get().asFile,
                        //                FN_RES_BASE + variantName + SdkConstants.DOT_RES
                        //            )
                        val resOutBaseNameFile = "resources-" + it.variantName + ".ap_"
                        val resPackageOutFile =
                            resPackageOutputFolder.file(resOutBaseNameFile).asFile
                        println("======" + resPackageOutFile.length())
                        println("======" + resPackageOutFile.absolutePath)

                        val processedResName = resPackageOutFile.nameWithoutExtension
                        val processedResExt = resPackageOutFile.extension
                        val tempResPackageOutFile =
                            resPackageOutputFolder.file("${processedResName}_temp.$processedResExt").asFile
                        println("======" + tempResPackageOutFile.absolutePath)
                        // 读取并复制 resources-release.ap_ 文件内资源
                        JarFile(resPackageOutFile).use { srcZip ->
                            // 创建临时文件
                            JarOutputStream(tempResPackageOutFile.outputStream()).use { out ->
                                srcZip.entries().asSequence().forEach { entry ->
                                    out.putNextEntry(JarEntry(entry.name))
                                    // 写入源文件到临时文件内
                                    srcZip.getInputStream(entry).use { it.copyTo(out) }
                                    out.closeEntry()
                                }
                                // 尝试添加新资源
                                //val newZipEntry = ZipEntry(" res/drawable-xxxhdpi-v4/newAdd.png")
                                //out.putNextEntry(newZipEntry)
                                //out.closeEntry()
                            }
                        }
                        // resources-release.ap_ 和 复制的  resources-release_temp.ap_ md5 不同
                        // 说明两者的压缩算法可能会存在不同
                        // 尝试覆盖源文件, 生成的安装包会安装失败
                        //tempResPackageOutFile.renameTo(resPackageOutFile)
                    }
                }
            }
        }
    }
}

优点

  1. processed_res 包含了应用构建依赖的res完整资源,并且包含 resources.arsc 文件, 这为动态的处理res资源和更新 resources.arsc 文件提供了无限可能
  2. 由于修改的都是构建缓存资源,对源文件无影响
  3. 如果依赖ProcessAndroidResources Task添加到构建缓存中, 可以自动化处理资源文件

缺点 1. merged_res、 packaged_res目录下无法获取全部的资源 2. 通过解析processed_res目录下后缀名为ap_的文件,可以获取到全部资源文件,但是文件的压缩格式与常用的zip格式不同,如果使用常用的zip格式读取复制文件,两个文件的的md5并不相同。 能够正常的解析和处理 resources-release.ap_ 文件的方式还在调研。

方式四

对生成的最终产物apk文件resources资源读取或处理,与方式三类似,不再赘述。

小试牛刀

上面只是讨论了一些获取Android resources资源的方式 ,要想实现上面期望的的插件功能,可能还有很长路要走,但是我们可以先实现一个简单的资源检重小插件,检测、统计项目中依赖的同一资源但是资源名不同的文件,并将统计结果保存在文件内。

private fun calculateFileMd5(file: File): String {

    val buffer = ByteArray(8192)
    val md5 = MessageDigest.getInstance("MD5")

    FileInputStream(file).use { input ->
        while (true) {
            val bytesRead = input.read(buffer)
            if (bytesRead == -1) {
                break
            }
            md5.update(buffer, 0, bytesRead)
        }
    }
    val digest = md5.digest()
    return Base64.getEncoder().encodeToString(digest).lowercase()
}

data class ResourceFile(val path: String, val length: Long) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as ResourceFile

        if (path != other.path) return false
        if (length != other.length) return false

        return true
    }

    override fun hashCode(): Int {
        var result = path.hashCode()
        result = 31 * result + length.hashCode()
        return result
    }
}


private fun resourcesCheckPluginImp(project: Project, extensionFilter: List<String>) {
    project.afterEvaluate {
        val appExtension = project.extensions.getByType(AppExtension::class.java)
        appExtension.applicationVariants.all { variant ->
            variant.mergeResourcesProvider.get().doFirst {
                val resourcesMap = hashMapOf<String, MutableSet<ResourceFile>>()
                variant.allRawAndroidResources.files.forEach { it ->
                    it.walk().forEach {
                        // 路径类型直接跳过
                        // 非指定扩展类型的直接忽略
                        if (it.isDirectory || !extensionFilter.contains(it.extension)) {
                            return@forEach
                        }
                        val resourceFile = ResourceFile(it.absolutePath, it.length())
                        // 获取文件的md5
                        val md5 = calculateFileMd5(it)
                        val repeatResources = resourcesMap[md5] ?: mutableSetOf()
                        repeatResources.add(resourceFile)
                        // 使用文件md5作为索引,
                        resourcesMap[md5] = repeatResources
                    }
                }
                // 资源检测完毕,资源以MD5进行分类
                val repeatResourcesMap = resourcesMap.filter {
                    it.value.size > 1
                }
                val gson = GsonBuilder().setPrettyPrinting().create()
                val json = gson.toJson(repeatResourcesMap)
                File(project.projectDir, "repeatResources.txt").writeText(json)

                // 计算可以优化的大小
                val sum = repeatResourcesMap.map {
                    val value = it.value
                    // 单个重复资源可优化大小
                    value.first().length * (value.size - 1)
                }.sum()
                println("ResourceMonitorPlugin 可以优化的资源大小为 : ${sum/1024f} KB")
            }
        }
    }
}

查看 cat app/repeatResources.txt , horse_test_aar.webp/horse_test_library.webp 被检测出是两个md5相同的重复文件

截屏2023-08-21 15.27.42.png

接入到我们的项目中后,发现可以输出优化的资源为 : 截屏2023-08-21 17.05.09.png

结束

本篇文章到此结束,主要是探讨获取应用资源的方案和思路,我们自己可以根据自己的需求选择合适的方案做一些功能拓展。 想要实现更多复杂的功能,例如实现自动化移除、合并和替换重复的资源文件,还需要进一步的探索,下一篇见 !