Android程序员必会技能---复杂Gradle Plugin编写-找到未使用的asset文件

1,637 阅读7分钟

之前我们介绍了两种动态生成类的方法,编译期注解动态代理

这两种方法呢都很实用,也很简单,但是都各自有局限性,比如说动态代理对接口要求性高,编译期注解也只适合动态生成新类,不太适用于直接修改类,比方说我们看某个jar包不爽,要修改里面的方法,而不是在这个方法前后进行hook的话,编译期注解和动态代理就基本束手无策了,这种时候更适合用 字节码修改 这种更高级一点的方法。比如 asm aspectj javassist 这三大字节码修改框架,然而在android中要使用这三种东西,需要你对gradle plugin 有一些了解。

所以今天我们就先介绍下gradle plugin具体如何使用,建议大家在阅读本文时,最好对gradle plugin有一定的基础了解。 今天这篇文章 直接介绍一个简单小工具的plugin的编写。

plugin背景:在app代码越来越多,迭代越来越频繁的时候,我们的assets目录下就会有很多个文件,我们希望能够分辨出 assets目录下 有哪些文件是没有使用过的,然后利用plugin的实现,来把这些没使用的文件利用日志系统告知给我们, 这样我们就可以及时的控制好包大小,而不用每隔一段时间就在群里问。。。。怎么样,是不是很方便?

技术方案:先把apk包解压缩下来,里面的assets 文件名全部取出来放到一个list里面。注意这里为了简单,我们只考虑 assets文件夹下面只有单层文件的情况,暂时不考虑assets文件夹下面还有文件夹的嵌套情况(有这个需求的话大家可以 后面在我的代码里自行修改)

然后利用apktool 反编译 apk包中的dex文件,注意不止一个dex文件要分析哦,因为现在的app都很大,拆包的情况很普遍 所以有多少个dex 就要反编译多少次。

大家都知道我们在使用assets文件的时候是如下:

            InputStream inputStream = assetManager.open("city.json");

也就是说如果用到assets下面的文件了,这个文件的文件名一定是写在字符串里面的,对于smail来说,这个写死的字符串 其实就一定是放在常量池里面的。

比如对上面的代码进行apktool反编译以后就是:

所以最后的方案就很简单了:

拿到assets目录下的 文件列表以后, 我们就对若干个dex文件进行遍历分析,如果反编译出来的smail代码的常量池 里面有 我们assets文件列表中的名字,那么就把这个文件列表中的名字删掉,这样全部遍历分析完毕以后,

这个list里面 还剩下的名字 就一定是没有使用过的文件,此时我们就可以愉快的在群里@所有人让他们各自修改了。

具体实现:

注意我们的plugin工程要引入这个apktool.jar包。 对于plugin工程来说,引入外部工程有2个坑(注意这2个坑是你们在 其他博客中看不到,但是你自己写是有大概率会碰到问题的)

1.对于plugin的groovy来说,引入的jar包 不会自动被打进最终包内。这会导致你上传到maven库上的jar包里面没有 你引入的jar包中的class,这样你的plugin运行起来就会报class not found的错。 这里给出解决方案:

apply plugin: 'groovy'
apply plugin: 'maven'

dependencies {
    implementation files('libs/apktool.jar')
    compile gradleApi()
    compile localGroovy()
}
//指定编译的编码
tasks.withType(JavaCompile) {
    options.encoding = "UTF-8"
}


jar {
    //这个不要遗漏 否则apktool包中的class 不会到你最终plugin的jar包内的
    from zipTree('libs/apktool.jar')
}
uploadArchives {
    repositories {
        mavenDeployer {
            //设置插件的GAV参数
            pom.groupId = 'com.wuyue.plugin'
            pom.artifactId = 'unusedplugin'
            pom.version = '1.0.5'
            //文件发布到下面目录
            repository(url: uri('../repo'))

        }
    }
}

sourceCompatibility = "7"
targetCompatibility = "7"


group = 'com.wuyue.plugin'

2.如果你引入的jar包里面 包含了某些库,而恰好com.android.tools.build:gradle 这个plugin也包含这个库的话 那大概率就要 报错了,比如说我们这里使用的apktools jar包里面 就恰好包含了com.google.common guaua,而我们的com.android.tools.build:gradle 也包含了这个包,且这2个包的版本还不一样,在我们的包中有个方法找不到,所以 最后还是会报错:

Unable to find method 'com.google.common.collect.ImmutableSet.toImmutableSet()Ljava/util/stream/Collector;'.

所以这里的解决方案就是 当你发现你的plugin和com.android.tools.build:gradle 里面有jar包冲突的时候,切记exclude 方案是无效的,因为classpath不支持exclude,所以只能修改我们自己的jar包,把冲突的jar包直接删了就可以了。

我这里就是用的7zip,把我们打出来的jar包里面 冲突的包 直接干掉。最后问题解决

最后上下代码吧,其实代码真的挺简单的,我没用groovy,直接用的java,代码写的比较粗糙,但是功能ok,如果小伙伴 自己有需要的话,最好还是修改下符合工程标准以后再提交吧。

package com.wuyue.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task

class FindUnusePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        //这个没啥好说的,大家如果有需要的话 可以设置task 依赖assemble task
        //我这里没有设置任何依赖,所以任务执行需要我们自己点一下 或者命令行执行一下
        Task task = project.tasks.create("FindUnusedAssetTask", FindUnusedAssetTask)
    }
}
package com.wuyue.plugin

import com.google.common.collect.Ordering
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import org.gradle.internal.impldep.com.google.common.collect.ImmutableMultimap
import org.gradle.internal.impldep.com.google.common.collect.ImmutableSet
import org.jf.baksmali.Adaptors.ClassDefinition
import org.jf.baksmali.BaksmaliOptions
import org.jf.dexlib2.DexFileFactory
import org.jf.dexlib2.Opcodes
import org.jf.dexlib2.dexbacked.DexBackedDexFile
import org.jf.dexlib2.iface.ClassDef
import org.jf.util.IndentingWriter

import java.util.zip.ZipEntry
import java.util.zip.ZipFile

class FindUnusedAssetTask extends DefaultTask {
    @TaskAction
    def startFind() {

        //这个apk path就是我们平时debug 包的 path ,有特殊需要自行更改
        String apkPath = "$project.buildDir/outputs/apk/debug/"
        //把assets下面的 文件名 全都取出来 放到这个list里面
        List<String> assetsFileNameList = getAssetsFileNameList(apkPath)
        //注意dex文件可以有很多
        getUnusedAssetFileInfo(assetsFileNameList, apkPath)
        println("可疑的没有使用过的asset文件:" + assetsFileNameList)
        //其实任务执行完毕以后 我们还需要手动把解压出来的dex文件进行删除,不然目录不干净也容易出bug
        // 这里我就偷懒了不写了,大家如果上生产的话记得自己补一下这个函数
    }

    //反编译 dex 文件 得到Smali 字节码 然后找到 const string 字段 和我们的 asset文件进行比对
    public void getUnusedAssetFileInfo(List<String> assetsFileName, String apkPath) {
        File file = new File(apkPath)
        for (File subFile : file.listFiles()) {
            if (subFile.getName().endsWith("dex")) {
                readSmaliConstString(subFile.getAbsolutePath(), assetsFileName)
            }
        }

    }

