在 matrix 代码中有一个 matrix-apk-canary
的 library ,可以查看 apk 的一些详细信息,如大小、方法数、资源使用等等一些情况。
包含功能如下:
UnzipTask(config, params) 解压 apk 文件
ManifestAnalyzeTask(config, params) 解析 Manifest、arsc文件
ShowFileSizeTask(config, params) 统计超过阈值的文件
MethodCountTask(config, params) 统计方法数
ResProguardCheckTask(config, params) 判断 apk 是否资源混淆(使用资源混淆来进一步减小 apk 的大小)
FindNonAlphaPngTask(config, params) 检测 png 文件是否有透明度(对于不含 alpha 通道的 png 文件,可以转成 jpg 格式来减少文件的大小)
MultiLibCheckTask(config, params) 判断是不是存在多 CPU 架构的 so
UncompressedFileTask(config, params) 检测原文件是否压缩(未压缩可以考虑是否需要压缩)
CountRTask(config, params) 统计 R 文件数量(编译之后,代码中对资源的引用都会优化成 int 常量,除了 R.styleable 之外,其他的 R 类其实都可以删除)
DuplicateFileTask(config, params) 判断是否存在一样的文件,通过计算文件的 md5
MultiSTLCheckTask(config, params) 判断是否依赖 std 标准模板库(如果有多个动态库都依赖了 STL ,应该采用动态链接的方式而非多个动态库都去静态链接 STL)
UnusedResourcesTask(config, params) 检测代码、资源文件中未引用的资源
UnusedAssetsTask(config, params); 检测未使用的 assets 资源
UnStrippedSoCheckTask(config, params) 检测为裁剪的 so(去掉调试信息)
CountClassTask(config, params) 统计类数量
使用说明如下
- 1.可以将
matrix-apk-canary
library 编译成 jar ,运行java jar -apkjar params1 params2 ...
即可。 - 2.直接在工程 run 也可。
其中参数说明:在matrix-apk-canary
工程下有一个apk-checker-config.json
文件,
-
--apk
替换成自己的apk路径 -
--output
替换为自己的结果输出路径,会输出一个html和一个json文件 -
--toolnm
替换为自己sdk下的nm命令行工具路径 -
--rTxt
替换为自己的build目录下的R.txt路径
之后可以将apk-checker-config.json
文件的路径作为运行的参数,最终可以在--output
目录下找到输出的 apk 分析结果有一个 html 的和一个 json 的结果。
流程原理如下:
matrix-apk-canary
的整体工作流程如上所示。
- UnzipTask
首先 apk 首先会经过 UnzipTask 处理,解压到--output
目录,还会在这里读取 mapping.txt (反混淆类名)、读取 resMapping.txt (反混淆资源)、统计文件数量及大小等。并将这些数据写入到JobConfig
这个类中,之后的 task 只需要读取这个JobConfig
这个类中的数据就可以了。
- UnzipTask
- ManifestAnalyzeTask
ManifestAnalyzeTask 用于读取AndroidManifest.xml
中的信息,如:packageName、verisonCode、clientVersion 等。
利用 ApkTool 中的 ApkResourceDecoder.createAXmlParser() 来解析二进制的 AndroidManifest.xml 和 arscFile 文件,不断读取 xml 的数据,遇到 handleStartElement( "<" 进,记录 xml) 与 handleEndElement( ">" 出,丢弃记录的 xml 数据) ,最终只会记录packageName、verisonCode、clientVersion等数据。
- ManifestAnalyzeTask
- ShowFileSizeTask
根据apk-checker-config.json
文件中的规则,如"--min":"10", "--order":"desc","--suffix":"png, jpg, jpeg, gif, arsc"
,过滤超过最小文件大小以及文件后缀名文件,并按照升序或降序排列结果。
利用 UnzipTask 中统计的文件的entryList
来作为输入,根据上面的规则来输出结果。
- ShowFileSizeTask
if (!entrySizeMap.isEmpty()) { //take advantage of the result of UnzipTask.
for (Map.Entry<String, Pair<Long, Long>> entry : entrySizeMap.entrySet()) {
final String suffix = getSuffix(entry.getKey());
Pair<Long, Long> size = entry.getValue();
if (size.getFirst() >= downLimit * ApkConstants.K1024) { // > 10240
if (filterSuffix.isEmpty() || filterSuffix.contains(suffix)) {
// 记录 size > 10240 ,且是 png, jpg, jpeg, gif, arsc 结尾的文件
entryList.add(Pair.of(entry.getKey(), size.getFirst())); // png, jpg, jpeg, gif, arsc
} else {
Log.d(TAG, "file: %s, filter by suffix.", entry.getKey());
}
} else {
Log.d(TAG, "file:%s, size:%d B, downlimit:%d KB", entry.getKey(), size.getFirst(), downLimit);
}
}
}
Collections.sort(entryList,...);
- 4.MethodCountTask
统计出各个 Dex 中的方法数,并按照类名或者包名来分组输出结果。
遍历 UnzipTask 中解压后是 dex 结尾的文件,利用 dexdeps 类库来读取 dex 文件,统计方法数。
for (int i = 0; i < dexFileList.size(); i++) {
RandomAccessFile dexFile = dexFileList.get(i);
// 统计 dex 中的方法数并将其存在 classInternalMethod 和 classExternalMethod 中
countDex(dexFile);
dexFile.close();
int totalInternalMethods = sumOfValue(classInternalMethod);
int totalExternalMethods = sumOfValue(classExternalMethod);
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("dex-file", dexFileNameList.get(i));
if (JobConstants.GROUP_CLASS.equals(group)) {
...
} else if (JobConstants.GROUP_PACKAGE.equals(group)) {
String packageName;
// 聚合 package 下的方法数
for (Map.Entry<String, Integer> entry : classInternalMethod.entrySet()) {
// 读出包名,用最后一个 . 来分割
packageName = ApkUtil.getPackageName(entry.getKey()); // android.accounts.Account->android.accounts
if (!Util.isNullOrNil(packageName)) {
// 原来没有就是 1 ,有的话就加上之前的数量
if (!pkgInternalRefMethod.containsKey(packageName)) {
pkgInternalRefMethod.put(packageName, entry.getValue()); // pair{android.accounts,1}
} else {
pkgInternalRefMethod.put(packageName, pkgInternalRefMethod.get(packageName) + entry.getValue());
}
}
}
// 以方法包名的数量排序
List<String> sortList = sortKeyByValue(pkgInternalRefMethod);
JsonArray packages = new JsonArray();
for (String pkgName : sortList) {
JsonObject pkgObj = new JsonObject();
pkgObj.addProperty("name", pkgName);
pkgObj.addProperty("methods", pkgInternalRefMethod.get(pkgName));
packages.add(pkgObj);
}
// 记录
jsonObject.add("internal-packages", packages);
}
// 记录
jsonObject.addProperty("total-internal-classes", classInternalMethod.size());
jsonObject.addProperty("total-internal-methods", totalInternalMethods);
if (JobConstants.GROUP_CLASS.equals(group)) {
...
} else if (JobConstants.GROUP_PACKAGE.equals(group)) {
String packageName = "";
for (Map.Entry<String, Integer> entry : classExternalMethod.entrySet()) {
packageName = ApkUtil.getPackageName(entry.getKey()); // android.accounts.Account->android.accounts
if (!Util.isNullOrNil(packageName)) {
if (!pkgExternalMethod.containsKey(packageName)) {
pkgExternalMethod.put(packageName, entry.getValue());
} else {
pkgExternalMethod.put(packageName, pkgExternalMethod.get(packageName) + entry.getValue());
}
}
}
List<String> sortList = sortKeyByValue(pkgExternalMethod);
JsonArray packages = new JsonArray();
for (String pkgName : sortList) {
JsonObject pkgObj = new JsonObject();
pkgObj.addProperty("name", pkgName);
pkgObj.addProperty("methods", pkgExternalMethod.get(pkgName));
packages.add(pkgObj);
}
jsonObject.add("external-packages", packages);
}
jsonObject.addProperty("total-external-classes", classExternalMethod.size());
jsonObject.addProperty("total-external-methods", totalExternalMethods);
jsonArray.add(jsonObject);
}
- ResProguardCheckTask
判断 apk 是否经过了资源混淆
资源混淆之后的 res 文件夹会重命名成 r ,直接判断是否存在文件夹 r 即可判断是否经过了资源混淆。
- ResProguardCheckTask
if (resDir.exists() && resDir.isDirectory()) {
Log.i(TAG, "find resource directory " + resDir.getAbsolutePath());
((TaskJsonResult) taskResult).add("hasResProguard", true);
} else {
resDir = new File(inputFile, ApkConstants.RESOURCE_DIR_NAME);
if (resDir.exists() && resDir.isDirectory()) {
File[] dirs = resDir.listFiles();
boolean hasProguard = true;
for (File dir : dirs) {
if (dir.isDirectory() && !fileNamePattern.matcher(dir.getName()).matches()) {
hasProguard = false;
Log.i(TAG, "directory " + dir.getName() + " has a non-proguard name!");
break;
}
}
((TaskJsonResult) taskResult).add("hasResProguard", hasProguard);
} else {
throw new TaskExecuteException(TAG + "---No resource directory found!");
}
}
- 6.FindNonAlphaPngTask
检测出 apk 中非透明的是 png 结尾的图片且不是 .9.png 的图片
通过 java.awt.BufferedImage 类读取png文件并判断是否有 alpha 通道。
private void findNonAlphaPng(File file) throws IOException {
if (file != null) {
if (file.isDirectory()) {
File[] files = file.listFiles();
for (File tempFile : files) {
findNonAlphaPng(tempFile);
}
// 是 png 结尾的图片并且不是 .9.png 的图片
} else if (file.isFile() && file.getName().endsWith(ApkConstants.PNG_FILE_SUFFIX) && !file.getName().endsWith(ApkConstants.NINE_PNG)) {
BufferedImage bufferedImage = ImageIO.read(file);
// 颜色模式没有透明度
if (bufferedImage != null && bufferedImage.getColorModel() != null && !bufferedImage.getColorModel().hasAlpha()) {
String filename = file.getAbsolutePath().substring(inputFile.getAbsolutePath().length() + 1);
if (entryNameMap.containsKey(filename)) {
filename = entryNameMap.get(filename);
}
long size = file.length();
if (entrySizeMap.containsKey(filename)) {
size = entrySizeMap.get(filename).getFirst();
}
if (size >= downLimitSize * ApkConstants.K1024) { // 10 * 1024
nonAlphaPngList.add(Pair.of(filename, file.length()));
}
}
}
}
}
-7.MultiLibCheckTask
判断 app 的 so 是不是支持多 CPU 的
遍历 lib 文件夹下是否包含多个目录。
@Override
public TaskResult call() throws TaskExecuteException {
try {
TaskResult taskResult = TaskResultFactory.factory(getType(), TASK_RESULT_TYPE_JSON, config);
if (taskResult == null) {
return null;
}
long startTime = System.currentTimeMillis();
JsonArray jsonArray = new JsonArray();
if (libDir.exists() && libDir.isDirectory()) {
File[] dirs = libDir.listFiles();
for (File dir : dirs) {
if (dir.isDirectory()) {
jsonArray.add(dir.getName());
}
}
}
((TaskJsonResult) taskResult).add("lib-dirs", jsonArray);
if (jsonArray.size() > 1) {
((TaskJsonResult) taskResult).add("multi-lib", true);
} else {
((TaskJsonResult) taskResult).add("multi-lib", false);
}
taskResult.setStartTime(startTime);
taskResult.setEndTime(System.currentTimeMillis());
return taskResult;
} catch (Exception e) {
throw new TaskExecuteException(e.getMessage(), e);
}
}
- 8.UncompressedFileTask
检测出未经压缩的png, jpg, jpeg, gif, arsc文件类型
实现方法:直接利用 UnzipTask 中统计的各个文件的压缩前和压缩后的大小,判断压缩前和压缩后大小是否相等。
@Override
public TaskResult call() throws TaskExecuteException {
try {
TaskResult taskResult = TaskResultFactory.factory(type, TaskResultFactory.TASK_RESULT_TYPE_JSON, config);
if (taskResult == null) {
return null;
}
long startTime = System.currentTimeMillis();
JsonArray jsonArray = new JsonArray();
// Map<文件名,Pair<解压大小,压缩大小>> 在 UnzipTask 中生成
Map<String, Pair<Long, Long>> entrySizeMap = config.getEntrySizeMap();
if (!entrySizeMap.isEmpty()) { //take advantage of the result of UnzipTask.
for (Map.Entry<String, Pair<Long, Long>> entry : entrySizeMap.entrySet()) {
final String suffix = getSuffix(entry.getKey());
Pair<Long, Long> size = entry.getValue();
if (filterSuffix.isEmpty() || filterSuffix.contains(suffix)) { // png, jpg, jpeg, gif, arsc
if (!uncompressSizeMap.containsKey(suffix)) {
uncompressSizeMap.put(suffix, size.getFirst());
} else {
uncompressSizeMap.put(suffix, uncompressSizeMap.get(suffix) + size.getFirst());
}
if (!compressSizeMap.containsKey(suffix)) {
compressSizeMap.put(suffix, size.getSecond());
} else {
compressSizeMap.put(suffix, compressSizeMap.get(suffix) + size.getSecond());
}
} else {
Log.d(TAG, "file: %s, filter by suffix.", entry.getKey());
}
}
}
for (String suffix : uncompressSizeMap.keySet()) {
if (uncompressSizeMap.get(suffix).equals(compressSizeMap.get(suffix))) {
JsonObject fileItem = new JsonObject();
fileItem.addProperty("suffix", suffix);
fileItem.addProperty("total-size", uncompressSizeMap.get(suffix));
jsonArray.add(fileItem);
}
}
((TaskJsonResult) taskResult).add("files", jsonArray);
taskResult.setStartTime(startTime);
taskResult.setEndTime(System.currentTimeMillis());
return taskResult;
} catch (Exception e) {
throw new TaskExecuteException(e.getMessage(), e);
}
}
- 9.CountRTask
统计 R 类以及 R 类的中的 field 数目
通过 dexdeps 类库来遍历 dex 文件,找出 R 类以及 field 数目。
@Override
public TaskResult call() throws TaskExecuteException {
try {
TaskResult taskResult = TaskResultFactory.factory(type, TaskResultFactory.TASK_RESULT_TYPE_JSON, config);
long startTime = System.currentTimeMillis();
Map<String, String> classProguardMap = config.getProguardClassMap();
for (RandomAccessFile dexFile : dexFileList) {
DexData dexData = new DexData(dexFile);
dexData.load();
dexFile.close();
ClassRef[] defClassRefs = dexData.getInternalReferences();
for (ClassRef classRef : defClassRefs) {
String className = ApkUtil.getNormalClassName(classRef.getName());
if (classProguardMap.containsKey(className)) {
className = classProguardMap.get(className);
}
String pureClassName = getOuterClassName(className);// java.lang.String->java.lang.String com.zhejiang.a.A$Z->com.zhejiang.a.A
if (pureClassName.endsWith(".R") || "R".equals(pureClassName)) {
if (!classesMap.containsKey(pureClassName)) {
classesMap.put(pureClassName, classRef.getFieldArray().length);
} else {
classesMap.put(pureClassName, classesMap.get(pureClassName) + classRef.getFieldArray().length);
}
}
}
}
JsonArray jsonArray = new JsonArray();
long totalSize = 0;
// null
Map<String, String> proguardClassMap = config.getProguardClassMap();
for (Map.Entry<String, Integer> entry : classesMap.entrySet()) {
JsonObject jsonObject = new JsonObject();
if (proguardClassMap.containsKey(entry.getKey())) {
jsonObject.addProperty("name", proguardClassMap.get(entry.getKey()));
} else {
jsonObject.addProperty("name", entry.getKey());
}
jsonObject.addProperty("field-count", entry.getValue());
totalSize += entry.getValue();
jsonArray.add(jsonObject);
}
((TaskJsonResult) taskResult).add("R-count", jsonArray.size());
((TaskJsonResult) taskResult).add("Field-counts", totalSize);
((TaskJsonResult) taskResult).add("R-classes", jsonArray);
taskResult.setStartTime(startTime);
taskResult.setEndTime(System.currentTimeMillis());
return taskResult;
} catch (Exception e) {
throw new TaskExecuteException(e.getMessage(), e);
}
}
- 10.DuplicatedFileTask
检测出冗余的文件
通过比较文件的 MD5 是否相等来判断文件内容是否相同。
// 计算每个文件的 md5,存到 md5Map 中,key = md5,value = array,如果有相同 md5 的就加入到 array 中。
// 添加每个文件到 fileSizeList 中,T = pair{md5,file_size}
private void computeMD5(File file) throws NoSuchAlgorithmException, IOException {
if (file != null) {
if (file.isDirectory()) {
File[] files = file.listFiles();
for (File resFile : files) {
computeMD5(resFile);
}
} else {
MessageDigest msgDigest = MessageDigest.getInstance("MD5");
BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file));
byte[] buffer = new byte[512];
int readSize = 0;
long totalRead = 0;
while ((readSize = inputStream.read(buffer)) > 0) {
msgDigest.update(buffer, 0, readSize);
totalRead += readSize;
}
inputStream.close();
if (totalRead > 0) {
final String md5 = Util.byteArrayToHex(msgDigest.digest());
String filename = file.getAbsolutePath().substring(inputFile.getAbsolutePath().length() + 1); // 从 un—zip 目录开始
if (entryNameMap.containsKey(filename)) {
filename = entryNameMap.get(filename);
}
if (!md5Map.containsKey(md5)) {
md5Map.put(md5, new ArrayList<String>());
if (entrySizeMap.containsKey(filename)) {
fileSizeList.add(Pair.of(md5, entrySizeMap.get(filename).getFirst()));
} else {
fileSizeList.add(Pair.of(md5, totalRead));
}
}
md5Map.get(md5).add(filename);
}
}
}
}
- 11.MultiSTLCheckTask
检测 apk 中的 so 是否静态链接 STL
通过 nm 工具来读取 so 的符号表,如果出现 std:: 即表示 so 静态链接了 STL 。
// libFile so文件
private boolean isStlLinked(File libFile) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(toolnmPath, "-D", "-C", libFile.getAbsolutePath());
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
![unuse_resource.png](https://upload-images.jianshu.io/upload_images/16590719-a131d5e46c8503a2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
String line = reader.readLine();
while (line != null) {
String[] columns = line.split(" ");
Log.d(TAG, "%s", line);
if (columns.length >= 3 && columns[1].equals("T") && columns[2].startsWith("std::")) {
return true;
}
line = reader.readLine();
}
reader.close();
process.waitFor();
return false;
}
- 12.UnusedResourceTask
检测出 apk 中未使用的资源,对于 getIdentifier 获取的资源可以加入白名单
原理(copy): - 读取 R.txt 获取 apk 中声明的所有资源得到 declareResourceSet ;
- 读取 smali 文件中引用资源的指令(包括通过 reference 和直接通过资源 id 引用资源)得出 class 中引用的资源 classRefResourceSet ;
- 通过 ApkTool 解析 res 目录下的 xml 文件、AndroidManifest.xml 以及 resource.arsc 得出资源之间的引用关系;
- 根据上述几步得到的中间数据即可确定出 apk 中未使用到的资源。
- 13.UnusedAssetsTask
检测出 apk 中未使用的 assets 资源
public TaskResult call() throws TaskExecuteException {
try {
TaskResult taskResult = TaskResultFactory.factory(type, TaskResultFactory.TASK_RESULT_TYPE_JSON, config);
long startTime = System.currentTimeMillis();
File assetDir = new File(inputFile, ApkConstants.ASSETS_DIR_NAME);
// 找到 assets 下的所有文件,存在 assetsPathSet 中
findAssetsFile(assetDir);
// 将符合过滤规则 ".so" 的 assetsPathSet 中的文件,存在了 assetRefSet
// assetsPathSet 中的文件去掉了前缀
generateAssetsSet(assetDir.getAbsolutePath());
Log.i(TAG, "find all assets count: %d", assetsPathSet.size());
// 将代码中使用到的 assets 资源加入到了 assetRefSet 里面
decodeCode();
Log.i(TAG, "find reference assets count: %d", assetRefSet.size());
assetsPathSet.removeAll(assetRefSet);
JsonArray jsonArray = new JsonArray();
for (String name : assetsPathSet) {
jsonArray.add(name);
}
((TaskJsonResult) taskResult).add("unused-assets", jsonArray);
taskResult.setStartTime(startTime);
taskResult.setEndTime(System.currentTimeMillis());
return taskResult;
} catch (Exception e) {
throw new TaskExecuteException(e.getMessage(), e);
}
}
- 14.UnStrippedSoCheckTask
检测 so 文件是否裁剪(去除了调试信息)
通过 nm 工具来读取 so 的符号表,如果出现 no symbols(错误输出) 表明已经去除了调试信息 。
private boolean isSoStripped(File libFile) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(toolnmPath, libFile.getAbsolutePath());
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String line = reader.readLine();
boolean result = false;
if (!Util.isNullOrNil(line)) {
Log.d(TAG, "%s", line);
String[] columns = line.split(":");
if (columns.length == 3 && columns[2].trim().equalsIgnoreCase("no symbols")) {
result = true;
}
}
reader.close();
process.waitFor();
return result;
}
写在最后
是不是可以自己编译成 jar,配合上线前的测试检测,分析 apk 的数据之后可以根据此结果再次的优化包体积、统计每次发布包的大小、文件占比及连续二次发布包的比较等等呢