前言
在最近项目开发过程中碰到了一些问题,如下 :
- 多人协作或者多
Moudle开发时,不同的开发各自不同的命名同一资源引入项目,例如使用了同一资源(图片、字体等等)a经过不同的开发引入项目后命名为b和c - 在早些年开发的项目中res/drawable目录可能会在
drawable-xhdpi、drawable-xxhdpi、drawable-xxxhdpi文件夹中引入多份资源,随着手机硬件、性能等各方面的提升,通常我们只需要添加一份drawable-xxxhdpi的资源即可,但是旧的资源如果让我们手动去逐个查找删除的话,这个工作量恐怕是耗时且巨大的 - 项目中直接引入
jpg、png等较大的图片资源,需要手动的使用TinyPng压缩或转换为webp格式图片 ,甚至有可能会忘记压缩 - 业务需要,例如添加一些
模型、so等一些离线文件 等等...
以上的这些问题都有可能会造成apk包体积变大,而有的变大对产品是没有任何收益,甚至是副作用,这对产品本身来说是难以接受的 。
理想 & 目标
那能不能编写一个gradle plugin,帮助我们解决上面的问题,这个插件有如下特点 :
- 检测项目中所有的重复资源 ,以可视化的格式输出重复的资源文件名或路径,自动对重复的资源进行移除和引用替换
- 同名图片资源在
drawable-xhdpi、drawable-xxhdpi、drawable-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
测试Demo结构如上,主工程app分别引用了 testAar.aar 和 testLibrary 依赖库,这是大多数场景下对依赖库的引用方式。通过上面的md5打印可以看出testlibrary/src/main/res/drawable-xxxhdpi/horse_test_library.webp 和testAar/src/main/res/drawable-xxxhdpi/horse_test_aar.webp 为同一资源
方式一
通过源码方式获取res资源算是一种最为简单的方式,在ResourceMonitorPlugin内定义PrintlnAllDrawableResBySourceTask 读取项目/src/main/res/drawable-*目录下webp、jpg资源的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 命令 , 输出如下
缺点
- 无法获取整个项目的完整资源,通过
Aar方式引入的资源,无法被打印,实现功能时存在一定局限性
方式二 :
官方提供了一个可以访问构建所有相关依赖的资源api,BaseVariant.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 命令,打印结果如下 :
优点
- 能够获取到完整的资源,为自定义功能提供更多的可能性
- 织入编译流程,无需手动调用,自定义实现随应用构建而运行,具有实时、持久、自动化特性
缺点
com.android.build.gradle.api.BaseVariant被@Deprecated标记为废弃getAllRawAndroidResources被@Incubating标记为不稳定API- 一些资源最终可能不会被打入到apk包内,但是也会读取或者修改
- 从截图中输出的资源路径可以看到,主工程资源路径是原文件路径,
testLibrary依赖库则来自于对应项目build目录下构建缓存,aar依赖库资源则来自.gradle/caches缓存 。如果直接对对应路径资源做修改、删除等处理操作的话 ,由于有的是删除的缓存,有的是源文件,可能会出现效果不一致的问题
方式三
除了上面的两种方式外,我有了一种新的思路 : 从应用的的构建过程入手,尝试获取到构建过程中合并后的资源目录,尝试对目录下的资源做一些增、删、改、查操作,应用的构建产物一般都是在 project/build 目录下,通过观察发现了三个相关的资源目录 ,如下:
app/build/intermediates/merged_res/debug/drawable-xxxhdpi*(MergeResources Task 构建缓存目录) 图片资源如下 :
app/build/intermediates/packaged_res/debug/drawable-xxxhdpi-v4 (PackageAndroidArtifact Task 构建缓存目录)图片资源如下 :
/app/build/intermediates/processed_res/debug/out(ProcessAndroidResources Task 构建缓存目录)资源如下 :
可以看到
processed_res目录下资源被打包为后缀名为ap_资源文件 ,这个文件是一个zip格式的文件,使用命令查询文件内图片资源
也可以通过代码方式访问
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)
}
}
}
}
}
}
优点
- processed_res 包含了应用构建依赖的
res完整资源,并且包含resources.arsc文件, 这为动态的处理res资源和更新resources.arsc文件提供了无限可能 - 由于修改的都是构建缓存资源,对源文件无影响
- 如果依赖
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相同的重复文件
接入到我们的项目中后,发现可以输出优化的资源为 :
结束
本篇文章到此结束,主要是探讨获取应用资源的方案和思路,我们自己可以根据自己的需求选择合适的方案做一些功能拓展。 想要实现更多复杂的功能,例如实现自动化移除、合并和替换重复的资源文件,还需要进一步的探索,下一篇见 !