Matrix-APKChecker源码分析(2)

根据文档ApkChecker目前支持13种功能检测。

  • 读取 manifest 的信息
  • 按文件大小排序列出 apk 中包含的文件(2)
  • 统计方法数 (1)
  • 检查是否经过了资源混淆(AndResGuard)(2)
  • 搜索不含 alpha 通道的 png 文件(2)
  • 检查是否包含多个ABI版本的动态库(3)
  • 搜索未经压缩的文件类型(2)
  • 统计apk中包含的R类以及R类中的 field count(1)
  • 搜索冗余的文件(2)
  • 检查是否有多个动态库静态链接了 STL(3)
  • 搜索 apk 中包含的无用资源(2)
  • 搜索apk中包含的无用 assets 文件(2)
  • 搜索 apk 中未经裁剪的动态库文件 (3)

官方定义这个工具是【Apk 分析减包利器】,可以通过这些功能点看出,控制APK包大小的一些思路。基本可以分为几类:

  • 方法/属性/R文件等,可能引起multiDex的方向,主要从dex文件方面做约束——代码方面
  • 资源文件,是否存在可压缩的、重复冗余的、没使用的、可替换格式的文件——资源方面
  • so库相关,是否可以去掉用户量极少的abi库、是否可剪裁、是否可以优化掉STL链接等——so方面

在Matrix的github文档中,有专门说明ApkChecker的部分——Matrix Android ApkChecker。其中描述了各个配置项、执行流程、每个检查项的基本原理和具体的分析实例,比较全面。这里参考文档和代码,对其中一些关键点,做一些笔记。

代码方面

代码方面的检查项基本是从dex下手,检查方法数、属性数、R类等。我们知道apk就是一个zip包,使用解压工具解压apk文件,就可以看到dex文件,一般来说稍微大一点的应用就得是多个dex文件了。根据官方文档介绍:

  • MethodCountTask 可以统计出各个Dex中的方法数,并按照类名或者包名来分组输出结果。

实现方法:利用google开源的 com.android.dexdeps 类库来读取dex文件,统计方法数。

类似的,CountRTask任务也是一样的原理。看一部分MethodCountTask解析代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (int i = 0; i < dexFileList.size(); i++) {
RandomAccessFile dexFile = dexFileList.get(i);
DexData dexData = new DexData(dexFile);
dexData.load();
dexFile.close();
ClassRef[] defClassRefs = dexData.getInternalReferences();
Set<String> classNameSet = new HashSet<>();
for (ClassRef classRef : defClassRefs) {
String className = ApkUtil.getNormalClassName(classRef.getName());
if (classProguardMap.containsKey(className)) {
className = classProguardMap.get(className);
}
if (className.indexOf('.') == -1) {
continue;
}
classNameSet.add(className);
}
//...
}
  • dexFileList 应该是在解压任务之后,将所有dex文件放入了该列表,具体可以参考MethodCountTask的init方法
  • 针对每个dex文件,使用DexData类加载/解析文件,直接可以取出其中的类信息来做统计

可以找到DexData及相关的工具源码被放在了commons module中,看来是直接把源码拉到了工程中。具体看这几个类里面的源码,基本上是按照dex规则、解析出其中的包/类/方法/属性等各级信息的功能实现。以后有涉及到解析dex相关需求的,可以使用该工具。走读一下CountRTask规则的代码,跟MethodCountTask的套路几无不同。

资源方面

资源方面是个大头,大部分检查项都是在从各种角度分析是不是可以减少各种资源的大小。比较常见的是UnusedResourceTask,无用资源检查。官方文档已经把流程写得很详细了。

实现方法: (1)过读取R.txt获取apk中声明的所有资源得到declareResourceSet; (2)通过读取smali文件中引用资源的指令(包括通过reference和直接通过资源id引用资源)得出class中引用的资源classRefResourceSet; (3)通过ApkTool解析res目录下的xml文件、AndroidManifest.xml 以及 resource.arsc 得出资源之间的引用关系; (4)根据上述几步得到的中间数据即可确定出apk中未使用到的资源。

可以这简述一下:

  • 找到所有资源
  • 找到代码里使用的资源
  • 找到xml文件里使用的资源
  • 所有资源去掉使用的资源,就是我们要的结果了

