??在 matrix 代碼中有一個(gè) matrix-apk-canary 的 library ,可以查看 apk 的一些詳細(xì)信息,如大小、方法數(shù)、資源使用等等一些情況。
包含功能如下:
UnzipTask(config, params) 解壓 apk 文件
ManifestAnalyzeTask(config, params) 解析 Manifest、arsc文件
ShowFileSizeTask(config, params) 統(tǒng)計(jì)超過閾值的文件
MethodCountTask(config, params) 統(tǒng)計(jì)方法數(shù)
ResProguardCheckTask(config, params) 判斷 apk 是否資源混淆(使用資源混淆來進(jìn)一步減小 apk 的大?。?FindNonAlphaPngTask(config, params) 檢測(cè) png 文件是否有透明度(對(duì)于不含 alpha 通道的 png 文件,可以轉(zhuǎn)成 jpg 格式來減少文件的大小)
MultiLibCheckTask(config, params) 判斷是不是存在多 CPU 架構(gòu)的 so
UncompressedFileTask(config, params) 檢測(cè)原文件是否壓縮(未壓縮可以考慮是否需要壓縮)
CountRTask(config, params) 統(tǒng)計(jì) R 文件數(shù)量(編譯之后,代碼中對(duì)資源的引用都會(huì)優(yōu)化成 int 常量,除了 R.styleable 之外,其他的 R 類其實(shí)都可以刪除)
DuplicateFileTask(config, params) 判斷是否存在一樣的文件,通過計(jì)算文件的 md5
MultiSTLCheckTask(config, params) 判斷是否依賴 std 標(biāo)準(zhǔn)模板庫(如果有多個(gè)動(dòng)態(tài)庫都依賴了 STL ,應(yīng)該采用動(dòng)態(tài)鏈接的方式而非多個(gè)動(dòng)態(tài)庫都去靜態(tài)鏈接 STL)
UnusedResourcesTask(config, params) 檢測(cè)代碼、資源文件中未引用的資源
UnusedAssetsTask(config, params); 檢測(cè)未使用的 assets 資源
UnStrippedSoCheckTask(config, params) 檢測(cè)為裁剪的 so(去掉調(diào)試信息)
CountClassTask(config, params) 統(tǒng)計(jì)類數(shù)量
使用說明如下
- 1.可以將
matrix-apk-canarylibrary 編譯成 jar ,運(yùn)行java jar -apkjar params1 params2 ...即可。 - 2.直接在工程 run 也可。
其中參數(shù)說明:在matrix-apk-canary 工程下有一個(gè)apk-checker-config.json文件,
-
--apk替換成自己的apk路徑 -
--output替換為自己的結(jié)果輸出路徑,會(huì)輸出一個(gè)html和一個(gè)json文件 -
--toolnm替換為自己sdk下的nm命令行工具路徑 -
--rTxt替換為自己的build目錄下的R.txt路徑
之后可以將apk-checker-config.json文件的路徑作為運(yùn)行的參數(shù),最終可以在--output目錄下找到輸出的 apk 分析結(jié)果有一個(gè) html 的和一個(gè) json 的結(jié)果。
流程原理如下:

apk-process.png
matrix-apk-canary 的整體工作流程如上所示。
- UnzipTask
首先 apk 首先會(huì)經(jīng)過 UnzipTask 處理,解壓到--output目錄,還會(huì)在這里讀取 mapping.txt (反混淆類名)、讀取 resMapping.txt (反混淆資源)、統(tǒng)計(jì)文件數(shù)量及大小等。并將這些數(shù)據(jù)寫入到JobConfig這個(gè)類中,之后的 task 只需要讀取這個(gè)JobConfig這個(gè)類中的數(shù)據(jù)就可以了。
- UnzipTask
- ManifestAnalyzeTask
ManifestAnalyzeTask 用于讀取AndroidManifest.xml中的信息,如:packageName、verisonCode、clientVersion 等。
利用 ApkTool 中的 ApkResourceDecoder.createAXmlParser() 來解析二進(jìn)制的 AndroidManifest.xml 和 arscFile 文件,不斷讀取 xml 的數(shù)據(jù),遇到 handleStartElement( "<" 進(jìn),記錄 xml) 與 handleEndElement( ">" 出,丟棄記錄的 xml 數(shù)據(jù)) ,最終只會(huì)記錄packageName、verisonCode、clientVersion等數(shù)據(jù)。
manifest.png
- ManifestAnalyzeTask
- ShowFileSizeTask
根據(jù)apk-checker-config.json文件中的規(guī)則,如"--min":"10", "--order":"desc","--suffix":"png, jpg, jpeg, gif, arsc",過濾超過最小文件大小以及文件后綴名文件,并按照升序或降序排列結(jié)果。
利用 UnzipTask 中統(tǒng)計(jì)的文件的entryList來作為輸入,根據(jù)上面的規(guī)則來輸出結(jié)果。
- 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 結(jié)尾的文件
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,...);

