App体积缩减(lottie动画资源转webp)- 自定义task

前言

上期说到了一个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
}
image.png

上述写到mac目录的父目录即可

结语,在github上有一个开源库:

https://github.com/smallSohoSolo/McImage
该库可以编译期间压缩资源目录下的图片,也可以大大减少app体积,本文中部分思路也是借鉴了该库的一些思想。这个库还是非常优秀的

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容