美文网首页
App体积缩减(lottie动画资源转webp)- 自定义tas

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

作者: kunio | 来源:发表于2021-03-08 16:08 被阅读0次

    前言

    上期说到了一个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体积,本文中部分思路也是借鉴了该库的一些思想。这个库还是非常优秀的

    相关文章

      网友评论

          本文标题:App体积缩减(lottie动画资源转webp)- 自定义tas

          本文链接:https://www.haomeiwen.com/subject/etipqltx.html