相信大家对于优化Android包体积大小的重要性或多或少都有所了解,这里就不再赘述,直接进入正题。
APK的组成
开始前先简单介绍下APK的大致组成:
- lib文件夹,该文件夹下是各个芯片架构对应的so文件,常见的芯片架构有ARM64,x86(非主流)。
- assets文件夹。
- res文件夹,该文件夹下各种UI相关资源,如图片,xml文件。
- 多个DEX文件,是将源码编译后得到的产物。
此外,APK还包含其余文件,如AndroidManifest.xml文件等等就不一一介绍了。
优化前的项目APK分析
使用AS自带的APK分析工具对现有项目APK进行分析,APK size是指APK本身的大小,Download size是指用户从Google Play下载时的APK大小,因为Google Play本身还会对APK进行压缩,因此会有两个size进行区分。
优化思路
参考资料1中有一段内容是这么说的:
APK本质是一个压缩文件,是打包后的产物,那可以作为切入点的阶段就是打包前、以及打包中。
- 打包前,即减少打包的文件,比如无用的资源、代码;
- 打包中,对打包中的产物进行压缩,比如资源文件、So文件;
关键词:减少、压缩。
本文也是主要围绕着两个点,如何减少要打包的文件大小和压缩打包中的产物来展开讲讲。
减少要打包的文件大小
删除多余的SO文件
从上面的APK分析可知,lib文件夹占了APK 50.6%空间,因此有很大的优化空间。
-
减小so文件的大小。
该部分的难点在于:
- so文件必须不是第三方的。
- so文件大小优化比较硬核,难度不小。
由于该部分超出了本文的讨论范围,因此不再赘述,感兴趣的小伙伴可看下参考资料4。
-
删除不需要的so文件。
-
删除非主流芯片架构的so文件。市面上目前主流Android手机的芯片架构是ARM64,因此可根据目前用户中手机芯片采用非主流架构的占比和减少包体积大小的迫切度来权衡是否要移除对非主流芯片架构的支持。
删除多余的assets文件
该步骤比较简单,就不再赘述。
删除无用的资源
该步骤可以使用Lint来辅助完成。
-
Code->Analyze Code->Run Inspection by Name.
-
选择Unused resources。
Lint检查完后就会将结果以列表的形式展示出来。
需要注意的是,Lint执行的是静态检查,会误判代码中动态引用的资源为无用资源,因此最好在删除前做好检查工作,删除后做好测试工作,避免删除了有用的资源。
个人使用下来的体验是Lint这个功能不好用,除了它是静态检查外,Lint本身bug是不少的,如误删xml文件的id,误判被ViewBinding或DataBinding生成的代码使用的xml文件为无用资源,具体可看参考资料2和3。
清理无用的代码
lint的该操作比较保守,只会去除一些可有可无的代码,如去除多余的可空符号,去除多余的修饰符,如public。
该操作收益为减少了0.6MB的包大小。
压缩图片
-
在AS安装TinyPng插件,后使用该插件将项目的所有图片资源进行一键压缩。
收益效果因项目而异,个人使用下来的体验就是没有收益。
-
将图片格式转为webp。
AS有提供一键将png转为webp的功能,选择图片右键点击"Convert to webp..."即可,缺点是只能一张张转。 收益不错,转化6张png减少了0.2MB的包大小。
压缩打包中的产物
开启代码混淆
在app模块下的build.gradle文件将release包isMinifyEnabled字段设置为true,如注释1处所示。
kotlin
buildTypes {
release {
isMinifyEnabled = true //1
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
...
}
}
之后Gradle在打包项目的release包时,就会在进行代码混淆的同时,移除无用的代码。
收益非常可观,减少了足足24.3MB大小的包体积。
如此之大的收益,优化手段难免会有些激进,代价就是程序容易崩溃,建议开启代码混淆后做好全覆盖的测试工作。
举一个栗子,如下所示,程序在发起网络请求的时候发生了崩溃,抛出的异常是说Interceptor没有一个空构造器方法,原因是Interceptor是通过反射使用的,R8在编译时候会判定为Interceptor的空构造器方法判定为无用方法,就直接删除掉了该方法。
kotlin
@BaseUrl("https://XXX.XXX.XXX")
interface Api{
@RequestInterceptor(Interceptor::class)
suspend fun getData():Data
}
解决方法有两个。
-
添加@Keep注解。
kotlin@Keep class Interceptor()
-
配置proguard-android-optimize.txt文件。
kotlin-keep public class path/from/Source/root
path/from/Source/root是相对路径,如com.XXX.XXX.XXX.Interceptor.kt
这里简单介绍下R8,启用Proguard的Android项目的编译顺序是:
SourceCode(.java) ---javac---> Java Bytecode(.class) ---Proguard---> Optimized Java bytecode(.class) ---Dex---> Dalvik Optimized Bytecode(.dex)
而R8是将Proguard和Dex这两个步骤合为一步,即是输入.class文件给R8,R8会输出.dex文件。
开启资源压缩
在app模块下的build.gradle文件将release包的isShrinkResources字段设置为true,如注释1处所示。
kotlin
buildTypes {
release {
isShrinkResources = true //1
...
}
}
该字段需要搭配代码混淆使用才有效果,原因是删除了无用代码后,原先引用的资源也变成了无用资源,可以删除了。
开启后,减少了大约2.3MB大小的包体积。
R field内联
首先介绍下R field是什么,当我们需要引用资源文件时,会使用R.xx.xx去获取,如下面代码中R.layout.activity_main,这个就是R field。
kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
我们能使用R.xx.xx去获取的原理是,在早期版本中,AGP会在编译过程中生成一个R.java文件,如下所示。
java
public final class R {
...
public static final class anim{
...
}
...
public static final class layout{
public static final int activity_main = 0x7f0d001d;
}
}
在运行过程中,应用会通过该id在resources.arsc文件(在APK里)找到对应的资源文件路径,再根据这路径在res文件夹找到对应的资源文件,需要注意的是,应用在安装完后,APK仍然会被保存在本地,路径为~/data/app/...,来供应用读取resources.arsc文件之类的文件。
从上面可以看到R field是静态常量,因此可以内联,即在编译过程用具体的值去替代引用,如用0x7f0d001d去替换R.layout.activity_main,这样做的好处是可以删掉R.java文件,减少APK包体积的大小,对于中大型项目,收益可高达10MB。
4.1版本以上的AGP(Android Gradle plugin)默认支持了R field内联,具体可见参考资料7和8,好消息是不用自己做了,坏消息是没有优化空间了。
总结
优化前包大小为92.7MB,优化后包大小为65.4MB,取得了27.3MB的收益,对于这效果,个人是满意的。
除了本文提到的技巧外,还有不少优化包大小的方法,如插件化和H5化,由于我没有亲身实践过,就不写出来贻笑大方了,感兴趣的小伙伴可以看看参考资料1。
参考资料
- Android包体积优化(常规、进阶、极致)
- Why the ids are removed?
- Layouts used by code generated with Data Binding incorrectly reported as UnusedResource by lint
- Android对so体积优化的探索与实践
- Shrink, obfuscate, and optimize your app
- What is the difference between Proguard and R8?
- Android性能优化 - 包体积杀手之R文件内联原理与实现
- Android agp 对 R 文件内联支持