包体积优化-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 文件反解出正确的行号
- 利用 proguard 来删除 debugItem (去掉
- 方案二(考虑性能)
- 尝试直接修改 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:flexbox | The 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 文件结构
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,我们来解释一下具体含义
- 第一个字节 7f:代表着这个资源属于本应用 apk 的资源,相应的以 01 代表开头的话(比如 0x01010000)就代表这是一个与应用无关的系统资源。0x7f010000,表明 abc_fade_in 属于我们应用的一个资源
- 第二个字节 01: 是指资源的类型,比如 01 就代表着这个资源属于 anim 类型
- 第三,四个字节 0000: 指资源的编号,在所属资源类型中,一般从 0000 开始递增
R 文件冗余
Android 从 ADT 14 开始为了解决多个 library 中 R 文件中 id 冲突,所以将 Library 中的 R 的改成 static 的 非常量
属性。不能用 switch-case。
在 apk 打包的过程中,module 中的 R 文件采用对依赖库的 R 进行累计叠加的方式生成。如果我们的 app 架构如下:
编译打包时每个模块生成的 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 文件
AGP3.5 先生成 R.java,然后再编译成 R.class
反编译后,也是能看到 R 文件的
- app 的 R 替换成了常量
- library 的 R 不是常量,未替换
AGP3.6 R 文件
AGP3.6 需要 Gradle5.6.4+
AGP 3.5.0 到 3.6.0 通过减少 R 生成的中间过程,来提升 R 的生成效率(先生成 R.java 再通过 Javac 生成 R.class 变为直接生成 R.jar)
AGP4.1 R 文件
AGP4.1,需要 Gradle6.5+
AGP4.1.0 后,app 和 library 的 R 都会替换成了常量
小结
- AGP3.5.2/3.6.0/4.1.0 app module 中 R 都是常量,app module 都会内联替换成常量
- AGP3.5.2/AGP3.6.0,App 的 R 替换成了常量,library 还是 R.xxx.xxx 变量,不会替换
- AGP3.5.2→3.6.0,相比 3.5.2 不会生成 R.java,直接生成 R.jar
- AGP3.5.2 和 3.6.0,library 的 R
- AGP4.1.0 是做了对 R 文件的内联,并且做的很彻底,不仅删除了冗余的 R 文件,并且还把所有对 R 文件的引用都改成了常量
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 相比,做了什么?
- AGP4.1 只是内联了 R 并删除了 R 中的条目,但并没有删除 R.class;androidx 库中的 R.class 中的条目没有删除
- Bytex shrink-r 删除了 module 中的 R.class 和 androidx 库中的 R.class
不需要内联的场景
- 反射用到 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;
}
getIdentifier
数据情况
- 58app 在使用 ByteX 将 R 资源 inline 后,包大小减少了 4.6M,dex 数量从 16 个减到了 11 个。
Ref
- Android agp 对 R 文件内联支持(网易云音乐)
- 浅谈Android中的R文件作用以及将R资源inline减少包大小
- 每日一问 为什么Android app module下的R.java中变量为final,而lib module中R.java中的变量非final呢?
nonTransitiveRClass 禁用 R 文件依赖传递 (最低 AGP4.2.0)
R 文件依赖传递,最上层的 module 拥有其依赖 module 的 R,不仅拖慢了编译速度,还增加了包的体积
1
android.nonTransitiveRClass=true
比如 Mashi 有个 lib_string module 专门放 string 资源的,每次都要传递的 app R,存在很大的空间浪费
ByteX 插件代码优化
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
” 的区别。
- “
define classes and methods
” 是指真正在这个 Dex 中定义的类以及它们的方法 - “
reference methods
” 指的是define methods
以及define methods引用到
的方法
示例 1: LocationWorker Defined Methods 为 3 个方法,表示定义了 3 个方法;Referenced Methods 为 4 个表示定义了 3 个方法,引用了 1 个方法
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。
因为跨 Dex 调用造成的这些冗余信息,它对我们 Dex 的大小会造成哪些影响呢?
method id 爆表
。我们都知道每个 Dex 的 method id 需要小于 65536,因为 method id 的大量冗余导致每个 Dex 真正可以放的 Class 变少,这是造成最终编译的 Dex 数量增多。信息冗余
。因为我们需要记录跨 Dex 调用的方法的详细信息,所以在 classes2.dex 我们还需要记录 Class B 以及 method b 的定义,造成string_ids
、type_ids
、proto_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)
}