以前咱們介紹了兩種動態生成類的方法,編譯期註解和動態代理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,