file_size.png
- 4.MethodCountTask
統(tǒng)計(jì)出各個(gè) Dex 中的方法數(shù),并按照類名或者包名來分組輸出結(jié)果。
遍歷 UnzipTask 中解壓后是 dex 結(jié)尾的文件,利用 dexdeps 類庫來讀取 dex 文件,統(tǒng)計(jì)方法數(shù)。
for (int i = 0; i < dexFileList.size(); i++) {
RandomAccessFile dexFile = dexFileList.get(i);
// 統(tǒng)計(jì) dex 中的方法數(shù)并將其存在 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 下的方法數(shù)
for (Map.Entry<String, Integer> entry : classInternalMethod.entrySet()) {
// 讀出包名,用最后一個(gè) . 來分割
packageName = ApkUtil.getPackageName(entry.getKey()); // android.accounts.Account->android.accounts
if (!Util.isNullOrNil(packageName)) {
// 原來沒有就是 1 ,有的話就加上之前的數(shù)量
if (!pkgInternalRefMethod.containsKey(packageName)) {
pkgInternalRefMethod.put(packageName, entry.getValue()); // pair{android.accounts,1}
} else {
pkgInternalRefMethod.put(packageName, pkgInternalRefMethod.get(packageName) + entry.getValue());
}
}
}
// 以方法包名的數(shù)量排序
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);
}

method_size.png
- ResProguardCheckTask
判斷 apk 是否經(jīng)過了資源混淆
資源混淆之后的 res 文件夾會(huì)重命名成 r ,直接判斷是否存在文件夾 r 即可判斷是否經(jīng)過了資源混淆。
- 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!");
}
}

resguard.png
- 6.FindNonAlphaPngTask
檢測(cè)出 apk 中非透明的是 png 結(jié)尾的圖片且不是 .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 結(jié)尾的圖片并且不是 .9.png 的圖片
} else if (file.isFile() && file.getName().endsWith(ApkConstants.PNG_FILE_SUFFIX) && !file.getName().endsWith(ApkConstants.NINE_PNG)) {
BufferedImage bufferedImage = ImageIO.read(file);
// 顏色模式?jīng)]有透明度
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()));
}
}
}
}
}

png_alpha.png
-7.MultiLibCheckTask
判斷 app 的 so 是不是支持多 CPU 的
遍歷 lib 文件夾下是否包含多個(gè)目錄。
@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);
}
}

mutil_so.png
- 8.UncompressedFileTask
檢測(cè)出未經(jīng)壓縮的png, jpg, jpeg, gif, arsc文件類型
實(shí)現(xiàn)方法:直接利用 UnzipTask 中統(tǒng)計(jì)的各個(gè)文件的壓縮前和壓縮后的大小,判斷壓縮前和壓縮后大小是否相等。
@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
統(tǒng)計(jì) R 類以及 R 類的中的 field 數(shù)目
通過 dexdeps 類庫來遍歷 dex 文件,找出 R 類以及 field 數(shù)目。
@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);
}
}

R.png
- 10.DuplicatedFileTask
檢測(cè)出冗余的文件
通過比較文件的 MD5 是否相等來判斷文件內(nèi)容是否相同。
// 計(jì)算每個(gè)文件的 md5,存到 md5Map 中,key = md5,value = array,如果有相同 md5 的就加入到 array 中。
// 添加每個(gè)文件到 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);
}
}
}
}

md5.png
- 11.MultiSTLCheckTask
檢測(cè) apk 中的 so 是否靜態(tài)鏈接 STL
通過 nm 工具來讀取 so 的符號(hào)表,如果出現(xiàn) std:: 即表示 so 靜態(tài)鏈接了 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()));

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;
}

stl.png
- 12.UnusedResourceTask
檢測(cè)出 apk 中未使用的資源,對(duì)于 getIdentifier 獲取的資源可以加入白名單
原理(copy): - 讀取 R.txt 獲取 apk 中聲明的所有資源得到 declareResourceSet ;
- 讀取 smali 文件中引用資源的指令(包括通過 reference 和直接通過資源 id 引用資源)得出 class 中引用的資源 classRefResourceSet ;
- 通過 ApkTool 解析 res 目錄下的 xml 文件、AndroidManifest.xml 以及 resource.arsc 得出資源之間的引用關(guān)系;
- 根據(jù)上述幾步得到的中間數(shù)據(jù)即可確定出 apk 中未使用到的資源。
- 13.UnusedAssetsTask
檢測(cè)出 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);
// 將符合過濾規(guī)則 ".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);
}
}

unuse_asserts.png
- 14.UnStrippedSoCheckTask
檢測(cè) so 文件是否裁剪(去除了調(diào)試信息)
通過 nm 工具來讀取 so 的符號(hào)表,如果出現(xiàn) no symbols(錯(cuò)誤輸出) 表明已經(jīng)去除了調(diào)試信息 。
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;
}

so_strip.png
寫在最后
是不是可以自己編譯成 jar,配合上線前的測(cè)試檢測(cè),分析 apk 的數(shù)據(jù)之后可以根據(jù)此結(jié)果再次的優(yōu)化包體積、統(tǒng)計(jì)每次發(fā)布包的大小、文件占比及連續(xù)二次發(fā)布包的比較等等呢
