首页 Android 正文
  • 本文约5240字,阅读需26分钟
  • 47
  • 0

Android 包体的清道夫

摘要

一. 背景 随着业务需求不断地进行迭代,同时功能也在不断地变更,随之而来的就是无用功能没有被及时移除,从而造成安装包体积不断变大。 同时,我们所依赖的三方库,无论是内部库还是外部库,也会或多或少存在没有使用的代码和资源。依赖库越多,无用代码和资源也会越多。 二.安装包产物构成 Android 安装包的产物构成,主要是 Dex、Resource、Assets、...

一. 背景

随着业务需求不断地进行迭代,同时功能也在不断地变更,随之而来的就是无用功能没有被及时移除,从而造成安装包体积不断变大。

同时,我们所依赖的三方库,无论是内部库还是外部库,也会或多或少存在没有使用的代码和资源。依赖库越多,无用代码和资源也会越多。

二.安装包产物构成

Android 包体的清道夫

Android 安装包的产物构成,主要是 Dex、Resource、Assets、Native Library。这次的无用代码和资源扫描,主要针对于 DexResource

三. 原理解析

1.概述

利用 ApkTool 对 Apk 里面的 dex、xml、resource.arsc 进行反编译,扫描并获取完整的类路径和完整的资源索引,再根据类与类、类与资源、资源与资源之间的依赖关系,构建有向依赖关系图。

Android 四大组件 和 Application 会在 AndroidManifest.xml 中进行注册和声明,即使没有注册声明的四大组件,也会与其他类相关联。因此 AndroidManifest.xml 作为 顶点。然后每个类,每个资源从自己开始,自下而上,做一次深度优先遍历。如果能顺着依赖关系图能遍历到该 顶点,则判定该类或资源为有用的。相反,则无用。

当然了,AndroidManifest.xml 是必需的 顶点,但不是唯一的 顶点。可以根据项目的具体情况,增加其它 顶点(也可以叫做白名单)

PS:以下的分析,暂且是以 AndroidManifest.xml 为唯一 顶点 去分析的。

Android 包体的清道夫

Android 包体的清道夫

2. Dex 转换成 Smali

将 apk 中的所有 dex 文件,利用 Apktool 将它们转换成 smali 文件,获取每一个 class(类),然后逐行读取该 class。使用正则表达式,匹配到每个类,每个资源。

在 smali 中,类名的表现形式是: Lcom/shey/dexresourcecleaner/MainActivity;

资源的表现形式有两种:1、索引值:0x7f000001; 2、R常量(最终也是索引值):R.drawable.ic_test

匹配类名和资源时,一旦匹配上,就将 该类资源 当做一个节点,并指向当前 class(当前 class 也是一个节点)。

Android 包体的清道夫

如果当前 class 是 R类 时,需要对里面的资源索引常量值,进行拆分,为什么呢?

R类 里面全部都是资源索引值,那么这些索引值都会指向 R类

public final class R {

    public static final class drawable {
        public static final int test1 = 0x7f000001;
        public static final int test2 = 0x7f000002;
        public static final int test3 = 0x7f000003;
        public static final int test4 = 0x7f000004;
    }
}
Java

Android 包体的清道夫

一旦 R类 指向了 Activity,那么里面所有资源都会被当做有用的资源。因此,需要对 R类 进行拆分,怎么拆分呢?把 R类 的每一个常量拆分成一个节点,如图:

Android 包体的清道夫

如果你的项目中做了 R 内联,而且 R 文件全部被删除了,可以忽略上述 R 文件的处理。

动态加载类,比如 Class.forNameBaseDexClassLoader.loadClass 这两种方式可以通过类的路径去实现动态加载该类。动态加载类,同时也分为 静态动态 的。动态 的只有实际运行时才能知道具体的值。但是 静态 的也可以在 smali 文件的 class 中,通过扫描常量字符串,去判断该字符串是不是类的路径。并将该类也当做一个类节点。

kotlin:

class MainActivity: AppCompatActivity(){
    override fun onCreate(savedInstanceState:Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        TestAnnotation("com.shey.dexresourcecleaner.TestReflect").test()
        Log.d(TestAnnotation.TAG,"End")
    }
}
Kotlin

