这两种方法呢都很实用,也很简单,但是都各自有局限性,比如说动态代理对接口要求性高,编译期注解也只适合动态生成新类,不太适用于直接修改类,比方说我们看某个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,