核心代码流程很清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public TaskResult call() throws TaskExecuteException {
try {
//...
readMappingTxtFile();
readResourceTxtFile();
unusedResSet.addAll(resourceDefMap.values());
Log.i(TAG, "find resource declarations %d items.", unusedResSet.size());
decodeCode();
Log.i(TAG, "find resource references in classes: %d items.", resourceRefSet.size());
decodeResources();
Log.i(TAG, "find resource references %d items.", resourceRefSet.size());
unusedResSet.removeAll(resourceRefSet);
Log.i(TAG, "find unused references %d items", unusedResSet.size());
Log.d(TAG, "find unused references %s", unusedResSet.toString());
//...
} catch (Exception e) {
throw new TaskExecuteException(e.getMessage(), e);
}
}
  • 读取mapping文件,这里主要采集混淆中的R文件的映射关系,为后面做smali分析做准备
  • 读取R.txt文件,主要是采集R文件中所有属性的值,同样是为后面smali分析做准备
  • 把所有资源名称和ID缓存到unusedResSet
  • 解析smali代码,更新resourceRefSet缓存
  • 解析资源文件,更新resourceRefSet缓存
  • unusedResSet中移除掉resourceRefSet,剩下的就是真正的unusedResSet了

这里涉及几个文件的解析和apktool反编译工具的使用。

mapping.txt

mapping文件是混淆之后ProGuard提供的源码和混淆后类/方法/字段等映射关系,一般情况下在上架包中采用混淆,除了一定程度上保证代码能有点反破解能力之外,还有压缩代码和资源的作用。该文件一般坐落于【工程目录/app目录/build/outputs/mapping/Variants目录】下,一般情况长这样:

1
2
3
4
5
6
7
8
9
10
11
12
46:46:android.os.IBinder onBind(android.content.Intent) -> onBind
android.support.customtabs.PostMessageService$1 -> android.support.customtabs.PostMessageService$1:
android.support.customtabs.PostMessageService this$0 -> this$0
29:29:void <init>(android.support.customtabs.PostMessageService) -> <init>
34:35:void onMessageChannelReady(android.support.customtabs.ICustomTabsCallback,android.os.Bundle) -> onMessageChannelReady
40:41:void onPostMessage(android.support.customtabs.ICustomTabsCallback,java.lang.String,android.os.Bundle) -> onPostMessage
android.support.customtabs.PostMessageServiceConnection -> android.support.customtabs.PostMessageServiceConnection:
java.lang.Object mLock -> mLock
android.support.customtabs.R$bool -> android.support.customtabs.R$bool:
int abc_action_bar_embed_tabs -> abc_action_bar_embed_tabs
21:21:void <init>() -> <init>
android.support.customtabs.R$color -> android.support.customtabs.R$color:

从代码逻辑上看,似乎静态类的映射是顶格写的,其他属性和类的映射在行首会有一些空格或者是一个tab。没有查到mapping文件在哪里有官方的格式定义,所以这个规则会不会一直适用也未可知。另外一个规则是”->”符号,表示混淆前后的映射关系,解析完前后的类路径之后,通过工具类ApkUtil中的getPureClassName、isRClassName方法来判断当前行是否是R类映射。

1
2
3
4
5
6
7
/*
* determine if the class if R class
*/
public static boolean isRClassName(String className) {
Pattern pattern = Pattern.compile("^R\\$\\w+");
return pattern.matcher(className).matches();
}

R.txt

可以说是见名知意了,应该是app在便衣过程中,合并掉所有module之后生成的一个R文件内容的映射文件。具体位置大致在【工程目录/app目录/build/intermediates/symbols/Variants目录】下。一般情况下长这样:

1
2
3
4
5
int anim abc_fade_in 0x7f050000
int anim abc_fade_out 0x7f050001
int anim abc_grow_fade_in_from_bottom 0x7f050002
int anim abc_popup_enter 0x7f050003
int anim abc_popup_exit 0x7f050004

可以有针对性的对比下对应的分析部分代码,主要解析功能实现在readResourceTxtFile函数中。

smali

