文章

包体积优化-Dex优化

包体积优化-Dex优化

包体积优化 - Dex 优化

对于大部分应用来说,Dex 都是包体积中的大头,而且 Dex 的数量对用户安装时间也是一个非常大的挑战

代码层面

一个功能尽量用一个库

比如加载图片库,不要 glide 和 fresco 混用,因为功能是类似的,只是使用的方法不一样,用了多个库来做类似的事情,代码肯定就变多了。

无用功能代码清理

  • 废弃功能
  • AB Test 实验功能保留一个

功能 H5 化

Dex 优化

Proguard

  • 代码混淆 使用 Proguard 工具进行了混淆,它将程序代码转换为功能相同,但是不容易理解的形式。比如说将一个很长的类转换为字母 a
  • 更安全了

Proguard 规则收敛

” 十个 ProGuard 配置九个坑 “,特别是各种第三方 SDK。我们需要仔细检查最终合并的 ProGuard 配置文件,是不是存在过度 keep 的现象。

你可以通过下面的方法输出 ProGuard 的最终配置,尤其需要注意各种的 keep *,很多情况下我们只需要 keep 其中的某个包、某个方法,或者是类名就可以了。

1
-printconfiguration  configuration.txt
四大组件和 View

一般来说,应用都会 keep 住四大组件以及 View 的部分方法,这样是为了在代码以及 XML 布局中可以引用到它们。

1
2
3
4
5
6
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.view.View

事实上,我们完全可以把 非 exported 的四大组件 以及 View 混淆,但是需要完成下面几个工作:

  • XML 替换。在代码混淆之后,需要同时修改 AndroidManifest 以及资源 XML 中引用的名称
  • 代码替换。需要遍历其他已经混淆好的代码,将变量或者方法体中定义的字符串也同时修改。需要注意的是,代码中不能出现经过运算得到的类名,这种情况会导致替换失败。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 情况一:变量
public String activityName = "com.sample.TestActivity";
// 情况二:方法体
startActivity(new Intent(this, "com.sample.TestActivity"));
// 情况三:通过运算得到,不支持
startActivity(new Intent(this, "com.sample" + ".TestActivity"));
// 情况四:在ActivityLifecycleCallbacks判断Activity的名字;FragmentLifecycleCallbacks也类似
open class SimpleActivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks {  
    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {  
        if (activity.javaClass.simpleName=="MainTabActivity") {  
            // 首页。。。  
        }  
    }
}

代码替换的方法,我推荐使用 ASM。饿了么曾经开源过一个可以实现四大组件和 View 混淆的组件 Mess,不过似乎已经没在维护了,仅供你参考。

Android Studio 3.0 推出了新 Dex 编译器 D8 与新混淆工具 R8,目前 D8 已经正式 Release,大约可以减少 3% 的 Dex 体积。AGP3.4 R8 也取代了 Proguard

Dex 行号优化

Proguard 不保留行号

Dex 中的 debug 区域占 5~10% 的大小,但其最大的作用是分析崩溃堆栈时定位。该区域可以通过去除 ProGuard 规则

1
2
# Proguard中keep住源文件及行号
-keepattributes SourceFile,LineNumberTable
Dex debug item 优化并保留行号

百度 如:百度选择在指令级别完成 debug infos 的映射与复用,同时联动百度性能平台 (目前仅供公司内部使用,功能可类比腾讯 bugly) 完成崩溃堆栈的还原,既优化了体积,又不会影响堆栈的分析。

支付宝支付宝 App 构建优化解析:Android 包大小极致压缩

通过这个方法,我们可以实现既保留行号,但是又可以减少大约 5% 的 Dex 体积。

  • 方案一(只需要考虑处理 crash 的上报)
    • 利用 proguard 来删除 debugItem (去掉 -keep lineNumberTable),在删除行号表之前 dump 出一个临时的 dex
    • 修改 dexdump:把临时的 dex 中的行号表关系 dump 成一个 dexpcmapping 文件 (指令集行号和源文件行号映射关系),并存至服务端
    • hook app runtime 的 crash handler,把 crash 时的指令集行号上报到反解平台
    • 反解平台通过上报指令集行号和提前准备好 dexpcmapping 文件反解出正确的行号
  • 方案二(考虑性能)
    • 尝试直接修改 dex 文件,保留一小块 debugItem,让系统查找行号的时候指令集行号和源文件行号保持一致,这样就什么都不用做,任何监控上报的行号都直接变成了指令集行号,只需修改 dex 文件
    • 不用改 proguard,也不用 hook native

