Android程序員必會技能---複雜Gradle Plugin編寫-找到未使用的asset文件

以前咱們介紹了兩種動態生成類的方法,編譯期註解動態代理java

這兩種方法呢都很實用,也很簡單,可是都各自有侷限性,好比說動態代理對接口要求性高,編譯期註解也只適合動態生成新類,不太適用於直接修改類,比方說咱們看某個jar包不爽,要修改裏面的方法,而不是在這個方法先後進行hook的話,編譯期註解和動態代理就基本一籌莫展了,這種時候更適合用 字節碼修改 這種更高級一點的方法。好比 asm aspectj javassist 這三大字節碼修改框架,然而在android中要使用這三種東西,須要你對gradle plugin 有一些瞭解。android

因此今天咱們就先介紹下gradle plugin具體如何使用,建議你們在閱讀本文時,最好對gradle plugin有必定的基礎瞭解。 今天這篇文章 直接介紹一個簡單小工具的plugin的編寫。json

plugin背景:在app代碼愈來愈多,迭代愈來愈頻繁的時候,咱們的assets目錄下就會有不少個文件,咱們但願可以分辨出 assets目錄下 有哪些文件是沒有使用過的,而後利用plugin的實現,來把這些沒使用的文件利用日誌系統告知給咱們, 這樣咱們就能夠及時的控制好包大小,而不用每隔一段時間就在羣裏問。。。。怎麼樣,是否是很方便?api

技術方案:先把apk包解壓縮下來,裏面的assets 文件名所有取出來放到一個list裏面。注意這裏爲了簡單,咱們只考慮 assets文件夾下面只有單層文件的狀況,暫時不考慮assets文件夾下面還有文件夾的嵌套狀況(有這個需求的話你們能夠 後面在個人代碼裏自行修改)sass

而後利用apktool 反編譯 apk包中的dex文件,注意不止一個dex文件要分析哦,由於如今的app都很大,拆包的狀況很廣泛 因此有多少個dex 就要反編譯多少次。bash

你們都知道咱們在使用assets文件的時候是以下:app

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

複製代碼

也就是說若是用到assets下面的文件了,這個文件的文件名必定是寫在字符串裏面的,對於smail來講,這個寫死的字符串 其實就必定是放在常量池裏面的。框架

好比對上面的代碼進行apktool反編譯之後就是:maven

因此最後的方案就很簡單了:ide

拿到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,

相關文章
相關標籤/搜索