Apk瘦身计划,从ApkChecker工具开撸


/   今日科技快讯   /

近日,甲骨文对外公告证实称,它已经与TikTok母公司字节跳动达成协议,成为其“可信技术提供商”,但该协议仍需美国政府批准。这意味着,TikTok美国业务不再出售。此前,美国财政部长姆努钦14日接受媒体采访时称,财政部在周末接到提案,甲骨文作为TikTok可信赖的数据安全合规合作伙伴,代表解决美国国家安全问题。

/   作者简介   /

本篇文章来自二手程序员的投稿,和大家分享了无用资源定位工具ApkChecker的相关内容,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

二手程序员的博客地址:

http://lyldalek.cn/

/   正文   /

分析无用资源,Android studio 本身就提供了lint工具。但是有个缺点就是无法配合proguard使用,无用代码引用的资源是不会被计算出来的。

ApkChecker里面提供了两个小工具,可以直接分析apk包里面的无用资源(res目录与asset目录)。

UnusedAssetsTask

我们先看简单一些的 UnusedAssetsTask 类,该类用于寻找出没有使用的 asset 资源。

public class UnusedAssetsTask extends ApkTask {...}

ApkChecker里面提供了十来个小工具,都是继承于 ApkTask,但是 ApkTask 里面啥都没有,只是做了输入参数处理,与提供一个统一处理核心逻辑的地方。

Task 的分析,主要是3部分:

  • 第一部分是构造函数,里面有个 type 比较重要,输出 task 分析的结果会用到,后面会有展示。

  • 第二部分是 init 函数,用于初始化一些变量。

  • 第三部分是 call 函数,里面是 task 的核心逻辑。

后面的 task 都会按照这个模板来分析。

构造函数

public class UnusedAssetsTask extends ApkTask {

    private static final String TAG = "Matrix.UnusedAssetsTask";