smali:

.method protected onCreate(Landroid/os/Bundle;)V
    .registers 3

    .line 9
    invoke-super{p0, p1},Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V

    const p1,0x7f0a001c

    .line 10
    invoke-virtual{p0, p1},Lcom/shey/dexresourcecleaner/MainActivity;->setContentView(I)V

    .line 11
    new-instance p1,Lcom/shey/dexresourcecleaner/TestAnnotation;

    const-string v0,"com.shey.dexresourcecleaner.TestReflect"

    invoke-direct {p1, v0},Lcom/shey/dexresourcecleaner/TestAnnotation;-><init>(Ljava/lang/String;)V

    invoke-virtual{p1},Lcom/shey/dexresourcecleaner/TestAnnotation;->test()V

    const-string p1,"TestAnnotation"

    const-string v0,"End"

    .line 12
    invoke-static{p1, v0},Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I

    return-void
.end method
Smali

上述 smali 代码中:const-string v0, "com.shey.dexresourcecleaner.TestReflect",我们会把 com.shey.dexresourcecleaner.TestReflect 当做一个类节点,并指向当前 class 类。

3. 读取 resource.arsc

如果 apk 存在资源文件或者资源值,那 apk 解压后都会有一个 resource.asrc 文件,就是一个资源索引表。大家对 R 文件应该很熟悉,R 文件里都是静态常量值,值都是资源索引值。而 arsc 文件就是帮助我们根据索引值去寻找资源文件,或者资源具体值。

使用 apktool 解析 arsc 时,我们可以得到 ResPackage,一个包名对应一个 ResPackage,所以一般来说,基本都是一个 apk 只有一个 ResPackage

public class ResPackage {
    private final ResTable mResTable;
    private final int mId;
    private final String mName;
    private final Map<ResID,ResResSpec> mResSpecs = new LinkedHashMap<>();
    private final Map<ResConfigFlags,ResType> mConfigs = new LinkedHashMap<>();
    private final Map<String,ResTypeSpec> mTypes = new LinkedHashMap<>();
    private final Set<ResID> mSynthesizedRes = new HashSet<>();
}
Java

我们需要的是 ResResSpec

public class ResResSpec {
    private final ResID mId;
    private final String mName;
    private final int mFlags;
    private final ResPackage mPackage;
    private final ResTypeSpec mType;
    private final Map<ResConfigFlags,ResResource> mResources = new LinkedHashMap<>();
}
Java

ResResSpec 在 ResPackage 里面是属于私有字段,并且我们只需要将一个索引值映射一个 ResResSpec 即可,不需要多余信息。

Android 包体的清道夫

4. 反编译 xml 二进制文件

apk 中的 布局、动画等 xml 以及 AndroidManifest.xml 都是二进制文件,要想读取里面的类,以及资源索引值,需要对其进行反编译,解析成具有可读性的字符串文件。

根据上面读取 arsc 所获得的 ResResSpec,再遍历其中的 ResResource,如果是文件,且是 xml 文件,就反编译该 xml 文件。

开发过程中的 xml 文件

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayoutxmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <ImageView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@mipmap/ic_test_in"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <com.shey.dexresourcecleaner.view.InView
        android:layout_width="100dp"
        android:layout_height="100dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
XML

反编译后的 xml 文件

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="-1"
    android:layout_height="-1">

    <TextView
        android:layout_width="-2"
        android:layout_height="-2"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="0"
        app:layout_constraintLeft_toLeftOf="0"
        app:layout_constraintRight_toRightOf="0"
        app:layout_constraintTop_toTopOf="0"/>

    <ImageView
        android:layout_width="dimension(25601)"
        android:layout_height="dimension(25601)"
        android:src="@ref/0x7f0b0002"
        app:layout_constraintBottom_toBottomOf="0"
        app:layout_constraintLeft_toLeftOf="0"
        app:layout_constraintRight_toRightOf="0"
        app:layout_constraintTop_toTopOf="0"/>

    <com.shey.dexresourcecleaner.view.InView
        android:layout_width="dimension(25601)"
        android:layout_height="dimension(25601)"
        app:layout_constraintBottom_toBottomOf="0"
        app:layout_constraintLeft_toLeftOf="0"
        app:layout_constraintRight_toRightOf="0"
        app:layout_constraintTop_toTopOf="0"/>
