写在前面
大家好我是三雒, 这一篇带大家从0到1做一个Android SO压缩方案,包括方案的整体设计和实现过程中的关键技术难题。另外本文项目已经开源到github, 代号Nano,欢迎大家Star和PR。
从预装包谈起
一切还要从预装厂商要求我们的预装包中.so文件必须以Store形式存放说起。
众所周知,Android的APK文件是一个ZIP文件,在ZIP包中每个文件都可以指定以压缩(Deflate)或者非压缩(Store)形式存放。如果在Manifest文件中声明android:extractNativeLibs="true"
,那么.so文件将会以压缩形式存放在APK中,并在安装时候将其解压到/data/app/[包名]/lib
目录方便运行时直接加载。如果声明android:extractNativeLibs="false"
那么.so文件会不压缩存放在ZIP里面,这样在安装时候不用再解压,Android系统可以直接通过mmap加载.so文件。
厂商之所以这么要求,应当主要从磁盘存储体积占用考虑的,保障用户拿到的新机磁盘占用较低。因为.so压缩后还是要解压到磁盘上,两种情况下的存储占用区别如下:
整体上就多了使用Defalte压缩后的.so文件体积,大小约为.so源文件的三分之一左右。
除了上述要求外,厂商还同时严格限制APK体积大小,比如我们App在只含一份arm64-v8a so且以Store形式存放时APK体积大小为185M,但是厂商却要求我们优化到105M,也就是我们要优化80M,这用常规优化手段根本达不到要求。 但是预装作为App推广的核心渠道,是新用户增长的重要来源,我们是肯定不能放弃的。因此我们重新审视APK,其中未压缩的.so文件就占用将近100M,虽然厂商不让使用ZIP的Defalte压缩,但是是否可以自己压缩一下呢?
简单来说就是我们可以使用任意的压缩算法(甚至比Deflate压缩率更高)把.so压缩成一个或几个文件,然后把压缩后的文件伪装成.so文件放入lib目录,甚至放入assets目录都是可以的,在运行时再解压出来给加载就可以了。我们来看如何实现这个方案以及实现这个方案需要考虑和解决哪些问题。
方案概览
方案整体上分为编译时和运行时两部分:
- 编译时: 从AGP中寻找合适的时机完成.so文件的压缩,包括.so文件获取,分组分块逻辑处理、压缩、压缩信息写入等步骤
- 运行时: 除了必要的初始化外,主要完成压缩文件解压 、校验 和.so文件加载。
接下来我们会针对这几部分逐一讲解。
压缩
压缩是指在构建过程中.so文件进行分组分块压缩,删除源文件,并将压缩后的文件伪装成.so文件放入lib目录下的过程。既然要想压缩,最重要的当然是看选择什么压缩算法。
压缩算法
对于压缩算法的选择,我们主要关心压缩率 和解压速度 两个性能指标。前者直接决定对包体积的收益,后者则直接影响新用户首次启动的体验。在 压缩算法BenchMark网站 上我们可以看到众多压缩算法相关的性能数据。
上图是各个算法压缩率和解压速度的对照关系,为了保证不错的包体积收益,我们只看压缩率大于3的算法,从左至右依次是zpaq、lzma、lzham、brotli、zstd。 我们筛选了两个非常优秀的压缩算法 lzma(xz)与 zstd , lzma具有非常高的压缩比,但是解压速度相对逊色 ,而zstd则追求压缩比与解压速度的均衡。
lzma是压缩算法,比如7z的.7z格式就使用该算法,xz是另一种使用lzma的压缩文件格式
压缩时机
Android构建过程会将项目中所有.so文件合并到一个目录,然后删除.so文件的Debug Symbols ,得到最终的.so文件打包进.apk或者.aab文件, 以assembleRelease为例构建过程SO和Dex的主要处理Task和流程如下:

注:红色部分为Nano新增
从上图我们可以看出,选择.so压缩时机有以下几种:
- 在package Task的doFirst
- 在stripDebugSymbol和package中间插入一个Task
- 在stripDebugSymbol的doLast
- 在package之后
经过验证1和2会报错,我们在压缩完成时会把原始的.so删除掉,而package Task执行时候会校验原来的.so文件是否存在,不存在就会报错, 校验的.so文件List是在stripDebugSymbol完成时候就确认了的,因此最终选择了时机3。另外压缩信息写入部分实现时候考虑到运行时更优的性能,并未直接写入文件,而是直接写入到Dex对应的class文件中,所以压缩部分还需要依赖Dex的生成,最终Nao增加的部分就是上图中橙色部分。
那为什么不在package之后呢?主要原因有两个:
- 在stripDebugSymbol这里可以同时适配assemble和bundle打包,如果放在package之后,bundle打包还需要再额外适配
- 在package之后对apk进行解压缩并重新压缩,还需要重新签名
分块
从提升解压速度的角度我们很容易想到多线程并发解压,压缩算法本身并不能并发解压一个文件,所以我们在压缩文件时候将文件分块,也就是指将多个源文件,分别压缩到多个压缩文件中去。分块策需要平衡 解压速度 和 压缩体积,达到最佳的用户体验。
分块策略
以启动场景为例,需要将所有的文件块解压完毕之后,程序才能继续运行,理想情况下分块后每个压缩文件大小一致,这样在解压时候解压完成的时间也接近,但是压缩后的大小需要真正压缩之后才知道,要现在他们完全均等是很难的,所以退而求其次保证源文件的大小接近。
完全均分
顾名思义,完全均分是指将保证所有分块中源文件的大小一致,这就存在一个问题,就是会将同一个源文件分割到不同的块中 去,这样对于增量解压的场景下原本只需要解压出来一个源文件,但由于这个文件跨块了,不得不解压两个块,显得并不理想。另外也会增加实现的复杂度。
增量解压:很多So文件更新并不频繁,当App更新时复用之前版本解压过的.so文件,只将变化的.so文件解压出来
非均匀分块
在不将一个源文件分割到两个压缩块的情况下,尽可能地保证每个压缩块的大小接近,使用贪心算法可以比较简单地解决该问题。在源文件数量较多的情况下,这种策略在均分的效果上略微差点,但是开发的复杂度以及增量解压场景下都会更好,Nano目前采用该分块策略。
分块数量
Nano框架侧可以配置压缩文件块数量,但是使用业务侧到底配置多少块合适呢?
每个压缩文件块需要使用一个线程来解压,那其实并发度也就决定了压缩块数。目前手机的CPU主要有4核以及8核,理论上压缩成8块并发解压在4核以及8核的手机上都能获得最佳的解压速度。但同时需要考虑到压缩文件块数越多,由于重复信息分散到不同的文件,压缩的的收益会有损耗。以apk中大小6.6M的文件为例,用xz压缩后体积随压缩文件数的变化如下:
压缩文件数 | 1 | 2 | 4 | 8 |
---|---|---|---|---|
压缩后的体积 | 4.7M | 4.8M | 5M | 5.1M |
建议配置4或者8块,具体多少可根据实际场景权衡选择。
分组
Nano还提供分组的概念,可以根据不同的场景对.so进行分组压缩,每一组可以单独指定分块数量,在使用到这一组.so文件时候触发解压即可。使用中可以压缩所有.so并在启动之前解压,也可以为一些二级页面单独压缩.so,比如拍摄、直播等使用.so较多的页面,根据实际情况灵活使用。
压缩信息写入
压缩时需要保存一些必要的信息给解压使用,解压一个组需要该组中所有源文件的信息,源文件信息包括文件名、所在压缩文件及位置、分组、校验checkNum等。Nano考虑到更优的运行时性能并未将这些信息使用文本文件存储,而是直接在编译时写入代码中。如下NanoFileInfo代表一个源文件,编译时直接生成new NanoFileInfo()的代码,存入相应的List中。
kotlin
class NanoFileInfo : Comparable<NanoFileInfo> {
/**
* the original file name
*/
@JvmField
var name: String = ""
/**
* the compressed file name which this file in.
*/
@JvmField
var compressedFileName: String = ""
/**
* the group name of this file, it is specified in the nano plugin.
*/
@JvmField
var group: String = ""
/**
* the size of the original file
*/
@JvmField
var fileSize: Long = 0
/**
* the pos in the compressed file stream where this file begins.
*/
@JvmField
var beginPos: Long = 0
/**
* the pos in the compressed file stream where this file ends.
*/
@JvmField
var endPos: Long = 0
@JvmField
var checkNum: Long = 0
override fun compareTo(other: NanoFileInfo): Int {
return beginPos.compareTo(other.beginPos)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NanoFileInfo) return false
if (checkNum != other.checkNum) return false
return name == other.name
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (checkNum xor (checkNum ushr 32)).toInt()
return result
}
override fun toString(): String {
return "NanoFileInfo(name='$name', compressedFileName='$compressedFileName', group='$group', fileSize=$fileSize, beginPos=$beginPos, endPos=$endPos, checkNum=$checkNum)"
}
}
所有源文件信息的List按照abi分别存储,运行时根据abi获取到所有源文件信息,然后进行分组保存。
kotlin
object CompressInfo {
fun getCompressMethod(): Int {
return 0
}
fun getSoFileInfoList(abi :String): List<NanoFileInfo> {
return when (abi) {
AbiUtils.ARMEABI-> getSoFileInfoListForArmeabi()
AbiUtils.ARMEABI_V7A -> getSoFileInfoListForArmeabiV7a()
AbiUtils.ARM64_V8A -> getSoFileInfoListForArm64V8a()
AbiUtils.X86 -> getSoFileInfoListForX86()
AbiUtils.X86_64 -> getSoFileInfoListForX86_64()
AbiUtils.MIPS -> getSoFileInfoListForMips()
AbiUtils.MIPS64 -> getSoFileInfoListForMips64()
else -> throw IllegalArgumentException("Unsupported ABI: $abi")
}
}
private fun getSoFileInfoListForArmeabi(): List<NanoFileInfo> {
/**
* build time instrumentation for armeabi
*/
return ArrayList()
}
private fun getSoFileInfoListForArmeabiV7a(): List<NanoFileInfo> {
/**
* build time instrumentation for armeabi-v7a
*/
return ArrayList()
}
private fun getSoFileInfoListForArm64V8a(): List<NanoFileInfo> {
/**
* build time instrumentation for arm64-v8a
*/
return ArrayList()
}
private fun getSoFileInfoListForX86(): List<NanoFileInfo> {
/**
* build time instrumentation for x86
*/
return ArrayList()
}
private fun getSoFileInfoListForX86_64(): List<NanoFileInfo> {
/**
* build time instrumentation for x86_64
*/
return ArrayList()
}
private fun getSoFileInfoListForMips(): List<NanoFileInfo> {
/**
* build time instrumentation for mips
*/
return ArrayList()
}
private fun getSoFileInfoListForMips64(): List<NanoFileInfo> {
/**
* build time instrumentation for mips64
*/
return ArrayList()
}
}
解压
解压的流程比较简单,以launch分组为例图示解压流程如下:

获取分组信息
通过上文中CompressInfo.getSoFileInfoList获取所有.so文件信息,根据group字段进行分组即可获得该组的所有.so信息。
确定解压文件
通过对比本地解压过的文件和这次压缩文件中每个文件的checkNum, 确定需要解压的源文件,进一步知道要解压哪几个压缩块。这可以减少不必要的文件解压,当然也要求我们在配置时候尽量将一些不常发生变化的.so文件压缩进一个文件块。
多线程解压
与分块个数相对应,确定要解压的压缩文件之后,每个压缩文件块使用一个单独的DecompressFileCallable去解压,多个文件块并发解压。考虑到更好的IO性能,解压后的文件使用mmap 写入。
加载
加载是指将解压后.so文件路径注入到ClassLoader中去,以保证App中使用System.loadLibrary加载.so文件的代码能够正常执行,和插件化框架做的事情类似, 先来了解下so加载的原理。
基本思路

如图所示,System.loadLibrary在进行so加载时候会从DexPathList的nativeLibraryPathElements数组中的目录下依次去查找.so文件,我们只需要将解压后的so目录插入到nativeLibraryPathElements中去即可加载.so。注入.so路径的代码,以Android6.0为例代码如下:
kotlin
private object V23 {
@Throws(Throwable::class)
fun install(classLoader: ClassLoader, folder: File) {
val pathListField = ReflectUtils.findField(BaseDexClassLoader::class.java, "pathList")
val dexPathList = pathListField[classLoader]
val nativeLibraryDirectories =
ReflectUtils.findFieldForObject(dexPathList, "nativeLibraryDirectories")
var origLibDirs = nativeLibraryDirectories[dexPathList] as MutableList<File>
if (origLibDirs == null) {
origLibDirs = ArrayList(2)
}
val libDirIt = origLibDirs.iterator()
while (libDirIt.hasNext()) {
val libDir = libDirIt.next()
if (folder == libDir) {
libDirIt.remove()
break
}
}
origLibDirs.add(0, folder)
val systemNativeLibraryDirectories = ReflectUtils.findFieldForObject(
dexPathList,
"systemNativeLibraryDirectories"
)
var origSystemLibDirs = systemNativeLibraryDirectories[dexPathList] as List<File>
if (origSystemLibDirs == null) {
origSystemLibDirs = ArrayList(2)
}
val newLibDirs: MutableList<File> =
ArrayList(origLibDirs.size + origSystemLibDirs.size + 1)
newLibDirs.addAll(origLibDirs)
newLibDirs.addAll(origSystemLibDirs)
val makeElements = ReflectUtils.findMethod(
dexPathList.javaClass,
"makePathElements",
MutableList::class.java,
File::class.java,
MutableList::class.java
)
val suppressedExceptions = ArrayList<IOException>()
val elements = makeElements.invoke(
dexPathList,
newLibDirs,
null,
suppressedExceptions
) as Array<Any>
val nativeLibraryPathElements =
ReflectUtils.findFieldForObject(dexPathList, "nativeLibraryPathElements")
nativeLibraryPathElements[dexPathList] = elements
}
}
在实现上述路径注入后,发现如果加载的.so动态依赖另一个.so文件时候,被依赖的.so文件会找不到从而导致整体加载失败,接下来我们来看这个问题。
SO依赖加载问题
SO的加载在Java层只是做一些路径查找和拼接的工作,真正加载的过程是由Native层的Linker来实现的,当一个SO文件动态链接了其他SO文件时,会在Native层的路径列表中查找它依赖的SO先进行加载,等依赖的SO都加载完成后再加载原始SO本身。