拿到所有资源相关的ID信息后,该分析代码中用到了哪些资源了。第一步是分析代码,具体下来是使用apktool工具解析dex文件为smali格式(decodeCode方法),然后依次分析每行代码(readSmaliLines方法)。

  • decodeCode

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    private void decodeCode() throws IOException {
    for (String dexFileName : dexFileNameList) {
    DexBackedDexFile dexFile = DexFileFactory.loadDexFile(new File(inputFile, dexFileName), Opcodes.forApi(15));
    BaksmaliOptions options = new BaksmaliOptions();
    List<? extends ClassDef> classDefs = Ordering.natural().sortedCopy(dexFile.getClasses());
    for (ClassDef classDef : classDefs) {
    String[] lines = ApkUtil.disassembleClass(classDef, options);
    if (lines != null) {
    readSmaliLines(lines);
    }
    }
    }
    }
    • DexBackedDexFile、BaksmaliOptions、ClassDef都是ApkTool中的相关工具方法,具体实现可以参考源码
    • 经过apktool解析,获取ClassDef的拷贝排序列表,通过工具类ApkUtil的disassembleClass过滤一些不需要处理的类,同时读取处需要处理的smali类的每行数据
  • readSmaliLines

    解析smali这个函数就不贴代码了,这个函数注释很不错,说明了这里的基本原则:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /*
    1. const
    const v6, 0x7f0c0061
    2. sget
    sget v6, Lcom/tencent/mm/R$string;->chatting_long_click_menu_revoke_msg:I
    sget v1, Lcom/tencent/mm/libmmui/R$id;->property_anim:I
    3. sput
    sput-object v0, Lcom/tencent/mm/plugin_welab_api/R$styleable;->ActionBar:[I //define resource in R.java
    4. array-data
    :array_0
    .array-data 4
    0x7f0a0022
    0x7f0a0023
    .end array-data
    */
    • 常量const/array-data,这个主要用来对比R文件中的值,是否有被定义到这里的,如果有,那么说明对应的资源应该是被使用的
    • sget/sput,操作指令,具体可以参考下Android开发者官网对字节码格式的说明。按照规则解析出第二个参数,如果符合R文件资源的规律,可以认为该资源也是被使用的

resources

该任务对资源文件的分析代码在decodeResources函数中,处理了manifest文件、resources.arsc文件和资源文件目录res。主要的文件解析逻辑,在工具类ApkResourceDecoder的decodeResourcesRef中。流程主要如下:

  • 校验输入文件的合法性

  • 解析resources.arsc文件

    • 解析ResTable,decodeArscFile函数
    • 遍历resTable.listMainPackages,初始化xml解析工具AXmlResourceParser、XmlPullParser
    • 针对每一个ResPackage,获取所有件引用和有使用的引用
  • 解析manifest文件

    • 使用XmlPullResourceRefDecoder工具,解析出manifest文件中的关联引用,放入有使用的资源set中

完成解析后,获得一个所有的资源引用set和有使用的资源set。再配合参数设定的【ignore】、子引用的分析等继续处理资源的分析。

这部分的处理主要还是依靠apktool的解析能力,很多数据结构和工具类的使用都需要有对这个工具具有一定的掌控能力。目前可以大致看到这些实现方式,如果需要具体研究,还得深入到apktool的源码和apk打包流程各个环节的文件格式等来具体分析。网络上有一些文章可以参考理解,如ApkTool项目解析resources.arsc详解

资源类其他规则如是否有可压缩的文件、是否存在不含 alpha 通道的 png 文件、是否有使用混淆工具等,都是挺有意思的规则实现。这里不进行逐一分析了,具体可以参考各自的task源代码学习。

so方面

关于so相关对apk大小的优化,网上有一些文章可以参考,比如Android ndk之so体积缩减。这里的三条规则,其中一个本质上是建议只保留一个abi的支持,这个的实现想起来都不比较容易。另外两个,一个是是否存在可剪裁的so库;另一个是是否有多个so库静态链接了 STL。通过源码可以知道,是通过java调用命令行,使用ndk工具链实现的。以下是两段关键代码:

  • 可剪裁判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

以下是文档原理解释:

  • UnStrippedSoCheckTask** 可以检测出apk中未经裁剪的动态库文件

实现方法:使用nm工具读取动态库文件的符号表,若输出结果中包含no symbols字样则表示该动态库已经过裁剪

  • STL判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

以下是文档原理解释:

  • CheckMultiSTLTask 可以检测apk中的so是否静态链接STL

实现方法:通过nm工具来读取so的符号表,如果出现 std:: 即表示so静态链接了STL。

通过工具命令行的输出的一些特征,分析so的状态结果。这里需要有一定的ndk开发经验来支撑判断规则的实现。可以参考官网的一些说明C++ 库支持

小结

关于静态分析工具,整个代码走读下来,有一些简单的思考。

  • 要对apk打包的各个环节的流程、各个部分的依赖关系和组合形式都了解的比较透彻
  • 统计所有可以优化的点,以及各个点工具化的实现方式
  • 知识面要足够广,在每个检查点的实现上才不会一筹莫展或者重复造轮子
  • 检查项要可量化,有输出
感谢您赏个荷包蛋~