我们如何让Android客户端暴瘦了100M
一、 引言
随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的时候,效果往往很有限,本文将详细介绍我们在包大小优化方面的实践经验,并通过一系列技术手段实现了显著的包体积缩减。
二、 安装包大小分析
Android的apk通常有以下几部分组成:
- 代码:包含应用中Java/Kotlin代码,在包中以dex的形式存在
- 资源:包含图片、布局文件等
- lib库: 包含了应用的Native代码库,以.so文件的形式存在
- assets:包含了应用运行时所需的非代码资源,如音频、视频、字体、配置文件等
- 其他:签名文件、资源索引文件等
通过分析安装包大小的组成,我们发现项目中lib库和assets占比达到70%,代码占比20%,资源和其他占比10%。
三、基础优化方案
- 代码优化:开启代码混淆,混淆可以帮助缩减代码尺寸、移除无用代码,通过分析反编译后的代码,我们发现很多本该混淆的类没有混淆,最终定位到工程中引入的一些三方库的混淆规则keep范围过大,导致混淆效果不理想;通过混淆规则的优化,包大小缩减了6M左右;
- 资源优化:解压apk文件,把res目录下的图片按照大小进行排序,我们发现项目中有一些尺寸较大的图片,把图片格式转为webp格式后,尺寸大大降低;同时通过对比资源的md5值,发现一些资源名字虽然不一样,但是内容是一样的,这些重复资源可以移除;通过资源的优化,包大小缩减了15M左右;
- assets资源优化:通过分析apk assets目录下的文件,我们发现里面有很多不用的文件,比如arm64位包中存在x86、armeabi-v7a等其他架构的so库,这些assets目录下的so库是三方库引入的,运行时动态加载,由于设置abiFilters无法过滤掉这些so库,导致打包进apk中;我们通过自定义构建流程,在mergeAssetsTask执行结束后移除assets中不用的so库;通过assets资源的优化,包大小缩减了4M左右.
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 移除无用的assets资源
removeUnusedAssets(assetsDir)
}
}
}
通过基础的代码和资源等的优化,包大小缩减了25M左右,但是对于一个180M的apk来说,效果非常有限,需要探索其他方案进一步降低包大小。
三、 进阶优化方案
上面我们分析过apk中的lib库和assets文件占比达到70%,因此我们重点针对lib库和assets文件尺寸大的问题进行优化,我们可以把这些文件放到云端,在应用启动的时候下载到本地,但是这样做有以下问题:
- 一些lib库和assets文件在应用启动的时候就会用到,如果放到云端,会导致应用启动时间变长甚至崩溃;
- 应用中加载assets资源是通过系统API
AssetManager.open
来加载的,但是把assets文件从apk中移除后,需要修改使用AssetManager.open的地方,改为从本地私有目录加载,这样会导致改动的地方很多,而且容易漏改和错改;一些三方库由于没有开源,修改起来会更加困难; - 应用中加载lib库是通过系统API
System.loadLibrary
来加载的,如果把so库从apk中移除后,需要修改为使用System.load加载私有目录下的so库,同样存在改动地方多,不开源的三方库修改困难的问题; - 把移除的so库和assets文件打包成一个文件下发会存在由于文件尺寸大导致下载时间长,容易下载失败问题,同时会导致当用户使用到相关功能的时候需要长时间的等待,体验差。
针对以上问题,我们采用了以下优化策略:
- 选择性移除:只把一些尺寸大,用户使用频次较低的功能中使用的assets和so库从apk包中移除,在不影响用户体验的同时,降低安装包大小。
- 分包下载:需要移除的so库和assets文件按功能模块进行分包,首次使用时再去下载对应的资源包,这样能确保功能模块依赖的云端资源尽可能的小,大幅降低下载时间,提升下载成功率,减少用户等待时间。
- 自动化构建:通过编写gradle脚本,自定义构建过程,在构建阶段自动把assets和so库从apk包中移除并打包。
project.afterEvaluate {
android.applicationVariants.all { variant ->
def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
mergeAssetsTask.doLast {
def assetsDir = mergeAssetsTask.outputDir.get().toString()
// 把assetsDir中需要移除的assets文件移除,放到模块指定的目录下
}
def mergeNativeLibsTask = project.getTasksByName("merge${variant.name.capitalize()}NativeLibs", false)
mergeNativeLibsTask.doLast {
def libDir = mergeNativeLibsTask.outputDir.get().toString()
// 把 libDir中需要移除的so库移除,放到模块指定的目录下
// 打包压缩模块目录
}
}
}
- 字节码插桩:开发gradle插件,使用字节码插桩技术,在编译阶段自动把调用
AssetManager.open
和System.loadLibrary
的地方替换为我们的自定义加载器,工程中的代码和三方闭源库无需做任何改动。
public class MyMethodVisitor extends MethodVisitor {
@Override
public void visitMethodInsn(int opcode, String owner, String name, string desc, boolean isInterface) {
// 替换System.loadLibrary为DynamicLoader.loadLibrary
if ("java/lang/System".equals(owner) && "loadLibrary".equals(name)) {
return super.visitMethodInsn(opcode, "com/xxx/loader/DynamicLoader", name, desc, isInterface);
}
// 替换AssetManager.open为DynamicLoader.openAsset
if ("android/content/res/AssetManager".equals(owner) && "open".equals(name) && "(Ljava/lang/String;)Ljava/io/InputStream".equals(desc)) {
return super.visitMethodInsn(Opcodes.INVOKESTATIC, "com/xxx/loader/DynamicLoader", "openAsset", "(Landroid/content/res/AssetManager;Ljava/lang/String;)Ljava/io/InputStream");
}
return super.visitMethodInsn(opcode, owner, name, desc, isInterface);
}
}
- 双重加载机制:在自定义加载器中先尝试加载apk内置的so库和assets文件,如果出现异常,则从动态下发的文件中查找并加载,这样可以保证无论so库是否移除都可以正常加载。
// 自定义加载器
public class DynamicLoader {
public static void loadLibrary(string libname) throw Throwable {
try {
// 先加载apk包中的so库
System.loadLibrary(libname);
return;
} catch(Throwable e) {
}
String soPath = findLibrary(libName);
// apk包中的so库加载失败时加载动态下发的so库
return System.load(soPath);
}
public static InputStream openAsset(AssetManager am, String fileName) throw IOException {
try {
// 先加载apk包中的asset文件
return am.open(fileName);
} catch(IOException e) {
}
// apk包中的asset文件加载失败时加载动态下发的asset文件
String assetPath = findAsset(fileName);
return new FileInputStream(assetPath);
}
}
四、 实施效果
采用上述包优化方案后,我们的Android客户端安装包大小从180M缩减到78M,实现了显著的包体积缩减。同时,通过监控优化后版本的崩溃率和用户反馈,未出现明显的崩溃率升高和用户体验下降的情况。
五、 未来展望
应用的安装包大小优化是一个长期的过程,需要建立一套包大小的监控、预警、原因分析、自动优化等机制,确保安装包大小在合理范围,我们将从以下几个方面进行探索:
- 设定安装包大小基准,持续监控安装包大小的变化,当安装包大小偏移基准值过大的时候,触发预警,并自动分析包大小增加原因,找出导致包大小增大的文件;
- 优化构建流程,构建阶段自动压缩大图片为webp格式,自动合并重复资源;
- 持续优化应用的性能表现和用户体验,并根据实际情况进行进一步的优化调整。
作者:jack5288
来源:juejin.cn/post/7379168502455222311
来源:juejin.cn/post/7379168502455222311