    public void readSmaliConstString(String dexFileName, List<String> assetsFileName) {

        DexBackedDexFile dexFile = null;
        try {
            dexFile = DexFileFactory.loadDexFile(new File(dexFileName), Opcodes.forApi(15));
            BaksmaliOptions options = new BaksmaliOptions();
            List<? extends ClassDef> classDefs = Ordering.natural().sortedCopy(dexFile.getClasses());
            for (ClassDef classDef : classDefs) {
                String[] lines = disassembleClass(classDef, options);
                if (lines != null) {
                    readSmaliLines(lines, assetsFileName);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }


    }

    //取得包中的asset文件的list
    public List<String> getAssetsFileNameList(String apkPath) {
        int buffSize = 204800;
        List<String> assetsName = new ArrayList<>();
        File file = new File(apkPath)
        File apkFile;
        if (file.isDirectory()) {
            for (File subFile : file.listFiles()) {
                if (subFile.getName().endsWith("apk")) {
                    println(subFile.getName())
                    apkFile = subFile;
                }
            }
        }
        if (apkFile != null) {
            ZipFile zipFile = null;
            try {
                zipFile = new ZipFile(apkFile.getAbsolutePath());
                Enumeration<ZipEntry> enumeration = (Enumeration<ZipEntry>) zipFile.entries();
                while (enumeration.hasMoreElements()) {
                    ZipEntry zipEntry = enumeration.nextElement();
                    //为了简单 这里只考虑 assets 下面只有单层文件的情况,不考虑asset下面 还存在多层文件夹嵌套的情况
                    //我们把文件名都取出来即可
                    if (zipEntry.getName().startsWith("assets/") && zipEntry.getName().split("/").length == 2) {
                        println(zipEntry.getName().split("/")[1]);
                        assetsName.add(zipEntry.getName().split("/")[1]);
                    }
                    //这一步是为了取出来dex文件 供反编译使用
                    if (zipEntry.getName().endsWith("dex")) {
                        println(zipEntry.getName());

                        FileOutputStream fileOutputStream = new FileOutputStream(apkPath + zipEntry.getName());
                        InputStream inputStream = zipFile.getInputStream(zipEntry);
                        int count = 0, tinybuff = buffSize;
                        if (inputStream.available() < tinybuff) {
                            tinybuff = inputStream.available();//读取流中可读取大小
                        }
                        byte[] datas = new byte[tinybuff];
                        while ((count = inputStream.read(datas, 0, tinybuff)) != -1) {
//遇到文件结尾返回-1 否则返回实际的读数
                            fileOutputStream.write(datas, 0, count);
                            if (inputStream.available() < tinybuff) {
                                tinybuff = inputStream.available();
                            } else tinybuff = buffSize;
                            datas = new byte[tinybuff];
                        }
                        fileOutputStream.flush();//刷新缓冲
                        fileOutputStream.close();
                    }
                }

            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return assetsName;
    }


    public String[] disassembleClass(ClassDef classDef, BaksmaliOptions options) {
        /**
         * The path for the disassembly file is based on the package name
         * The class descriptor will look something like:
         * Ljava/lang/Object;
         * Where the there is leading 'L' and a trailing ';', and the parts of the
         * package name are separated by '/'
         */
        String classDescriptor = classDef.getType();

        //validate that the descriptor is formatted like we expect
        if (classDescriptor.charAt(0) != 'L'
                || classDescriptor.charAt(classDescriptor.length() - 1) != ';') {
//            Log.e(TAG, "Unrecognized class descriptor - " + classDescriptor + " - skipping class");
            return null;
        }

        //create and initialize the top level string template
        ClassDefinition classDefinition = new ClassDefinition(options, classDef);

        //write the disassembly
        Writer writer = null;
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();

            BufferedWriter bufWriter = new BufferedWriter(new OutputStreamWriter(baos, "UTF8"));

            writer = new IndentingWriter(bufWriter);
            classDefinition.writeTo((IndentingWriter) writer);
            writer.flush();
            return baos.toString().split("\n");
        } catch (Exception ex) {
//            Log.e(TAG, "\n\nError occurred while disassembling class " + classDescriptor.replace('/', '.') + " - skipping class");
            ex.printStackTrace();
            // noinspection ResultOfMethodCallIgnored
            return null;
        } finally {
            if (writer != null) {
                try {
                    writer.close();
                } catch (Throwable ex) {
                    ex.printStackTrace();
                }
            }
        }
    }

    public static boolean isNullOrNil(String str) {
        return str == null || str.isEmpty();
    }

    private static void readSmaliLines(String[] lines, List<String> assetsFileNameList) {
        if (lines == null) {
            return;
        }
        for (String line : lines) {
            line = line.trim();
            if (!isNullOrNil(line) && line.startsWith("const-string")) {
                String[] columns = line.split(",");
                if (columns.length == 2) {
                    String assetFileName = columns[1].trim();
                    //把双引号去掉 因为这里的 columns[1].trim() 取出来的常量池的名字 是包含双引号的
                    //所以要把双引号去掉才是正确的常亮名字 这里比较绕,有时间大家自己打下日志或者debug就明白了
                    String trueName = assetFileName.replace("\"", "");
                    if (assetsFileNameList.contains(trueName)) {
                        assetsFileNameList.remove(trueName)
                    }

                }
            }
        }
    }

}

最后执行下我们的plugin,