</androidx.constraintlayout.widget.ConstraintLayout>
XML

如果在布局 xml 中引用了其它的资源,它其实也是以索引值的形式存在。就这样读取每个 Element,如果是索引值,就构造一个节点,并指向该 xml 文件;同理,如果是包名+类型,也一样指向该 xml 文件。

Android 包体的清道夫

5.遍历依赖图

分析完 dex 和 xml 后,已经能差不多构建完整的有向关系依赖图了。但是上述 dex 转 smali 时获取到的常量字符串类名、资源索引值,xml 中获取到的类名、资源索引值,需要进行有效性过滤。类名是否在 dex 中,资源索引值是否在 arsc 中。

Android 包体的清道夫

在总的有向关系依赖图中,每个节点,自下而上,都往上做一次深度优先遍历,若遍历到 AndroidManifest.xml ,即该节点有用。相反,则无用。

四. 编译时删除

在分析完无用代码和资源后,会生成一份无用列表清单,分别是无用class,无用资源文件。那么在项目编译构建时,就需要进行删除,以达到减少包体的目的。

我们通过上面的过程,可以知道无用的 class 和 资源文件,不管是外部的依赖库,还是内部的依赖库。即便是 androidx,一旦发现存在无用的 class,也会进行该 class 的删除。

1.删除资源文件

Android 编译构建过程,都是一个个 Task 在执行,合并处理资源的任务是 processReleaseResource、processDebugResource。该任务的产物是 .ap_ 文件,其实它也就是一个 压缩文件,资源相关的文件全在里面,例如 xml、png、jpg、webp 等等。

process${变体}Resource 任务执行完毕后,将 .ap_ 文件解压,根据无用资源列表清单,对里面的文件做空文件替换,并重新压缩生成一个 .ap_ 文件。

Android 包体的清道夫

2.删除 class

删除 class 在 gradle Transform 中进行删除,需要注意的是,输入文件有 jar、有 class 文件,需要分开处理。如果是 jar,类似上述处理 .ap_ 文件一样,解压后删除其中的 class,然后重新生成替换。而如果是 class 文件,删除即可。

@Override
void transform(TransformInvocation transformInvocation)throws TransformException,InterruptedException,IOException{
    TransformOutputProvider outputProvider = transformInvocation.outputProvider
    if (!transformInvocation.isIncremental()) {
        outputProvider.deleteAll()
    }
    transformInvocation.inputs.forEach {TransformInput input ->
        //输入是 jar 文件
        input.jarInputs.forEach {JarInput jarInput ->
            ...
        }

        //输入是 class 文件
        input.directoryInputs.forEach {DirectoryInput directoryInput ->
            ...
        }
    }
}
Java

五. 注意事项

无用代码资源的扫描,都是基于静态代码分析的,建立 类 与 索引值 之间的有向关系依赖图。但是 java 实际开发中,是存在通过类名去动态加载类的,比如: Class.forName()BaseDexClassLoader.loadClass()ServiceLoader.load(),同时,资源也是可以动态加载。native(即 so 库)也可以动态加载类。

类名同时也分为两种形式,一种是静态的,另一个种是动态的(比如 变量拼接)。静态的话,上面对 dex 的分析时,已经会自动处理类名的常量字符串。如果是动态的,就需要实际运行中才能知道其中的值了。

正因为类的动态加载机制,使得该扫描工具存在一定的风险。动态加载类的地方需要完全排除,才能完全避免风险。其实,也是类似于混淆场景,需要维护一份混淆规则列表。依赖库本身也是知道哪些类是不能混淆,并且是动态加载的,从而将这些类加入白名单。白名单上的类或资源,也将成为跟 AndroidManifest.xml 一样的 顶点

六. 优化效果

应用的时候,包体优化了 13.5%。并与构建系统进行打通,实现了自动扫描和删除的处理。

标签:包体优化

扫描二维码,在手机上阅读
评论