写点什么

Android 图片资源检测插件实现

作者:java易二三
  • 2023-08-25
    湖南
  • 本文字数:5476 字

    阅读完需:约 18 分钟

为什么要检测图片资源?

  1. 避免不小心把未压缩,不合适的图片资源打入 apk 中,造成 apk 过大

  2. 图片打入 apk 前,可以自动化转换,压缩

实现思路

  1. 思路一:使用 gradle 在 aapt 编译期,扫描汇总资源的文件夹,过滤出不符合要求的图片资源,并抛出异常中断编译

  2. 思路二:是思路一的进阶。还是在使用 gradle 在 aapt 编译期,查找有没有合适的 gradle task,提供给我们遍历所有资源的机会

gradle 插件实现

gradle 插件实现的基础

简单对 gradle 插件实现进行复习

插件搭建

  • 新建一个模块

  • 配置好该模块的上传配置(mvn.gradle)

  • 在 build 中,对 gradleApi 进行依赖

    scss 复制代码

    apply plugin: 'kotlin' //插件如果使用kotlin实现,需要依赖kotlindependencies { implementation gradleApi() implementation localGroovy() implementation 'com.android.tools.build:gradle:3.4.2'}

  • 在 main 下面新建 resources.META-INF.gradle-plugins 文件夹

  • 在该文件夹中创建一个和 module 同名的.properties 文件,在里面配置上你的插件入口类

    例:

    arduino 复制代码

    implementation-class=com.xxx.checkbigimage.image.ImagePlugin

插件的基本实现

上面讲到要配置一个入口类,这个入口类就是实现了 Plugin 接口的类,它有一个 override fun apply(project: Project)方法,就是我们插件开始执行的地方,相当于 main 函数,参数 project 就是整个工程的配置文件

可以使用以下方法,从我们使用插件的地方获取到对插件的配置

python复制代码project.extensions.create("config", Config::class.java)mConfig = project.property("config") as Config
复制代码

Config 是一个 java bean 数据类

"config"是我们在 build 中的配置名称

这样一个简单 gradle 插件就实现了

图片资源检测插件实现

上面说了为什么要实现这样一个插件和该如何实现一个 gradle 插件,那么下面就具体介绍该插件的实现过程

想要的功能

  • 检测和拦截功能

    检测是否有大小超标的图片

    检测是否有宽高超标的图片

    拦截非 webp 资源,并进行提示

  • 自动化压缩

    自动压缩 png,jpg 等资源

  • 白名单设置

  • 一些统计功能

实现过程

上面已经说了 gradle 插件的实现,那么我们就从 apply 方法开始说起。

瞄准 task 挂钩

既然是要 hock android 打包的编译过程,那就要寻找 android 打包时,合适的 task

想 hock task,首先应该拿到任务 task 集合

在 android 插件编译生成 apk 的过程中,有好多 task 都可以生成 apk,它们的名字基于 Build Types 和 Product Flavor 生成。那么我们怎么拿到具体生成 apk 的 task 组呢?

为了解决这个问题。android 插件有几个属性,就是我们平常配置的变体(所谓的环境),androd 中有三类变体

  • applicationVariants(只适用于 app plugin)

  • libraryVariants(只适用于 library plugin)

  • testVariants(app、library plugin 均适用)

这三个对象都是实现了 BaseVariant(BaseVariantImpl 为实现这个接口的抽象类)接口的类的对象的集合

属性名

属性类型

说明

name

String

Variant 的名字,唯一

description

String

Variant 的描述说明

dirName

String

Variant 的子文件夹名,唯一。可能有不止一个子文件夹,例如 “debug/flavor1”

baseName

String

Variant 输出的基础名字,必须唯一

outputFile

File

Variant 的输出,该属性可读可写

processManifest

ProcessManifest

处理 Manifest 的 task

aidlCompile

AidlCompile

编译 AIDL 文件的 task

renderscriptCompile

RenderscriptCompile