    public UnusedAssetsTask(JobConfig config, Map<String, String> params) {
        super(config, params);
        type = TaskFactory.TASK_TYPE_UNUSED_ASSETS;
        dexFileNameList = new ArrayList<>();
        ignoreSet = new HashSet<>();
        assetsPathSet = new HashSet<>();
        assetRefSet = new HashSet<>();
    }

JobConfig 是从命令行输入读取的配置信息解析而成的对象。ApkChecker 除了提供了十几个小工具用于分析与优化APK,由于它是一个命令行工具,所以还有一部分代码都是与读取参数有关,就比如我们平时使用的 ls -l 命令,会带些参数,还是一套比较完整的解析代码,而且对于结果输出,也有json与html两个格式,这也涉及到些设计模式,与重构还是代码大全的某个例子有点类似,有兴趣的可以研究研究。

init 函数

@Override
public void init() throws TaskInitException {
    super.init();

    String inputPath = config.getUnzipPath();
    inputFile = new File(inputPath);
    ...

    // 收集 dex 文件
    File[] files = inputFile.listFiles();
    if (files != null) {
        for (File file : files) {
            if (file.isFile() && file.getName().endsWith(ApkConstants.DEX_FILE_SUFFIX)) {
                dexFileNameList.add(file.getName());
            }
        }
    }
}

config.getUnzipPath是获取解压的路径,09 篇说了 UnzipTask 任务,它将需要分析的 apk 解压,这里的路径就是解压目录路径。

这个函数主要是获取所有 .dex 文件。

call 函数

@Override
public TaskResult call() throws TaskExecuteException {
    try {
        ...

        // 收集 asset 目录下文件
        findAssetsFile(assetDir);

        // 收集相对路径,里面去除了配置的忽略文件
        // 因为写忽略配置文件,肯定是相对 assets 目录的
        // --ignoreAssets
        //  assetsPathSet 里面存放的是 asset 目录下的所有文件
        generateAssetsSet(assetDir.getAbsolutePath());

        Log.i(TAG, "find all assets count: %d", assetsPathSet.size());
        decodeCode();
        // assetRefSet 里面存放的是 配置的忽略的资源 + smali 中引用到的资源
        Log.i(TAG, "find reference assets count: %d", assetRefSet.size());
        assetsPathSet.removeAll(assetRefSet);
        ...
    } catch (Exception e) {
        throw new TaskExecuteException(e.getMessage(), e);
    }
}

可以自己先想一下,如何才能找出 assets 目录下的未使用的资源?访问 asset 资源的方法:

webView.loadUrl("file:///android_asset/win8_Demo/index.html");
getAssets().open("wpics/0ZR424L-0.jpg");

可以看到,我们都是使用的字符串来表示 assets 目录下的某一特定资源。当 .dex 反编译为 .smail 代码的时候,对于常量字符串,都是以 const-string 开头的。

// const-string 的语法:
// const-string v0, "LOG"        # 将v0寄存器赋值为字符串常量"LOG"
// 这算是一种简单的分析方式

所以,以这种方式,我们可以得出一个大致正确的结果。所以思路就是:以读取文件的方式,遍历 smali 代码文件,找到所有使用常量字符串的地方。需要先反编译 .dex 文件:

private void decodeCode() throws IOException {
    for (String dexFileName : dexFileNameList) {
        // 使用 apktool 加载 dex 文件
        DexBackedDexFile dexFile = DexFileFactory.loadDexFile(new File(inputFile, dexFileName), Opcodes.forApi(15));

        BaksmaliOptions options = new BaksmaliOptions();
        // class 类按自然排序
        List<? extends ClassDef> classDefs = Ordering.natural().sortedCopy(dexFile.getClasses());

        for (ClassDef classDef : classDefs) {
            // 从 ClassDef 中获取该 class 对应的 smali 代码
            String[] lines = ApkUtil.disassembleClass(classDef, options);
            if (lines != null) {
                // 分析 smali 代码
                readSmaliLines(lines);
            }
        }

    }
}

使用的是 APKTOOL 的 api,就不介绍了,我也不熟。

private void readSmaliLines(String[] lines) {
    if (lines == null) {
        return;
    }
    for (String line : lines) {
        line = line.trim();
        // 找 常量字符串,因为使用 assets 中的资源文件,方式是很单一的,需要制定文件的名字
        // 所以遍历 smali 代码,找到所有使用常量字符串的地方
        // const-string 的语法:
        // const-string v0, "LOG"        # 将v0寄存器赋值为字符串常量"LOG"
        // 这算是一种简单的分析方式
        if (!Util.isNullOrNil(line) && line.startsWith("const-string")) {
            String[] columns = line.split(",");
            if (columns.length == 2) {
                // 获取 , 后面的
                String assetFileName = columns[1].trim();
                // 去除双引号
                assetFileName = assetFileName.substring(1, assetFileName.length() - 1);
                if (!Util.isNullOrNil(assetFileName)) {
                    // 遍历之前收集的所有资源文件的路径
                    for (String path : assetsPathSet) {
                        // 如果包含该文件
                        if (assetFileName.endsWith(path)) {
                            // 存放到 assetRefSet 里面
                            assetRefSet.add(path);
                        }
                    }
                }
            }
        }
    }
}

这里就是判断,const-string 的字符串里面有没有与 asset 目录下资源名一样的,有的话就算这个资源文件被使用了。当然,如果恰好你的字符串与资源名相同,那就会误判。

如何分析 asset 资源文件有没有被使用,ApkChecker 提供了一个简单的判断方式,虽然不完全准确,但是对我们应该也有启发。那么,可以思考一下,如何判断 libs 文件夹下的 jar 包有没有被引用?这个 ApkChecker 中并没有实现,如果有思路可以提pr。

下面再介绍一下,如何分析res中的资源有没有被引用?

UnusedResourcesTask

分析 res 下无用资源与 assets 下无用资源是相似的,但是需要弄清楚一件事:

当我们在 layout 等xml资源中使用了 values 文件中的资源,这个在代码中是无法找到的,所以需要理清思路,分析各个资源的引用关系。

思路

ApkChecker 里面提供的思路如下:

首先,代码里面引用的资源肯定需要标记为已使用。比如:R.layout.activity_main,其次,R.layout.activity_main 文件引用的资源,也需要标记为已使用。所以,我们需要想办法收集到 res 目录下资源的相互引用关系。

ApkChecker 将 res 下的资源分为两部分:

for (ResPackage pkg : resTable.listMainPackages()) {
    aXmlResourceParser.getAttrDecoder().setCurrentPackage(pkg);
    // layout
    // drawable
    // anim
    // menu
    // animator
    // color
    // 等等 .xml 文件
    for (ResResource resSource : pkg.listFiles()) {
        decodeResResource(resSource, resDir, aXmlResourceParser, nonValueReferences);
    }

    // 这里直接读取的 values 下的文件
    for (ResValuesFile valuesFile : pkg.listValuesFiles()) {
        Log.e("ResValuesFile", "path = " + valuesFile.getPath());
        decodeResValues(valuesFile, xmlPullParser, serializer, valueReferences);
    }
}

nonValueReferences 最后储存的是 非values 下资源文件的引用关系,比如,R.layout.activity_main 引用了哪些资源。

valueReferences 储存的是 values 下资源的引用关系,比如,R.string.app_namex 引用了哪些资源。

获取了 res 下资源的相互引用关系之后,就可以开始标记有用资源了。

private void readChildReference(String resource) throws IllegalStateException {
    if (nonValueReferences.containsKey(resource)) {
        visitPath.push(resource);
        // 获取该资源引用的其他资源
        Set<String> childReference = nonValueReferences.get(resource);
        // 资源有被引用则从 unusedResSet 里面移除
        // 验证一下,如果 a 引用 b,a没有用到,b会被发现吗?
        unusedResSet.removeAll(childReference);
        for (String reference : childReference) {
            if (!visitPath.contains(reference)) {
                readChildReference(reference);
            } else {
                visitPath.push(reference);
                throw new IllegalStateException("Found resource cycle! " + visitPath.toString());
            }
        }
        visitPath.pop();
    }
}

这是一个深度遍历。resource参数是代码中引用的资源 + valueReferences 集合中的元素。这里有个疑问,看如下代码:

// values 有 n 个文件夹 :values-pl values-v24 等等
// 判断里面的 item 有没有使用 @ 或者 attr 方式的,有就添加进来
for (String resource : valuesReferences) {
    if (resguardMap.containsKey(resource)) {
        resourceRefSet.add(resguardMap.get(resource));
    } else {
        resourceRefSet.add(resource);
    }
}

// resourceRefSet 现在储存的是 values 里面的引用的资源 + 代码中使用的资源
for (String resource : resourceRefSet) {
    readChildReference(resource);
}

resourceRefSet里面本来就有代码中引用的资源集合,这里是先添加了valuesReferences集合中的元素,然后才去做深度遍历。那么问题是,nonValueReferences根本就不会包含valuesReferences,在这之前添加有什么用处吗???

我们继续分析上面的深度遍历代码,假设resource是一个layout资源,按照上面的逻辑,该layout引用的所有string,dimen,drawable都会从unusedResSet集合中移除。这也就是说明它们被人引用过了,即判断它是有用资源。

这显然是一个有bug的逻辑,当一个无用资源A引用了无用资源B,那无用资源B岂不是被标记为有用,确实!!!

所以,有必要的话可以删除一批无用资源后,再次重新运行该工具,直到资源无变化。这里非 values 资源的逻辑是处理完了,那么 values 资源的逻辑呢,除了被 layout 等 xml 资源引用,代码中直接引用的情况有没有处理呢?

其实是有的,我们看 call 方法:

@Override
public TaskResult call() throws TaskExecuteException {
    try {
        ...
        unusedResSet.removeAll(resourceRefSet);
        ...
    } catch (Exception e) {
        throw new TaskExecuteException(e.getMessage(), e);
    }
}

在最后,它将 resourceRefSet 里面的所有资源都标记为引用了。从这里可以看出,resourceRefSet 里面的元素,都是根。

所以,总结一下:是以代码为根(不准确,values的引用也是根,不知道为啥要这么设计),然后深度遍历所有引用的资源。

smali 引用方式分析

我们接下来,看看,如何从代码中找到资源的引用。有四种情况:

1. const

const v6, 0x7f0c0061

2. sget

// app 生成的是 static int 的,所以直接转换为了数值
// 但是 lib 里面不是  final 的,所以会是引用的方式
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

第一种最简单,直接是以 R.layout.xxx 的方式。

第二种稍微绕一点,也是以R.layout.xxx的方式,但是不知在app中,而是在modules中,因为modules生成的资源不是final的(因为如果是final的,那资源id 就固定了,而app引用modules的时候,是需要一起计算 id 的,id 是按顺序的)。由于不是 final 的,所以就不是 const 语法,而是 sget 语法。

第三种,是 R.styleable,它的 R.java 是这样的:

public static final int[] SwitchCompat={
  0x01010124, 0x01010125, 0x01010142, 0x7f020101, 
  0x7f020107, 0x7f020112, 0x7f020113, 0x7f020115, 
  0x7f020123, 0x7f020124, 0x7f020125, 0x7f02013a, 
  0x7f02013b, 0x7f02013c
};

对应的 smali 就是 sput 了。

第四种,就是 array 了。

int[] arr = new int[]{
    R.layout.activity_main, R.string.app_name
};

注意3和4的区别。

以上,就是所有资源的引用分析了,具体代码就不贴了,无非就是如何根据 smali 语法分割出我们想要的资源id,然后从 build/intermediates/symbols/debug/R.txt 中,根据资源id找出对应的资源名(如果资源做了混淆,还要处理一下 resMapping)。

还有一个问题,就是使用 android.content.res.Resources#getIdentifier这种方式引用的资源是无法分析出来的。

我们看一下反编译的 smali 代码:

invoke-virtual {v0, v1, v2, v3}, Landroid/content/res/Resources;
->getIdentifier(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I

v1-v3是参数。

如果都是直接传递的字符串常量,那么ok,勉强是可以分析出来的,但是如果使用了局部变量,而且不是紧邻 getIdentifier 代码,那么可能连资源id都拿不到,就算拿到了资源id,也几乎分析不出来它的资源类型是啥。

而且,有时候还会使用动态拼接资源名的方式,这就更没法搞了。

所以,ApkChecker 里面是没有支持这种引用方式的,这个暂时无思路,可能的话,只能从侧面入手,比如写个插件,遇到 getIdentifier 就提醒一下,让开发者把资源添加到白名单里面去。

xml文件资源引用分析

这个 task 里面涉及到的东西还是比较多的,比如分析 .xml 文件的资源引用:

String value = mParser.getAttributeValue(i);
String attributeName = mParser.getAttributeName(i);
if (!Util.isNullOrNil(value)) {
    if (value.startsWith("@")) {
        int index = value.indexOf('/');
        if (index > 1) {
            String type = value.substring(1, index);
            resourceRefSet.add(ApkConstants.R_PREFIX + type + "." + value.substring(index + 1).replace('.', '_'));
        }
    } else if (value.startsWith("?")) {
        int index = value.indexOf('/');
        if (index > 1) {
            resourceRefSet.add(ApkConstants.R_ATTR_PREFIX + "." + value.substring(index + 1).replace('.', '_'));
        }
    }
}

我们在 xml 中引用资源,都会使用 @ 方式,而 xml 编译之后,@还是存在的,所以以 @ 为标识来添加资源引用关系。

这里有个疑问,不知道是不是 bug。我们引用属性,通常会使用 ?attr/xxx的方式,但是 xml 编译之后,会变成 ?xxx ,所以 else if 里面的 if 条件是进不去的。这里我没搞懂,else if 是干啥的。

如下:

<vector android:tint="?colorControlNormal" 

<size android:height="@dimen/abc_progress_bar_height_material"

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

原来在Android中请求权限也可以有这么棒的用户体验

使用MD风格,让你的项目更好看

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值