因此Native层也需要一份SO的路径信息,这个路径信息是由NameSpace来保存,NameSpace和ClassLoader是一一对应的关系,某个ClassLoader对应的NameSpace一旦创建过就不会再创建。说了这么多,为什么加载依赖的SO会找不到呢?
这是因为LoadApk在创建PathClassLoader时候,就会直接为其创建对应的NameSpace, 代码如下:
java
private void createOrUpdateClassLoaderLocked(List<String> addedPaths) {
//通过 ApplicationLoaders.getClassLoaderWithSharedLibraries创建PathClassLoader
mDefaultClassLoader = ApplicationLoaders.getDefault().getClassLoaderWithSharedLibraries(
zip, mApplicationInfo.targetSdkVersion, isBundledApp, librarySearchPath,
libraryPermittedPath, mBaseClassLoader,
mApplicationInfo.classLoaderName, sharedLibraries.first, nativeSharedLibraries,
sharedLibraries.second);
mAppComponentFactory = createAppFactory(mApplicationInfo, mDefaultClassLoader);
setThreadPolicy(oldPolicy);
registerAppInfoToArt = true;
}
// 如果App注册了AppComponetFactory, 则在这里调用其instantiateClassLoader方法
if (mClassLoader == null) {
mClassLoader = mAppComponentFactory.instantiateClassLoader(mDefaultClassLoader,
new ApplicationInfo(mApplicationInfo));
}
}
ApplicationLoaders内部会调用ClassLoaderFactory.createClassLoader创建PathClassloader,并为其创建对应的NameSpace,在创建时候就会传入librarySearchPath。
java
public static ClassLoader createClassLoader(String dexPath,
String librarySearchPath, String libraryPermittedPath, ClassLoader parent,
int targetSdkVersion, boolean isNamespaceShared, String classLoaderName,
List<ClassLoader> sharedLibraries, List<String> nativeSharedLibraries,
List<ClassLoader> sharedLibrariesAfter) {
// 创建PathClassLoader
final ClassLoader classLoader = createClassLoader(dexPath, librarySearchPath, parent,
classLoaderName, sharedLibraries, sharedLibrariesAfter);
String sonameList = "";
if (nativeSharedLibraries != null) {
sonameList = String.join(":", nativeSharedLibraries);
}
// 为PathClassLoader创建Namespace
String errorMessage = createClassloaderNamespace(classLoader,
targetSdkVersion,
librarySearchPath,
libraryPermittedPath,
isNamespaceShared,
dexPath,
sonameList);
return classLoader;
}
这样就算我们后续将解压后的.so路径注入到PathClassLoader的DexPathList中去,Native层的Namespace也并不会更新,因此会出现依赖的.so文件找不到的问题。
原因清楚了,那么如何解决呢?解决的思路大致有三种:
- 更新NameSpace,直接更新PathClassLoader对应的NameSpace中的路径,这个需要再研究Natvie层代码看具体方案
- 重建NameSpace,只需要在Java层重新创建一个新的PathClassLoader替换之前即可,在该ClassLoader所加载的类第一次调用System.loadLibrary加载SO时候会创建NameSpace
- 应用层捕获异常手动加载依赖, 大致是编译时通过字节码替换Sysmtem.loadLibrary调用,在发生UnsatisfiedLinkError时候解析原始.so的依赖,然后手动按照依赖顺序依次加载即可,这样可以保证.so可以正常加载。
思路2替换PathClassLoader在Tinker中有成熟的方案可以参考,思路3也可以再插件中解析Dex文件,修改字节码替换,也基本没有技术卡点,Nano目前采用了替换PathClassLoader的方案。
PathClassLoader替换
PathClassLoader替换分为创建和替换两步,创建主要是要从原始ClassLoader中反射获取Dex目录、SO目录并添加上解压后的SO目录,传入新创建的PathClassLoader即可。
kotlin
fun createNewClassLoader(
oldClassLoader: ClassLoader?,
newLibDir: File?
): ClassLoader {
val baseDexClassLoaderClass =
Class.forName("dalvik.system.BaseDexClassLoader", false, oldClassLoader)
val pathListField = ReflectUtils.findField(
baseDexClassLoaderClass,
"pathList"
)
val oldPathList = pathListField[oldClassLoader]
//Dex
val dexPathBuilder = StringBuilder()
val originDexPaths = getDexPaths(oldPathList)
for (i in originDexPaths.indices) {
if (i > 0) {
dexPathBuilder.append(File.pathSeparator)
}
dexPathBuilder.append(originDexPaths[i])
}
val combinedDexPath = dexPathBuilder.toString()
//Lib dir
val nativeLibraryDirectoriesField =
ReflectUtils.findField(oldPathList.javaClass, "nativeLibraryDirectories")
val oldNativeLibraryDirectories = if (nativeLibraryDirectoriesField.type.isArray) {
Arrays.asList(*nativeLibraryDirectoriesField[oldPathList] as Array<File?>)
} else {
nativeLibraryDirectoriesField[oldPathList] as List<File>
}
val libraryPathBuilder = StringBuilder()
var isFirstItem = true
for (libDir in oldNativeLibraryDirectories) {
if (libDir == null) {
continue
}
if (isFirstItem) {
isFirstItem = false
} else {
libraryPathBuilder.append(File.pathSeparator)
}
libraryPathBuilder.append(libDir.absolutePath)
}
//append newLibDir
if (newLibDir != null && newLibDir.exists() && newLibDir.isDirectory) {
libraryPathBuilder.append(File.pathSeparator)
libraryPathBuilder.append(newLibDir.absolutePath)
}
val combinedLibraryPath = libraryPathBuilder.toString()
val result: ClassLoader =
NanoClassLoader(combinedDexPath, combinedLibraryPath, oldClassLoader!!)
return result
}
具体替换因为系统支持的API不同,分Android 9以上和以下来分别实现。
Android 9 及以上
Android 9以上提供 AppComponentFactory可以比较方便实现ClassLoader替换,并且这个代码时机比较早我们可以保证应用的Application类也是由新的ClassLoaer加载,除了替换实现方便这个时机也很重要,在下文低版本上会讲这个问题。
kotlin
@RequiresApi(Build.VERSION_CODES.P)
class DemoAppComponentFactory : AppComponentFactory(){
override fun instantiateClassLoader(cl: ClassLoader, aInfo: ApplicationInfo): ClassLoader {
//创建新的PatchClassLoader
return Nano.install(cl, aInfo)
}
}
Android 9以下
低版本的替换参考了Tinker的实现,主要替换ContextImpl、LoadedApk、Resource三个对象中持有的ClassLoader。
kotlin
fun inject(app: Application, oldClassLoader: ClassLoader?, newLibDir: File?): ClassLoader {
//NanoClassLoader is already injected by AppComponentFactory,But the NanoClassLoader.class.clsLoader is different, so we need to use name
if (app.classLoader.javaClass.name == NanoClassLoader::class.java.name) {
NanoLog.i( "NanoClassLoader is already injected, skip re-inject")
return app.classLoader
}
NanoLog.i(
"NanoClassLoader inject,app classLoader:" + app.classLoader.toString()
)
val newClassLoader = createNewClassLoader(oldClassLoader, newLibDir)
doInject(app, newClassLoader)
return newClassLoader
}
kotlin
private fun doInject(app: Application, classLoader: ClassLoader) {
Thread.currentThread().contextClassLoader = classLoader
val baseContext = ReflectUtils.findField(app.javaClass, "mBase")[app] as Context
try {
ReflectUtils.findField(baseContext.javaClass, "mClassLoader")[baseContext] = classLoader
} catch (ignored: Throwable) {
// There's no mClassLoader field in ContextImpl before Android O.
// However we should try our best to replace this field in case some
// customized system has one.
}
val basePackageInfo =
ReflectUtils.findField(baseContext.javaClass, "mPackageInfo")[baseContext]
ReflectUtils.findField(basePackageInfo.javaClass, "mClassLoader")[basePackageInfo] =
classLoader
val res = app.resources
try {
ReflectUtils.findField(res.javaClass, "mClassLoader")[res] = classLoader
} catch (ignored: Throwable) {
// Ignored.
}
try {
val drawableInflater = ReflectUtils.findField(res.javaClass, "mDrawableInflater")[res]
if (drawableInflater != null) {
ReflectUtils.findField(
drawableInflater.javaClass,
"mClassLoader"
)[drawableInflater] = classLoader
}
} catch (ignored: Throwable) {
// Ignored.
}
}
我们最早能执行替换的时机是在Application类的attachBaseContext方法中,Application类本身还是使用原始PathClassLoader创建。在Android中如果不是通过ClassLoader.loadClass显式加载,而是通过代码调用、Class.forName("xxx")加载类,那么被加载的类ClassLoader将会使用调用方类的ClassLoader,这样如果Application是由原ClassLoader加载的,即使我们完成上述代码中的替换,其他业务类也还是会被传染。 因此我们需要把业务代码和Application类完全隔离起来,Nano也借鉴了Tinker的 ApplicationLike的概念, 业务代码全写在ApplicationLike中,生命周期和Application保持一致,只是ApplicationLike会由新的ClassLoader来加载。
写在最后
本文详细介绍了Android SO压缩方案的实现,包含编译时插件和运行时两部分的详细实现。
在编译时插件插件:
- 压缩算法选择,选择的标准,平衡解压时间和压缩收益
- 压缩时机选择,在AGP的任务中有哪些可以选择的时机以及各自的利弊
- 分块,主要优化解压时间,需考虑压缩收益损耗
- 分组支持
- 压缩信息写入,通过Dex字节码直接写入优化解析的时间
在运行时:
- 解压部分基本和压缩部分对应,包括读取分组信息,增量解压,多线程并发解压等
- 加载则相对复杂,详细阐述了SO加载的原理,SO依赖加载的问题和解决方案
Again,项目已经开源到github, 代号Nano,欢迎大家Star和PR。