编译 Renderscript 文件的 task

mergeResources

MergeResources

合并资源文件的 task

mergeAssets

MergeAssets

合并 assets 的 task

processResources

ProcessAndroidResources

处理并编译资源文件的 task

generateBuildConfig

GenerateBuildConfig

生成 BuildConfig 类的 task

javaCompile

JavaCompile

编译 Java 源代码的 task

processJavaResources

Copy

处理 Java 资源的 task

assemble

DefaultTask

Variant 的标志性 assemble task

因为我们的插件应该可以应用在主工程或者模块包上的,所以当我们插件运行后,我们要检测当前使用我们插件的模块是主工程,还是模块包

kotlin复制代码val hasAppPlugin = project.plugins.hasPlugin("com.android.application")val variants = if (hasAppPlugin) {  (project.property("android") as AppExtension).applicationVariants} else {  (project.property("android") as LibraryExtension).libraryVariants}
复制代码
找到想要 hock 的任务

我们想 hock 住 android 插件运行的 task 任务,就需要一个重要的 gradle 回调

erlang复制代码project.afterEvaluate{...}
复制代码

afterEvaluate 该方法就是整个 gradle 配置文件配置成功后的回调,证明此时配置已检查完毕,所有 task 已经就绪,已经可以开始按指定顺序运行 task 了,那么我就需要在这个回调里办事!

Grade 执行顺序

  1. 执行 setting,检测所有 module,为每个模块配置 project

  2. 加载 build.properties,生成 task 执行链表和配置

  3. 执行某个指定 task,然后会先执行该 task 所依赖的 task

配置完成后,开始遍历 variants 中所有的变体

arduino复制代码project.afterEvaluate {  variants.all { variant ->    ...  }}
复制代码
我们的目标 task:mergeResourcesProvider

mergeResourcesProvider 这个任务就是 android 插件合并所有 module 中资源的 task,看名字就知道了。

我们可以从变体中获取这个 task 对象

ini复制代码val mergeResourcesTask = variant.mergeResourcesProvider.get()
复制代码

那么,我们自己的任务呢?

gradle api 提供给我们可以在代码中生成 task 的方法

ini复制代码val mcPicTask = project.task("CheckBigImage${variant.name.capitalize()}")
复制代码

使用 project.task("taskname")来生成一个我们自己需要执行的 task

然后我们编写这个 task 的逻辑,也是本插件的逻辑

复制代码mcPicTask.doLast {...}
复制代码

variant 里面有各种对象,allRawAndroidResources 恰好就是我们需要的。它只有 3.3 以上才会有。

ini复制代码val dir = variant.allRawAndroidResources.files
复制代码

这个 dir 对象,就是 android 所有文件资源的 files 集合

ok。让我们遍历这个文件 list 吧!

scss复制代码for (channelDir: File in dir) {check(channelDir)}fun check(file: File) { if(file.isDirectory) {   check(file)} else {   process(file)}}
复制代码

如果遇到文件夹,这里是一个递归调用。

如果遇到文件,就可以按照自己的规则处理了。

挂钩 mergeResourcesProvider

我们 task 写好后,需要和 mergeResourcesProvider 挂钩

less复制代码mergeResourcesTask.dependsOn(project.tasks.findByName(mcPicTask.name))
复制代码

使 mergeResourcesTask 依赖我们的 mcPicTask,当 mergeResourcesTask 执行前,就会先执行我们的 mcPicTask 了!!

注意:此处直接使用 mergeResourcesTask 系统 task 依赖我们的 task,我们的 task 执行顺序会和 mergeResourcesTask 原有的依赖混杂在一起,不可控。后面讲一种可控的方法

拦截图片的逻辑

