16.优化 - apk 分析

  在 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 的结果。

流程原理如下:

apk-process.png

matrix-apk-canary 的整体工作流程如上所示。

    1. UnzipTask
      首先 apk 首先会经过 UnzipTask 处理,解压到--output目录,还会在这里读取 mapping.txt (反混淆类名)、读取 resMapping.txt (反混淆资源)、统计文件数量及大小等。并将这些数据写入到JobConfig这个类中,之后的 task 只需要读取这个JobConfig这个类中的数据就可以了。
    1. ManifestAnalyzeTask
      ManifestAnalyzeTask 用于读取 AndroidManifest.xml中的信息,如:packageName、verisonCode、clientVersion 等。
      利用 ApkTool 中的 ApkResourceDecoder.createAXmlParser() 来解析二进制的 AndroidManifest.xml 和 arscFile 文件,不断读取 xml 的数据,遇到 handleStartElement( "<" 进,记录 xml) 与 handleEndElement( ">" 出,丢弃记录的 xml 数据) ,最终只会记录packageName、verisonCode、clientVersion等数据。
      manifest.png
    1. ShowFileSizeTask
      根据apk-checker-config.json文件中的规则,如"--min":"10", "--order":"desc","--suffix":"png, jpg, jpeg, gif, arsc",过滤超过最小文件大小以及文件后缀名文件,并按照升序或降序排列结果。
      利用 UnzipTask 中统计的文件的entryList来作为输入,根据上面的规则来输出结果。
            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,...);
file_size.png
  • 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);
            }
method_size.png
    1. ResProguardCheckTask
      判断 apk 是否经过了资源混淆
      资源混淆之后的 res 文件夹会重命名成 r ,直接判断是否存在文件夹 r 即可判断是否经过了资源混淆。
            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
    检测出 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()));
                    }
                }
            }
        }
    }
png_alpha.png

-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);
        }
    }
mutil_so.png
  • 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);
        }
    }
R.png
  • 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);
                }
            }
        }
    }
md5.png
  • 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;
    }
stl.png
  • 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);
        }
    }
unuse_asserts.png
  • 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;
    }
so_strip.png

写在最后
是不是可以自己编译成 jar,配合上线前的测试检测,分析 apk 的数据之后可以根据此结果再次的优化包体积、统计每次发布包的大小、文件占比及连续二次发布包的比较等等呢

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容