前言
上期说到了一个app体积缩减的手段:R文件删除,今天介绍另外一种方案来进行体积缩减
正文
今天介绍的方案应该是归属于编译期间进行资源压缩的一种方式,但是压缩的资源不在res目录下,而是assets目录下,主要是针对于lottie动画来做一个处理
处理之前,我们可以先分析一下lottie在做动画的时候,怎么找到所需要的图片的呢?可以先看看lottie相关的json文件,有这个一个地方:
{
......,
下面的属性只需关注key值,value被修改过
"assets": [
{
"id": "id",
"w": w,
"h": h,
"u": "u",
"p": "xxx.png",
"e": 0
},
上面的json可以看到,在assets这个json数组中,每一个json对象中会包含一个p属性,它所指向的就是当前所需要的某一张图片,那么我们就可以去找到相应的图片,从而对图片进行编译期间转成webp,然后将xxx.png修改成xxx.webp即可
这个是我们今天方案的一个整体思路,但是该方案有一些局限性,那就是在编译期间,我们无法知道某一个具体的json文件,它对应的图片文件夹在何处,因为我们在代码中使用的时候,大多情况会选择这么编写一个lottie动画:
<com.airbnb.lottie.LottieAnimationView
app:lottie_fileName="xxx/loading.json"
app:lottie_imageAssetsFolder="xxx/images />
一般情况,设计师给出的文件格式都会如上述所示,json文件和所需的image文件是同属于一个父文件的,那么我们就可以在找到了json文件之后,遍历其同级目录下的文件夹,在该文件夹中寻找是否有json文件中所需的图片,如果有,则进行图片转换
那么现在我们有了转换图片的方法,那么是不是统一对图片做转换即可呢?答案也是否定的,因为有可能某一张图片转换成webp之后,它反而变大了,所以这种情况我们无需进行转换了
到这里,我们有了转换图片的初步方案,那么现在还有一个重要的问题没有得到处理,那就是我们啥时候开始转换?在编译阶段的什么时候呢?
时机
在打包过程中,gradle有很多的task,其中有一个task是用来进行assets文件合并的:mergeDebugAssets / mergeReleaseAssets,这个task用于assets合并,那么我们可以自定义一个task在该task执行之前执行
准备工作
现在还需要一个png转webp的工具,这里使用官方的一个工具:cwebp,下载地址:https://developers.google.com/speed/webp/download
编写代码
首先,我们需要自定义一个task,并且让该task在mergeXxxAssets这个task之前执行
1. plugin部分
public class PreMergeAssetsPlugin implements Plugin<Project> {
private static final String CONFIG_NAME = "preAssetsConfig";
@Override
public void apply(@NotNull Project project) {
boolean hasAppPlugin = project.getPlugins().hasPlugin("com.android.application");
if (!hasAppPlugin) {
throw new GradleException("this plugin can't use in library module");
}
AppExtension android = (AppExtension) project.getExtensions().findByName("android");
if (android == null) {
throw new NullPointerException("application module not have \"android\" block!");
}
project.getExtensions().create(CONFIG_NAME, PreAssetsConfig.class);
DomainObjectSet<ApplicationVariant> variants = android.getApplicationVariants();
project.afterEvaluate(p -> {
PreAssetsConfig config = p.getExtensions().findByType(PreAssetsConfig.class);
if (config != null && !config.enable) {
return;
}
variants.all((Action<BaseVariant>) baseVariant -> {
String name = baseVariant.getName();
String variantName = name.substring(0, 1).toUpperCase() + name.substring(1);
Task preMergeAssetsTask = p.getTasks().create("preMerge" + variantName + "Assets", PreMergeAssetsTask.class, p, baseVariant);
MergeSourceSetFolders mergeAssetsTask = baseVariant.getMergeAssetsProvider().get();
preMergeAssetsTask.dependsOn(mergeAssetsTask.getTaskDependencies().getDependencies(mergeAssetsTask));
mergeAssetsTask.dependsOn(preMergeAssetsTask);
});
});
}
}
2. 实体类
public class PreAssetsConfig {
//
public boolean skipApplication = true;
public String webpConvertToolsDir = "";
public boolean enable = true;
}
2. task
public class PreMergeAssetsTask extends DefaultTask {
private final BaseVariant variant;
private final Project project;
PreAssetsConfig config;
private final ThreadPoolExecutor executor;
@Inject
public PreMergeAssetsTask(Project project, BaseVariant variant) {
this.variant = variant;
this.project = project;
config = project.getExtensions().findByType(PreAssetsConfig.class);
executor = new ThreadPoolExecutor(0,
30,
10,
TimeUnit.SECONDS,
new LinkedBlockingDeque<>());
}
@TaskAction
void action() {
MergeSourceSetFolders mergeAssetsTask = variant.getMergeAssetsProvider().getOrNull();
if (mergeAssetsTask == null) {
return;
}
PreAssetsConfig config = project.getExtensions().findByType(PreAssetsConfig.class);
if (config == null) {
config = new PreAssetsConfig();
}
Set<String> appAssets = null;
// 如果是跳过app模块下的assets,则先收集app下assets文件路径
// 因为app模块下路径是开发直接编写所在的路径,此task若要修改就会直接修改工程文件了,这里做一个开关看是否需要
// 转换app下assets文件
if (config.skipApplication) {
appAssets = new HashSet<>();
List<SourceProvider> sourceSets = variant.getSourceSets();
for (SourceProvider sourceSet : sourceSets) {
Collection<File> assetsDirectories = sourceSet.getAssetsDirectories();
for (File directory : assetsDirectories) {
// 收集application的assets文件目录
appAssets.add(directory.getAbsolutePath());
}
}
}
FileCollection files = mergeAssetsTask.getInputs().getFiles();
List<Set<File>> allAssetsJson = new ArrayList<>();
for (File input : files) {
if (!input.isDirectory()) {
// 不是文件夹不用处理
continue;
}
// 若已经收集了app下的assets目录且目录与app下路径匹配,则跳过
if (appAssets != null && appAssets.contains(input.getAbsolutePath())) {
continue;
}
Set<File> lottiesJson = collectLottieAssetsJsonResource(input);
allAssetsJson.add(lottiesJson);
}
// 每一个module中的json文件集合
List<Set<File>> finalAssetsLottieJson = Collections.unmodifiableList(allAssetsJson);
// 将收集到到的所有json文件传入,由方法内统一判断是否可以进行转换
List<Future<Boolean>> results = new LinkedList<>();
for (Set<File> moduleFiles : finalAssetsLottieJson) {
Future<Boolean> result = executor.submit(() -> transformationPngAndJson(moduleFiles));
results.add(result);
}
// 等待所有的转换线程执行完成
for (Future<Boolean> future : results) {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("PreMergeAssetsTask failed");
}
}
}
/**
* 解析当前集合下的json文件,遍历其同目录的文件夹,看下面是否有符合json文件格式的图片,若找到了符合的,
* 那么就尝试转换图片至webp格式并更改json文件内容
*
* @param source json文件集合
* @return 执行结果
*/
private boolean transformationPngAndJson(Collection<File> source) {
for (File file : source) {
if (!file.getName().endsWith(".json")) {
throw new IllegalArgumentException("收集json文件出错:" + file.getAbsolutePath());
}
String jsonString = Utils.changeFileToJsonString(file);
List<String> names = null;
try {
names = Utils.collectJsonImagesName(jsonString);
} catch (Exception ignored) {
}
if (names == null || names.isEmpty()) {
continue;
}
File parentFile = file.getParentFile();
File[] sameLevelFiles = parentFile.listFiles();
for (File f : sameLevelFiles) {
if (f.isDirectory() && Utils.isTargetImagesDir(f, names)) {
realConvertJsonAndImage(f, file,names);
break;
}
}
}
return true;
}
private void realConvertJsonAndImage(File dir, File json,List<String> names) {
String webpConvertToolsDir = config.webpConvertToolsDir;
if (webpConvertToolsDir.isEmpty()) {
throw new IllegalArgumentException("需要设置webpConvertToolsDir,其为转换工具的mac|windows|linux目录的父级目录");
}
List<String> convertedFileName = Utils.covertToWebp(dir, webpConvertToolsDir,names);
if (!convertedFileName.isEmpty()) {
// debug下输出转换的文件
if (getName().contains("Debug")) {
System.out.println("转换成webp:"+convertedFileName.size()+"个");
System.out.println("dir:"+dir.getAbsolutePath());
for (String name : convertedFileName) {
System.out.println("image:"+name);
}
System.out.println("json:"+json.getAbsolutePath());
System.out.println("\n");
}
// 有转化成功的webp,就相应的修改json文件中的文件名后缀为webp
String jsonString = Utils.changeFileToJsonString(json);
for (String s : convertedFileName) {
jsonString = jsonString.replace(s, s.substring(0, s.lastIndexOf(".")) + ".webp");
}
try (BufferedWriter writer = new BufferedWriter(new FileWriter(json))) {
writer.write(jsonString);
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 收集当前file下所有的.json文件
*
* @param file file对象
* @return Set<File>
*/
@NotNull
private Set<File> collectLottieAssetsJsonResource(File file) {
if (file == null || !file.exists()) {
return Collections.emptySet();
}
Set<File> files = new HashSet<>();
if (file.isFile() && file.getName().endsWith(".json")) {
files.add(file);
}
if (!file.isDirectory()) {
return files;
}
File[] childFile = file.listFiles();
if (childFile == null) {
return files;
}
for (File child : childFile) {
Set<File> childLottieAssetsJsonResource = collectLottieAssetsJsonResource(child);
files.addAll(childLottieAssetsJsonResource);
}
return files;
}
}
2. utils
public class Utils {
private static final String JPG = ".jpg";
private static final String JPEG = ".jpeg";
private static final String PNG = ".png";
private static final String WEBP = ".webp";
private static final String cwebpLastSegment;
static {
if (isMac()) {
cwebpLastSegment = "mac";
} else if (isLinux()) {
cwebpLastSegment = "linux";
} else {
cwebpLastSegment = "windows";
}
}
public static String changeFileToJsonString(File file) {
String line;
StringBuilder builder = new StringBuilder();
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(file));
while ((line = reader.readLine()) != null) {
builder.append(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return builder.toString();
}
public static List<String> collectJsonImagesName(String jsonString) {
JSONObject jsonObject = new JSONObject(jsonString);
boolean fr = jsonObject.has("fr");
boolean ip = jsonObject.has("ip");
boolean w = jsonObject.has("w");
boolean h = jsonObject.has("h");
boolean assets = jsonObject.has("assets");
boolean layers = jsonObject.has("layers");
if (!fr || !ip || !w || !h || !assets || !layers) {
return Collections.emptyList();
}
JSONArray assetsArray = jsonObject.optJSONArray("assets");
List<String> imageNames = new ArrayList<>(assetsArray.length());
for (int i = 0; i < assetsArray.length(); i++) {
JSONObject j = assetsArray.optJSONObject(i);
boolean assetsId = j.has("id");
boolean assetsW = j.has("w");
boolean assetsH = j.has("h");
boolean assetsU = j.has("u");
boolean assetsP = j.has("p");
if (!assetsH || !assetsId || !assetsP || !assetsU || !assetsW) {
continue;
}
String p = j.optString("p");
if (isImage(p)) {
imageNames.add(p);
}
}
return imageNames;
}
private static boolean isImage(String name) {
return name.endsWith(JPG) || name.endsWith(PNG) || name.endsWith(JPEG);
}
/**
*以不带文件后缀名作为匹配
*/
public static boolean isTargetImagesDir(File targetDir, List<String> names) {
Set<String> tempName = new HashSet<>();
for (String name : names) {
String noSuffixName = name.split("\\.")[0];
tempName.add(noSuffixName);
}
File[] files = targetDir.listFiles();
if (files == null || files.length == 0) {
return false;
}
for (File file : files) {
tempName.remove(file.getName().split("\\.")[0]);
}
// if (!tempName.isEmpty()){
// System.out.println("isTargetImagesDir:false \n");
// System.out.println(tempName);
// }
return tempName.isEmpty();
}
private static boolean isLinux() {
String system = System.getProperty("os.name");
return system.startsWith("Linux");
}
private static boolean isMac() {
String system = System.getProperty("os.name");
return system.startsWith("Mac OS");
}
private static boolean isWindows() {
String system = System.getProperty("os.name");
return system.toLowerCase().contains("win");
}
private static void cmd(String cmd) {
try {
Process process = Runtime.getRuntime().exec(cmd);
process.waitFor();
} catch (Exception e) {
e.printStackTrace();
throw new GradleException("命令行执行期间出现错误");
}
}
public static List<String> covertToWebp(File dir, String toolsDir, List<String> names) {
Set<String> needCovert = new HashSet<>();
Map<String,String> noSuffixNames = new HashMap<>();
for (String name : names) {
needCovert.add(name);
String noSuffixName = name.split("\\.")[0];
noSuffixNames.put(noSuffixName,name);
}
// System.out.println("covertToWebp needCovert:"+needCovert);
if (!dir.isDirectory()) {
throw new GradleException("不是文件夹不能进行转换child文件");
}
File[] files = dir.listFiles();
if (files == null || files.length == 0) {
// System.out.println("covertToWebp:dir.listFiles()没有文件:"+dir.getPath());
return Collections.emptyList();
}
List<String> convertedFile = new ArrayList<>();
for (File file : files) {
if (file.getName().endsWith(".webp")) {
// 本来就是webp就无需再进行转换了
// System.out.println("本来就是webp就无需再进行转换了:"+file.getName());
if (noSuffixNames.containsKey(file.getName().split("\\.")[0])){
convertedFile.add(noSuffixNames.get(file.getName().split("\\.")[0]));
}
continue;
}
// 该图片不在json文件中声明,本次不转换
if (!needCovert.contains(file.getName())){
// System.out.println("该图片不在json文件中声明,本次不转换:"+file.getName());
continue;
}
String path = file.getPath();
String webpPath = path.substring(0, path.lastIndexOf(".")) + ".webp";
File webpFile = new File(webpPath);
cmd(toolsDir + File.separator + cwebpLastSegment + File.separator + "cwebp " + file.getAbsolutePath() + " -o " + webpFile.getAbsolutePath() + " -m 6 -quiet");
if (webpFile.length() < file.length()) {
// System.out.println("webpFile.length() < file.length():"+file.getName());
convertedFile.add(file.getName());
file.delete();
} else {
// System.out.println("webpFile.length() > file.length():"+webpFile.getName());
webpFile.delete();
}
}
return Collections.unmodifiableList(convertedFile);
}
}
这里是提供了代码编写所需要的类,具体的插件上传以及使用,需要大家自行处理,在做成插件之后,项目中在app模块下的build.gradle除了依赖插件之外,配置加上:
apply plugin: "xxx.xxx.xxx"
preAssetsConfig {
skipApplication = true
// 该属性,为cwebp工具所在项目中的目录,一般情况下,写至操作系统类型目录的上级目录即可
webpConvertToolsDir = rootProject.projectDir.path + "/xxx"
enable = true
}
上述写到mac目录的父目录即可
结语,在github上有一个开源库:
https://github.com/smallSohoSolo/McImage
该库可以编译期间压缩资源目录下的图片,也可以大大减少app体积,本文中部分思路也是借鉴了该库的一些思想。这个库还是非常优秀的