这个逻辑应该实现在上面伪代码 process(file:File)方法中

  1. 首先我们只需要处理图片,所以对参数 file 进行首轮过滤,只留下后缀名为图片的文件

    kotlin 复制代码

    fun isImage(file: File): Boolean { return (file.name.endsWith(Const.JPG) || file.name.endsWith(Const.PNG) || file.name.endsWith(Const.JPEG) || file.name.endsWith(Const.GIF) || file.name.endsWith(Const.WEB_P) ) && !file.name.endsWith(Const.DOT_9PNG)}

  2. 需要检查图片的宽高的话,可以使用 java 的原生 api

    arduino 复制代码

    val sourceImg = ImageIO.read(FileInputStream(imgFile))if (sourceImg.height > maxHeight || sourceImg.width > maxWidth) { ...

  3. 需要过滤图片大小的话

    lua 复制代码

    if (imgFile.length() >= maxSize) { LogUtil.log(SIZE_TAG, imgFile.path, true.toString()) return true}

压缩图片逻辑

这里我们只处理普通图片转换为 webp 的压缩。jpg,png 的自压缩原理相同,就不复述了

想压缩转换 webp 图片,需要用到转换工具

google 提供的有一套命令行转换工具:cwebp ,各个平台都有,我们去下载一套,放在我们的主工程文件夹下就可以了

这里需要注意的是:为了方便,如果把 cwebp 命令行程序放在环境变量下,那么执行命令时,拼接命令时,直接拼接 cwebp 就好。

如果使用工程目录下的 cwebp,执行前,需要在 cwebp 命令前面拼接它所在的工程目录。

使用

lua复制代码project.rootDir.path
复制代码

可以获取工程的根目录

如何执行命令行程序呢?

可以使用 java 的 api

scss复制代码Runtime.getRuntime().exec(cmd)
复制代码

现在可以愉快的转换图片了

bash复制代码Tools.cmd("cwebp", "${imgFile.path} -o ${webpFile.path} -m 6 -quiet")
复制代码

转换后,记得把原图删掉

优化点:

  1. 有的图片转换后比以前还大,这里需要注意

  2. 第一次扫描过后的无法优化的图片,可以存在一个 text 文本当中,第二次执行时,就不要去转换了

系统兼容

在 linux 系统上,创建和删除文件都需要权限,如果没有权限就会失败。这时需要先判断当前的操作系统是不是 linux,如果是,可以执行 chmod 755 -R ${FileUtil.getRootDirPath()}添加权限

这里可以优化一下,在我们的 mcPicTask 前面再加一个 task,用来添加权限,这个 task 只对文件夹进行递归添加就可以了,比一个一个文件要来的快。

因为我们不清楚系统的 task(mergeResourcesTask)都依赖了哪些,那么如何在依赖上再加依赖,如何插入 task 呢?

gradle api 提供给了我们一个方法,xxx.taskDependencies.getDependencies(xxx)可以获取自己的依赖树

在这里就是

scss复制代码(project.tasks.findByName(chmodTask.name) asTask).dependsOn(mergeResourcesTask.taskDependencies.getDependencies(mergeResourcesTask))
复制代码

让 chmodTask 依赖 mergeResourcesTask 的依赖。假如 mergeResourcesTask 是 A,chmodTask 是 B。A 依赖一个系统的 C。那么上面的代码就是让 B 依赖了 C。这时的 task 图就是 B->C,A->C

接下来我们再把 mcPicTask(简称为 D)也依赖进来

arduino复制代码(project.tasks.findByName(mcPicTask.name) as Task).dependsOn(project.tasks.findByName(chmodTask.name) as Task)
复制代码

这时就是 D->B->C,A->C

最后,回到我们刚刚拦截图片的逻辑的最后代码

less复制代码mergeResourcesTask.dependsOn(project.tasks.findByName(mcPicTask.name))
复制代码

就变成了 A->D->B->C,也就是 mergeResourcesTask->mcPicTask->chmodTask->原依赖 task,依赖和执行顺序是相反的。

正常的代码就是

scss复制代码(project.tasks.findByName(chmodTask.name) asTask).dependsOn(mergeResourcesTask.taskDependencies.getDependencies(mergeResourcesTask))(project.tasks.findByName(mcPicTask.name) as Task).dependsOn(project.tasks.findByName(chmodTask.name) as Task)mergeResourcesTask.dependsOn(project.tasks.findByName(mcPicTask.name))
复制代码
Tips

直接使用 mergeResourcesTask.dependsOn(project.tasks.findByName(mcPicTask.name))插入 task。执行顺序打印

......

Task :app:mainApkListPersistenceDebug UP-TO-DATE

Task :app:CheckBigImageDebug

Task :app:generateDebugResValues UP-TO-DATE Task :app:generateDebugResources UP-TO-DATE Task :app:mergeDebugResources

......

而使用正规的插入法顺序

Task :app:mainApkListPersistenceDebug UP-TO-DATE Task :app:generateDebugResValues UP-TO-DATE Task :app:generateDebugResources UP-TO-DATE Task :app:chmodDebug

Task :app:CheckBigImageDebug

Task :app:mergeDebugResources

gradle 版本差异

我们上面的例子,都是基于比较最新的 gradle 和 android gradle tools 版本(>3.3),android 插件直接提供给了我们 allRawAndroidResources,方便无比,直接在 merge 前遍历它就好了。

那么 3.3 之前的版本呢?就是我们最初的设想了,在合并完各个 module 资源后,扫描 merge 文件夹!这里又有 aapt 和 aapt2 的差异

方法一

关掉 aapt2

ini复制代码android.enableAapt2=false
复制代码

mergeDebugResources 后,processDebugResources 前扫描文件夹

前面说过,mergeDebugResources 是合并所有 module 的资源文件到固定目录

那么 processDebugResources 是什么呢?就是处理这些已经合并完成的文件,生成R.id,资源索引之类的文件

那么我们的任务就必须插入到 processDebugResources 前面,而不是 mergeDebugResources

方法二

仔细翻了翻 MergeResources 里面的方法,有一个 getResSet 和 computeResourceSetList 看起来有点意思。那么 computeResourceSetList 中又调用了 getResSet。最后发现 computeResourceSetList 果然可以获取所有文件列表。

less复制代码/*** Computes the list of resource sets to be used during execution based all the inputs.*/@VisibleForTesting@NonNullList<ResourceSet> computeResourceSetList()
复制代码

注释也很有意思,有道翻译一下:根据所有输入计算执行期间使用的资源集列表。

鉴于该方法是友元方法,就使用反射获取。

因为 3.3 之后,aapt2 是强制开启的,并且 aapt2 merge 后的文件不是原文件了哦!注意 aapt1 合并后,还是正常的 xxx.png。aapt2 合并后的文件扩展名为 flat

所以,方法一不支持大于 3.3 的 gradle 版本。方法二支持。可以平滑过渡到新版本。鉴于新版本的 gradle 直接提供了 allRawAndroidResources 这样的方法,所以在 3.3 以上,直接使用它就可以了

allRawAndroidResources 和扫描合并文件夹的差异。

allRawAndroidResources 提供的是未合并前的资源路径

  • 源码依赖的 module,编译时,会获取该文件的真实路径

  • aar 依赖的路径,会获取到 aar-cache 的路径

  • 所以:如果开启自动转换 webp 功能你会发现:你本地源代码中的 png,都转成了 webp

扫描合并文件夹,扫描的是编译期 merge 成功后的文件夹

  • 不会影响源代码


优化

  1. 已经扫描过的,且确认无法经过 webp 优化的图片,把这些名称写入一个本地文件,优化扫描速度

未来想做的事情

统计

  1. 拦截了多少图片

  2. 转换了多少图片

    3. 统计各个模块的图片资源情况。在合适的时间进行预警

用户头像

java易二三

关注

还未添加个人签名 2021-11-23 加入

还未添加个人简介

评论

发布
暂无评论
Android图片资源检测插件实现_Java_java易二三_InfoQ写作社区