注解优化

Dex 中注解分为三种类型:Build、Runtime、System。

  • Build 和 Runtime 对应 ProGuard 规则 -keepattributes *Annotation*
  • 可优化的 System 注解根据具体类型分别对应 -keepattributes InnerClasses, Signature, EnclosingMethod。跟行号一样,可以通过去除这些规则完成一刀切的优化。但由于我们接入的三方组件自带这些 ProGuard 规则,且部分类的 System 注解有保留的需要,我们选择后置地处理 Dex 文件,基于 Dex 字节码工具完成目标注解的移除。

assumenosideeffects 移除 log 代码

见: [[Proguard#assumenosideeffects 移除 log 代码]]

google 相关库 proguard-rules 优化

通过对工程中现有 keep 规则进行优化,以达到包体积优化的效果。

目前分析混淆规则中 -keep class com.google.** { *; },keep 的范围过大

在 si App,删除以上规则后包体积收益 1MB+

SDK混淆规则备注
com.android.installreferrer:installreferrer-keep public class com. Android. Installreferrer.** { *; }Google Play Store 获取安装 App 引荐来源相关信息 SDK
com.google.firebase:firebase-perf-keep class com.google.firebase.** { *; }Firebase 性能监控
com.google.firebase:firebase-crashlytics-keep class com.google.firebase.** { *; }崩溃
com.google.firebase:firebase-messaging-keep class com.google.firebase.** { *; }FCM
com.google.android.flexbox:flexboxThe FlexboxLayoutManager may be set from a layout xml, in that situation the RecyclerView
Tries to instantiate the layout manager using reflection.
This is to prevent the layout manager from being obfuscated.
-keepnames public class com. Google. Android. Flexbox. FlexboxLayoutManager
FlexBox 布局组件
com.google.android.play:core\Google Play Store App 发布,App 更新,App 下载安装、动态下发
com.google.zxing` -keep class com.google.zxing.** {*;}
-dontwarn com.google.zxing.** `
二维码
com.google.code.gson:gson-keepclassmembers class com.google.gson.**
-keepclassmembers class com. Google. Gson.** { public private protected *; }
-keep @interface com. Google. Gson. Annotations. SerializedName
-keepclassmembers class * { <br>    @com. Google. Gson. Annotations. SerializedName <fields>; <br>}
Gson
com.google.guava:guava UsingProGuardWithGuava · google/guava Wiki (github.com) 
com.google.auto: auto-common\Google 编译时代码生成辅助工具库

启用 R8 替代 Proguard

AGP 3.4 及以上,默认由 R8 替代 Proguard 工具。

100M+ 的,有个 2M 优化空间

见 [[R8和D8]]

R 文件

R 文件内联

通过把 R 文件里面的资源内联到代码中,从而减少 R 文件的大小

  • AGP3.6 支持 R 文件的内联了

R 文件产生

在 Android 编译打包的过程中,位于 res/目录下的文件,就会通过 aapt 工具,对里面的资源进行编译压缩,从而生成相应的资源 id,且生成 R.java 文件,用于保存当前的资源信息,同时生成 resource.arsc 文件,建立 id 与其对应资源的值

R 文件结构

tr0zw
R.java 内部包含了很多内部类:如 layout、mipmap、drawable、string、id 等等,这些内部类里面只有 2 种数据类型的字段:

  • public static final int
  • public static final int[]

只有 styleable 最为特殊,只有它里面有 public static final int[] 类型的字段定义,其它都只有 int 类型的字段。

R 资源 id 表示

资源 id 用一个 16 进制的 int 数值表示。比如 0x7f010000,我们来解释一下具体含义

  1. 第一个字节 7f:代表着这个资源属于本应用 apk 的资源,相应的以 01 代表开头的话(比如 0x01010000)就代表这是一个与应用无关的系统资源。0x7f010000,表明 abc_fade_in 属于我们应用的一个资源
  2. 第二个字节 01: 是指资源的类型,比如 01 就代表着这个资源属于 anim 类型
  3. 第三,四个字节 0000: 指资源的编号,在所属资源类型中,一般从 0000 开始递增

R 文件冗余

Android 从 ADT 14 开始为了解决多个 library 中 R 文件中 id 冲突,所以将 Library 中的 R 的改成 static 的 非常量 属性。不能用 switch-case。
在 apk 打包的过程中,module 中的 R 文件采用对依赖库的 R 进行累计叠加的方式生成。如果我们的 app 架构如下:
fz5no
编译打包时每个模块生成的 R 文件如下:

  • R_lib1 = R_lib1;
  • R_lib2 = R_lib2;
  • R_lib3 = R_lib3;
  • R_biz1 = R_lib1 + R_lib2 + R_lib3 + R_biz1(biz1 本身的 R)
  • R_biz2 = R_lib2 + R_lib3 + R_biz2(biz2 本身的 R)
  • R_app = R_lib1 + R_lib2 + R_lib3 + R_biz1 + R_biz2 + R_app(app 本身 R)

在最终打成 apk 时,除了 R_app(因为 app 中的 R 是常量,在 javac 阶段 R 引用就会被替换成常量,所以打 release 混淆时,app 中的 R 文件会被 shrink 掉),其余 module 的 R 文件全部都会打进 apk 包中。这就是 apk 中 R 文件冗余的由来。而且如果项目依赖层次越多,上层的业务组件越多,将会导致 apk 中的 R 文件将急剧的膨胀。

javac 本身会对 static final 的基本类型做内联,也就是把代码引用的地方全部替换成常量;可以少了一次内存寻址,还可以删除内联后的 R 文件

module R 不是常量的原因
避免了不同 module 之间资源名相同时导致的资源冲突

在 Android 中,我们每个资源 id 都是唯一的,因此我们在打包的时候需要保证不会出现重复 id 的资源。如果我们在 library module 就已经指定了资源 id,那我们就和容易和其他 library module 出现资源 id 的冲突。因此 AGP 提供了一种方案,在 library module 编译时,使用资源 id 的地方仍然采用访问域的方式,并记录使用的资源在 R.txt 中。在 application module 编译时,收集所有 library module 的 R.txt,加上 application module R 文件输入给 aapt,aapt 在获得全局的输入后,按序给每个资源生成唯一不重复的资源 id,从而避免这种冲突。但此时,library module 已经编译完成,因此只能生成 R.java 文件,来满足 library module 的运行时资源获取。

不同版本 AGP 生成 R 文件的表现

keep R 文件混淆规则

1
2
3
4
5
6
-keepattributes InnerClasses

-keep class **.R
-keep class **.R$* {
    <fields>;
}
AGP3.5.2 R 文件

uebrq
AGP3.5 先生成 R.java,然后再编译成 R.class
反编译后,也是能看到 R 文件的
v69pp

  • app 的 R 替换成了常量
  • library 的 R 不是常量,未替换
AGP3.6 R 文件

AGP3.6 需要 Gradle5.6.4+
91si4
AGP 3.5.0 到 3.6.0 通过减少 R 生成的中间过程,来提升 R 的生成效率(先生成 R.java 再通过 Javac 生成 R.class 变为直接生成 R.jar)
jkv5h

AGP4.1 R 文件

AGP4.1,需要 Gradle6.5+
egh2a
AGP4.1.0 后,app 和 library 的 R 都会替换成了常量

小结
  1. AGP3.5.2/3.6.0/4.1.0 app module 中 R 都是常量,app module 都会内联替换成常量
  2. AGP3.5.2/AGP3.6.0,App 的 R 替换成了常量,library 还是 R.xxx.xxx 变量,不会替换
  3. AGP3.5.2→3.6.0,相比 3.5.2 不会生成 R.java,直接生成 R.jar
  4. AGP3.5.2 和 3.6.0,library 的 R
  5. AGP4.1.0 是做了对 R 文件的内联,并且做的很彻底,不仅删除了冗余的 R 文件,并且还把所有对 R 文件的引用都改成了常量
 AGP3.5.2AGP3.6.0AGP4.1.0
是否生成 R.java
生成 R.java 路径u44el
生成 R.java
不生成不生成
app R.class/R.jar?w3xu5
app module 生成的 R.class 是常量
ja5u8app module 生成的 R.jar 是常量k5qjpapp module 生成的 R.jar 是常量
library R.class/R.jareclyq
library module 生成的 R.class 不是常量
8iict
library module 生成的 R.java 的不是常量
uz74z
library R.jar 不是常量
生成的 R class 的路径50f59
/build/intermediates/javac/debug/classes/me/hacket/qiubaitools/R.class
xpkg7
build/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar
6gaym
build/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar
app R 内联app module 的 R 都内联成了常量  
library module 内联(查看 LoginActivity 字节码)fbyy6
p8ljr
at5vg
是,library module 的 R 被替换成了常量,但 R 文件没有删除
pm8j1

booster r inline (R.txt)

  • booster-transform-r-inline

R.txt 存在哪?

  • 最早:R.java
  • 旧版本 build/intermediates/symbols
  • 4.x 版本 build/intermediates/runtime_symbol_list

总结: 滴滴 booster 通过解析 R.txt 文件效率上要高于扫描 class,使用方便,逻辑清晰,library 的 R 资源的 Fields 会全部删除,并将引用 Library 包名的 int 数组会全部修改为应用包名,应用包名的 R 的 styleable 资源会全部保留。需要注意的是 Library 的 R class 没有被删除,所以应用中即使使用了反射获取资源 id 时也不会造成应用崩溃,使用反射肯定捕获了异常,但可能会造成页面异常,另外不支持根据资源名配置白名单,只能根据包名进行配置。

Bytex-shrink

字节跳动的 ByteX 会扫描所有 R 文件 class 并将相关信息存储到集合中,支持根据包名和资源名配置白名单,对 Styleable class 的 int 数组也做了 inline 处理,并且将无用 R 文件 class 进行了删除,最后还提供了 html 的报告,里面包含可能使用反射获取 R 资源的类信息,这可以帮助我们更好地配置白名单,由于 R 文件 class 也会被删除,所以如果应用使用反射获取资源可能会直接崩溃。相对于滴滴 booster,字节跳动的 ByteX 将无用 R 文件 class 也进行了删除和 R 资源中的 int 数组也进行了 inline 内联处理,处理更彻底。
和 AGP4.1 相比,做了什么?

  1. AGP4.1 只是内联了 R 并删除了 R 中的条目,但并没有删除 R.class;androidx 库中的 R.class 中的条目没有删除

o5xd6

  1. Bytex shrink-r 删除了 module 中的 R.class 和 androidx 库中的 R.class

1btw7

不需要内联的场景

  1. 反射用到 R 资源的地方
1
2
3
4
5
6
7
8
9
10
public static int getId(String num){
    try {
        String name = "weather_detail_icon_" + num;
        Field field = R.drawable.class.getField(name);
        return field.getInt(null);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}
  1. getIdentifier

数据情况

  • 58app 在使用 ByteX 将 R 资源 inline 后,包大小减少了 4.6M,dex 数量从 16 个减到了 11 个。

Ref

nonTransitiveRClass 禁用 R 文件依赖传递 (最低 AGP4.2.0)

R 文件依赖传递,最上层的 module 拥有其依赖 module 的 R,不仅拖慢了编译速度,还增加了包的体积

1
android.nonTransitiveRClass=true

比如 Mashi 有个 lib_string module 专门放 string 资源的,每次都要传递的 app R,存在很大的空间浪费

ByteX 插件代码优化

  1. 编译器内联常量
  2. 编译期间优化掉Log调用

Redex

Redex 介绍

Facebook 的一个开源编译工具 ReDex

ReDex 除了没有文档之外,绝对是客户端领域非常硬核的一个开源库,非常值得你去认真研究。ReDex 这个库里面的好东西实在是太多了,其中去除 Debug 信息是通过 StripDebugInfoPass 完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "redex" : {
    "passes" : [
      "StripDebugInfoPass"
    ]
  },
  "StripDebugInfoPass" : {
    "drop_all_dbg_info" : "0",     // 去除所有的debug信息,0表示不去除
    "drop_local_variables" : "1",  // 去除所有局部变量,1表示去除
    "drop_line_numbers" : "0",     // 去除行号,0表示不去除
    "drop_src_files" : "0",        
    "use_whitelist" : "0",
    "drop_prologue_end" : "1",
    "drop_epilogue_begin" : "1",
    "drop_all_dbg_info_if_empty" : "1"
  }
}

Dex 分包

define classes and methods 和 referenced methods

在 Android Studio 查看一个 APK 的时候,不知道你是否知道下图中 “defines 48554 methods” 和 “references 65530 methods” 的区别。

image.png

  • define classes and methods” 是指真正在这个 Dex 中定义的类以及它们的方法
  • reference methods” 指的是 define methods 以及 define methods引用到 的方法

示例 1: LocationWorker Defined Methods 为 3 个方法,表示定义了 3 个方法;Referenced Methods 为 4 个表示定义了 3 个方法,引用了 1 个方法 image.png

LocationWorker 代码:

1
2
3
4
5
6
7
class LocationWorker(appContext: Context, params: WorkerParameters) :
    RemoteCoroutineWorker(appContext, params) {
    override suspend fun doRemoteWork(): Result {
        L.e("LocationWorker doRemoteWork".log(applicationContext))
        return Result.success()
    }
}

跨 dex 调用

示例 2:

如下图所示如果将 Class A 与 Class B 分别编译到不同的 Dex 中,由于 method a 调用了 method b,所以在 classes2.dex 中也需要加上 method b 的 id。

image.png

因为跨 Dex 调用造成的这些冗余信息,它对我们 Dex 的大小会造成哪些影响呢?

  • method id 爆表。我们都知道每个 Dex 的 method id 需要小于 65536,因为 method id 的大量冗余导致每个 Dex 真正可以放的 Class 变少,这是造成最终编译的 Dex 数量增多。
  • 信息冗余。因为我们需要记录跨 Dex 调用的方法的详细信息,所以在 classes2.dex 我们还需要记录 Class B 以及 method b 的定义,造成 string_idstype_idsproto_ids 这几部分信息的冗余。

为了减少跨 Dex 调用的情况,我们必须 尽量将有调用关系的类和方法分配到同一个 Dex 中。但是各个类相互之间的调用关系是非常复杂的,所以很难做到最优的情况。所幸的是,ReDex 的 CrossDexDefMinimizer 类分析了类之间的调用关系,并 使用了 贪心算法 去计算局部的最优解(编译效果和 dex 优化效果之间的某一个平衡点)。使用 “InterDexPass” 配置项 可以把互相引用的类尽量放在同个 Dex,增加类的 pre-verify,以此提升应用的冷启动速度

dex 有效率

定义一个 Dex 信息有效率的指标,希望保证 Dex 有效率应该在 80% 以上。同时,为了进一步减少 Dex 的数量,我们希望每个 Dex 的方法数都是满的,即分配了 65536 个方法。

1
2
Dex信息有效率 = define methods数量/reference methods数量
# dex有效率 = 1个dex定义的方法数 / 1个dex定义和引用的方法数

那如何实现 Dex 信息有效率提升呢?关键在于我们需要将有调用关系的类和方法分配到同一个 Dex 中,即 减少跨 Dex 的调用 的情况。但是由于类的调用关系非常复杂,我们不太可能可以计算出最优解,只能得到局部的最优解。

Dex 压缩

oatmeal

Ref

Kotlin 语法糖

  • 不用 data class ,用普通的 class
  • 自定义 ViewModel 的 lazy 实现?待验证

自定义 ViewModel 的 Lazy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@MainThread
inline fun <reified VM : ViewModel> Fragment.defaultActivityViewModels(
): Lazy<VM> = DefaultFragmentViewModelLazy(
    VM::class,this,needRequestActivity = true
)

class DefaultFragmentViewModelLazy<VM : ViewModel> (
    private val viewModelClass: KClass<VM>,
    private val fragment: Fragment,
    private val needRequestActivity: Boolean = false
) : Lazy<VM> {
    private var cached: VM? = null

    override val value: VM
        get() {
            val viewModel = cached
            return if (viewModel == null) {
                val factory = if(needRequestActivity) fragment.requireActivity().defaultViewModelProviderFactory else fragment.defaultViewModelProviderFactory
                val store = if(needRequestActivity) fragment.requireActivity().viewModelStore else fragment.viewModelStore
                ViewModelProvider(
                    store,
                    factory,
                    if(needRequestActivity) fragment.requireActivity().defaultViewModelCreationExtras else fragment.defaultViewModelCreationExtras
                ).get(viewModelClass.java).also {
                    cached = it
                }
            } else {
                viewModel
            }
        }

    override fun isInitialized(): Boolean = cached != null
}

class DefaultViewModelLazy<VM : ViewModel> (
    private val viewModelClass: KClass<VM>,
    private val storeProducer: ViewModelStore,
    private val factoryProducer: Factory,
    private val extrasProducer: CreationExtras
) : Lazy<VM> {
    private var cached: VM? = null

    override val value: VM
        get() {
            val viewModel = cached
            return if (viewModel == null) {
                val factory = factoryProducer
                val store = storeProducer
                ViewModelProvider(
                    store,
                    factory,
                    extrasProducer
                ).get(viewModelClass.java).also {
                    cached = it
                }
            } else {
                viewModel
            }
        }

    override fun isInitialized(): Boolean = cached != null
}

inline fun <reified VM : ViewModel> Fragment.defaultViewModels(): Lazy<VM> {
    return DefaultFragmentViewModelLazy(VM::class,this)
}

inline fun <reified VM : ViewModel> ComponentActivity.defaultViewModels(): Lazy<VM> {
    return DefaultViewModelLazy(VM::class, viewModelStore , defaultViewModelProviderFactory, defaultViewModelCreationExtras)
}
本文由作者按照 CC BY 4.0 进行授权