Android包体积优化方案-动态资源管理系统
动态资源管理系统是货拉拉目前使用的用于管理离线so、动画资源、字体文件的组件,对于减小包体积起着重要作用。具体使用方法参考开源代码中介绍(github.com/HuolalaTech/hll-dynamic-res-plugin )
前言
随着公司业务的扩展,货拉拉用户端apk包的体积也不断变大,过去一年,用户端android组进行了大量的瘦身工作,取得了较为显著的成果。再使用常规方法,,已经很难优化包体积了。
我们可以把一些使用频率相对较低的资源不打包进apk,并在需要时下载到本地(例如动画文件,字体,zip压缩包,so库等)
我们注意到,货拉拉用户端apk中,使用了35个以上的so库,并且都支持arm64-v8a和armeabi-v7a这2种abi,结果就是so体积成倍上涨。用户端生产环境下的apk,解压缩后,存放so包的lib目录,占据了整个应用41%的大小。
因此动态资源管理系统是下一个优化的重点,动画,字体和zip包只是普通文件,完全可以支持动态下载并使用。而so文件本质上就是一种可动态加载并执行的文件,将 so文件动态下发是切实可行的,但是要将它从 apk中剔除并保证稳定性并不是一件易事。
# 行业方案
未找到现成的github项目或者三方sdk方案,来实现动态资源管理。
部分博客提供了动态管理so文件的思路,但是缺少完整流程。
行业目前并未提供完整的成熟方案供我们使用,需要我们自己造轮子。
# 功能和方案
## 实现功能
资源分类,预定义了字体,帧动画,so这3种内置资源,以及单个文件,多个文件这2种可自定义资源。
提供通用的加载动态资源方法,所有资源均可由此加载。
内置资源,提供默认的应用方法,外部可以直接应用。自定义资源,用户自行决定如何应用。
对于所有资源,提供可配置的方便快捷打包方式,减少手动操作。
几个概念
资源加载:将动态资源通过下载,校验,解压等方式,映射到本地文件的过程。
该过程对所有资源通用,sdk使用方无需修改资源加载方式。
资源应用:动态资源对应的本地文件应用到具体业务中。例如动态字体资源的应用,就是为TextView设置一个新的字体。
该过程每个资源不同,sdk使用方无需修改内置资源的应用方式,对于自定义资源,需要使用方自行决定应用方式。
资源打包:包括生成一个待上传的资源文件,以及生成资源的Java描述(DynamicPkgInfo类)。so资源还包含了一些方法的hook操作。
该过程对所有资源都适用,统一使用可配置的dyanmicresresplugin插件完成。sdk使用方无需修改资源打包方式,但是可通过配置dyanmic_plugin.gradle文件,配置打包过程。
## 通用资源加载
如何确定资源已经下载过了,避免重复下载?
Java代码中,使用DynamicPkgInfo类来描述资源,该类中包含了资源的版本号。我们比较该类和本地数据库中的资源版本号,如果不同,才会下载资源。
下载资源是否提供多线程下载,断点续传等功能?
本sdk只提供了下载接口,未提供实际下载功能,因此如需这些功能,需要调用者自己实现。
如何校验资源,防止被篡改?
DynamicPkgInfo类中包含了资源校验信息,我们利用该类,对下载好的文件进行md5码,文件长度,文件名称的校验。
如何判断资源是否压缩包,以及如何解压缩?
目前简单的采用后缀名是否为.zip判断,使用使用Java内置java.util.zip包下工具解压。
如何校验解压后的资源子文件,防止被篡改?
DynamicPkgInfo同样包含了zip包中所有子文件的校验信息,我们利用它,来校验所有解压后的文件。
资源应用
字体资源应用,从加载好的本地文件中,创建系统Typeface字体对象,并设置到TextView上。
帧动画资源应用,从加载好的本地文件中,创建系统AnimationDrawable帧动画对象,并设置到ImageView上。
字体和帧动画资源的应用流程,见第5章,内置资源应用流程。
so资源应用流程,见第7章,so资源加载和应用解决方案。
自定义资源的应用,需要sdk使用者自己定义。
## 资源打包
我们使用dynamic_plugin gradle插件来完成所有资源的打包。
### 字体资源打包
扫描输入目录字体文件,将他们拷贝到输出目录。
为每个字体生成一个DynamicPkgInfo类的常量,代表该动态资源。
### 帧动画资源打包
扫描输入目录帧动画文件夹,将它们逐个压缩,并将压缩包输出到指定目录。
为每一组帧动画生成一个DynamicPkgInfo类的常量,代表该动态资源。
### so资源打包
Hook系统System.loadLibrary方法的调用。
系统打包流程中,删除配置文件指定so文件,并将他们拷贝到指定目录。
扫描上面的so文件目录,将他们逐个压缩,并将压缩包输出到指定目录。
为每一个so压缩包生产一个DynamicPkgInfo类的常量,代表该动态资源。
### 自定义资源打包
单个文件的资源打包同字体资源
多个文件的资源打包同帧动画资源
### 运行产物
下图为该打包插件运行一次之后的产物。
input目录,所有待打包资源的存放目录,我们需要手动把要打包的资源拷贝这里,例如字体文件拷贝到input/typeface目录下。注意so资源会在打包过程中,自动生成,无需手动处理。
output目录,则是打包出来的产物,包括字体资源,so资源,帧动画资源等,我们可以手动将此目录下的打包后资源上传到服务器。
DynamicResConst.java文件,该文件中生成了所有资源的信息。
DynamicResConst.java文件的内容,我们在这里也稍微看一下,图中为字体资源和帧动画资源的java描述。可以看到所有动态资源,都用DynamicPkgInfo类来描述。
单个文件资源,包含了资源的id,文件名称,资源类型,下载地址,版本号,文件长度以及md5码。
多个文件资源,除了包含上述信息外。还包含了该压缩包解压后,里面每个文件的名称,文件长度以及md5码
整体架构
由于整个系统功能较复杂,我们将其分为3个module。
dynamicresbase:只包含md5,压缩解压等通用操作以及代表资源的实体类DynamicPkgInfo,该module为后面2个module的基础。
dynamicrescore:提供了资源的加载和应用功能,目前包含字体资源,帧动画资源,so资源以及自定义资源。
dynamicresplugin:为一个gradle plugin工程,提供了资源打包功能。
dynamicrescore模块架构
该库包括了动态资源加载和应用全过程,我们分为5层实现
外部接口层,主要为加载管理器和加载监听器,提供了所有外部的接口。
资源应用层,封装了几种内置动态资源的应用,字体资源,帧动画资源,so资源。
加载流程层,具体完成了资源的加载过程,主要采用状态模式实现,包括一个状态管理器,以及各种状态,例如检查本地版本状态,下载状态,校验文件状态等。
接口隔离层,主要是一些功能接口,例如下载功能,解压缩功能,上报功能等,隔离了底层实现。
具体实现层,各个具体功能的实现,例如数据库操作,java zip库等。
## dynamicresplugin插件架构
系统插件层,主要为系统gradle plugin的实现,以及对dynamic_plugin.gradle配置文件的读取和解析
任务模块层,包含了各个任务,例如删除并拷贝so文件任务,压缩zip包任务等。
底层实现层,包含了具体功能的实现,例如asm框架和transform api,zip压缩,javepoet代码生成等。
# 通用资源加载,内置资源应用流程
## 通用资源加载主流程
加载普通资源的主流程如下,首先判断资源包指定版本号和本地数据库版本号是否相同,如果想同,进入本地资源校验流程,否则进入下载流程。
下载校验解压流程
我们首先调用下载接口下载资源。
如果下载成功,我们校验下载文件,下载失败,则尝试删除文件,并直接跳到失败结果。
校验下载文件成功,我们在判断是否为zip文件,对于zip文件,我们执行解压缩操作,非zip文件,直接成功。
解压缩完成后,我们在对解压后的所有文件执行校验操作。
本地资源校验流程
对于下载并解压的压缩包资源,以及本地数据库版本和资源实体类版本号相同的资源,我们需要进行本地资源校验流程。
遍历资源包指定的字文件列表,对他们进行逐个文件检验就可以了。
## 单个文件校验流程
资源实体类中指定的文件名称,文件长度,文件md5码和本地文件相同时,我们认为该文件校验成功了
## 加载恢复流程
动态资源加载过程中,可能因为各种原因,导致加载未能得到成功或者失败的结果,而在中间状态被中断,如应用进程被杀死,手机关机等等。为了避免加载意外中断的情况下,完全从头开始进行加载,我们设计了一个动态资源加载的恢复流程,如果异常中断,我们下次加载资源时,可以恢复到当前状态,继续进行加载。
下载过程的恢复和断点续传,需要下载接口的实现者负责。
其他状态,我们在状态改变时,将资源id,当前状态和待处理文件路径,保存到数据库。
每次加载动态开始时,根据资源id查找数据库中是否有待恢复数据。
有待恢复数据,转到待恢复的状态,否则,直接去检查版本号状态。
资源加载成功或者失败时,从数据库中删除当前资源id对应的恢复状态。
## 内置资源应用流程
前面我们总结了动态资源的加载流程,资源加载完成后,我们还需要将该资源进行应用,而这里我们要说的就是将动态资源应用到对应View上的流程。
根据资源id,从缓存中获取动态资源对应的本地文件。
文件获取成功,直接设置到view上,获取失败,进入下一步。
参数列表中的占位资源不为空,则将占位资源设置到View上。
将资源id设置到View的tag上,尝试清除上次动态资源加载失败状态。
使用管理器Manager类的load方法,执行之前的加载流程。
异步等待加载完成回调,判断资源id是否和View的tag相同,防止view被复用,导致的资源错乱情况。
如果Activity没有被销毁,则将资源设置到View上。
# dynamicrescore模块类设计
可与第4章,整体架构分层图对照着看
### 外部接口层
DynamicResManager类负责和外部交互,提供了初始化(init),加载资源(load),isResReady(判断资源是否就绪),clearFailState(清除错误状态等方法)等方法。
Config类,则可以向管理器提供线程池,下载器接口,本地资源信息接口,本地资源状态接口等配置信息。
AbsResInfo抽象类,代表动态资源。
DynamicPkgInfo类,AbsResInfo的子类,提供给外部使用,代表了一个动态资源实体。
DynamicPkgInfo.FileInfo,AbsResInfo的子类,资源实体内部类,代表了资源中的一个子文件。
DynamicPkgInfo.FolderInfo,AbsResInfo的子类,资源实体内部类,代表了资源中的一个子文件夹。
ILoadResListener接口,提供了加载资源时的回调功能,会回调加载成功,失败,状态变化,下载中进度
### 资源应用层
AbsResApply抽象类,实现了动态资源在ui元素上的应用。
TypefaceResApply类,AbsResApply的子类,代表了字体资源的应用。
FrameAnimApply类,AbsResApply的子类,代表了帧动画资源的应用。
AbsSoLoad抽象类,实现了so动态资源的应用。
RelinkerSoLoad类,AbsSoLoad的子类,使用Relinker第三方库最终load so库。
SystemSoLoad类,AbsSoLoad的子类,使用系统System.loadLibrary方法最终load so库
### 加载流程层
我们使用状态模式来控制整个动态资源的加载流程。
IState,状态接口,代表了加载流程中的一个状态。
InitState类,初始化状态。
CheckVersionState类,检查资源实体类版本号与数据库版本号是否相同状态。
DownloadState类,下载资源状态。
VerrifyFileState,校验下载资源状态。
UnZipState,解压缩下载资源状态。
VerifyZipState,校验解压后的所有文件状态。
IStateMechine,状态管理机接口,负责管理前面所有的IState对象。
DefaultStateMachine类,状态管理机的默认实现。
ResCtx类,状态管理机运行过程中的全局context对象,存储了路径信息,加载成功信息,加载失败异常等全局信息。
### 接口隔离和具体实现层
这2层的类,较为杂乱,限于篇幅,我们就不一一列举了。
### 类uml图
#
# so资源动态化方案
## so资源打包问题
在打包so资源的过程中,我们遇到了如下问题。
如何移除apk中的so文件,并将他们收集起来?
如何将多个so文件压缩打包,并生成对应的信息?
如何保证第三方sdk缺少so文件时,不崩溃?
## so资源打包解决方案
移除并收集apk中的so文件
看到移除 so文件可能有些同学会问,这不是只要在as中删除libs目录就搞定了么?这样会有几个问题
对于多个module的工程,我们需要逐个删除每个module下的libs目录,麻烦而且容易出错。
对于三方aar包中的so文件,我们就没法删除了。
so文件变化需要人工维护,容易出错。
出于以上考虑,我们认为,在编译时期,自动删除并收集so文件是最优解,那么在编译时期进行以上操作呢?我们注意到as在进行build时,会有大量的系统提供的task在运行,那么这些系统task是否就完成了编译并收集各个地方的so文件,并把他们打包进apk的任务呢?
看一眼这幅超级复杂的apk构建流程图,嗯,可以看到,系统确实会在apkBuilder构建前,将本地的c/c++文件编译成so库,并将第三方的so库一起打包到apk中,我们需要寻找的就是收集所有so库的系统Task
通过查找资料,我们发现,确实有2个系统task会用来处理合并so库并且删除debug符号(注意,task名称可能与此处不完全相同)。
| Task名称 | 实现类 | 作用 | 结果保存目录 |
|---|---|---|---|
| mergeDebugNativeLibs | MergeNativeLibsTask | 合并所有依赖的 native 库 | intermediates/mergednativelibs |
| stripDebugDebugSymbols | StripDebugSymbolsTask | 从 Native 库中移除 Debug 符号 | intermediates/strippednativelibs |
| |||
| |||
| |||
| |||
| |||
| 由于我们有多个gradle task需要执行,因此我们创建了一个名为dynamicresplugin的android plugin工程,内部包含了多个gradle task。关于as中新建插件的方法,请自行搜索其他博客,本文因为篇幅问题,不进行讲解。 |
在我们的dynamicresplugin插件内部,我们新建一个名为DeleteAndCopySo的gradle task并将它插入到系统的merge和strip之间,利用该Task完成删除mergednativelibs目录下对应so文件,并将其拷贝到我们指定的新目录下。这样apk打包时,就不会包含动态化的so文件了
//获取系统的mergeTask
Task mergeNativeTask =TaskUtil.getMergeNativeTask(project);
//获取系统的skipTask
Task stripTask =TaskUtil.getStripSymbol(project);
//创建我们的DeleteAndCopySo task
Task deleteTask = project.getTasks().create(PluginConst.Task.DELETE_SO);
deleteTask.doLast(newAction<Task>(){
@Override
publicvoid execute(Task task){
deleteAndCopySo(project, param);
}
});
//将我们的Task插入到merge和strip之间
stripTask.dependsOn(deleteTask);
deleteTask.dependsOn(mergeNativeTask);
### 如何将多个so文件压缩打包,并生成对应的信息?
上一步中,我们已经将so文件从系统apk构建流程中删除,并且拷贝到了指定目录下。那么现在我们应该做什么呢?
将so文件打包成.zip压缩包。
生成该资源对应的实体类DynamicPkgInfo。包括文件id,文件名称,文件类型,版本号,下载地址等基本信息,以及文件md5,文件长度等校验信息。以及压缩包下的所有子文件及文件夹相关信息。
将该zip文件上传到服务器,以方便下载和使用。
对于上述这些步骤,在我们的货拉拉动态管理系统初始版本中,我们采用了自己打zip包,自己写java代码来生成资源信息的方式。
但是在后来的使用过程中,我们发现,手动进行这些步骤,很繁琐且容易出错,我们需要有一种自动化的方式进行上述过程。
我们在dynamicresplugin插件内部,再新增一个ZipSoTask来进行压缩so文件夹,以及生成资源信息常量的操作。该task在DeleteAndCopySo之后,stripe系统task之前执行。
//执行将so文件夹压缩成.zip操作
List<DynamicUtil.ZipInfo> zips =DynamicUtil.zipFolder(newFile(param.getmInputSo()),outDir);
//根据so文件和zip压缩包信息,生成md5,length等校验信息并存储
DynamicUtil.createPkgDatas(mPkgList,zips,PluginConst.Type.SO);
//根据资源信息类生产java文件
param.getmFileCreate().createFile(mPkgList,param);
前2步,压缩so文件,和根据so文件,zip文件生成校验信息并存储比较简单,就不详细说了。
第3步,根据前面的信息,直接生产java文件,我们使用了第三方的开源库javapoet。
JavaPoet 是 Square 公司推出的开源 Java代码生成框架,提供接口生成 Java 源文件。这个框架功能非常有用,我们可以很方便的使用它根据注解、数据库模式、协议格式等来对应生成代码。通过这种自动化生成代码的方式,可以让我们用更加简洁优雅的方式要替代繁琐冗杂的重复工作。
大致的生产代码如下,首先生成一个DynamicResConst类,之后遍历zip压缩资源列表,为列表中的每一个资源,生成一个static final的常量,表示每个资源,最后生成java文件。
//创建DynamicResConst类,用来存储资源实体常量
TypeSpec.Builder typeBuilder =TypeSpec.classBuilder("DynamicResConst")
.addModifiers(Modifier.PUBLIC,Modifier.FINAL);
//遍历资源列表,生成对应实体类DynamicPkgInfo
for(DynamicPkgInfo pkg : pkgs){
FieldSpec fsc = createField(pkg);
typeBuilder.addField(fsc);
}
//插件java文件,并写入
JavaFile javaFile =JavaFile.builder(param.getmCreateJavaPkgName(), typeBuilder.build()).build();
try{
javaFile.writeTo(newFile(param.getmOutputPath()));
}catch(Exception e){
}
至于最后一步,将so压缩包上传到服务器,我们在配置文件中提供了一个上传方法,不过默认实现为空,用户可以手动上传也可以修改默认方法实现自动上传。自动生成的资源文件中,版本号需要手动修改控制,下载地址手动上传的话,也需要手动修改。
保证第三方sdk在缺少so文件时,不崩溃
很多三方sdk都要求在应用启动时,进行初始化,一个使用so库的类的典型类代码如下:
publicclassThirdLib{
//静态方法加载so库
static{
System.loadLibrary("third");
}
//native方法示例
publicnativevoid testNative();
//java方法示例
publicvoid test();
//......其他内容省略
}
如果此时so库没有被加载好,直接使用ThirdLib类,则会执行static代码段中的System.loadLibrary方法,导致UnsatisfiedLinkError的错误,造成App崩溃。由于我们无法直接修改第三方sdk的源码,因此我们只能采用动态字节码技术,替换掉System.loadLibrary方法了。
我们采用android的transform加asm技术,动态的将System.loadLibrary替换成我们自己的SoLoadUtil中的loadLibrary方法。
Gradle Transform 是 Android 官方提供给开发者在项目构建阶段,即由 .class 到 .dex 转换期间修改 .class 文件的一套 API, 无论是class还是jar都可以控制。
ASM是一种通用Java字节码操作和分析框架。它可以用于修改现有的class文件或动态生成class文件。
具体执行替换的代码如下,在Asm框架中的MethodVisitor类中,重写visitMethodInsn方法,判断该方法的拥有者,名称和参数列表和System.loadLibrary对应,则我们将他替换为我们的SoLoadUtil.loadLibrary方法
@Override
publicvoid visitMethodInsn(int opcode,String owner,String name,String descriptor,boolean isInterface){
if(TextUtil.equals(owner,PluginConst.SYSTEM_CLASS)&&
TextUtil.equals(name,PluginConst.LOAD_LIBRARY_METHOD)&&
TextUtil.equals(descriptor,PluginConst.LOAD_LIBRARY_DESC)){
owner ="com/xxx/xxx/dynamicres/util/SoLoadUtil";
mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, name, descriptor,false);
return;
}
mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
}
替换后的方法主要逻辑为,使用第三方库Relinker替代System.loadLibrary方法进行so文件加载,并且catch住加载异常,来防止应用直接奔溃,并且在加载so库异常时,将该库的名称保存下来,在我们的so包被正常下发加载后,再次调用本方法,将so库load到系统中。
protectedvoid realSoLoad(Context c,String libName){
try{
ReLinker. recursively ().loadLibrary(c, libName);
removeFormWaitList(libName);
}catch(Throwable t){
addToWaitList(libName);
}
}
对于so库没有加载完成,直接使用ThirdLib类导致System.loadLibrary方法被调用,导致的应用崩溃问题,我们已经解决了。
而对于直接调用ThirdLib类的testNative方法,导致的应用崩溃问题,则无法解决。因此需要看情况决定是否能够接受该种崩溃,以及是否将引发该问题的so库进行动态化。
我们只需要在工程的主Application中,直接调用loadSo方法,对so动态资源进行加载。加载完成后,so库就能正常使用了。
publicvoid loadSo(DynamicSoInfo soInfo,ILoadSoListener listener){
if(soInfo ==null){
return;
}
//根据本机abi,获取适合的动态资源实体类DynamicPkgInfo
DynamicPkgInfo pkg = soInfo.getPkgInfo(Build.SUPPORTED_ABIS);
if(pkg ==null){
return;
}
//如果该so资源,已经被加载缓存过了,直接listener的成功回调,并返回
if(isLoadAndDispatchSo(pkg, listener)){
return;
}
//开启资源加载,和普通资源流程一致
DynamicResManager manager =DynamicResManager.getInstance();
manager.load(pkg,newDefaultLoadResListener(){
@Override
publicvoid onSucceed(LoadResInfo info){
super.onSucceed(info);
//so成功下载校验后,执行加载逻辑
handleLoadSoSucceed(pkg, info, listener);
}
});
}
## so资源加载和应用问题
在so资源的加载和应用过程中,我们发现了如下问题
如何判断系统需要哪些so文件,并按需正确加载?
如何下载so文件,并保证它的正确性?
如何将下载的动态so文件,正确应用到系统中?
## so资源加载和应用解决方案
### 如何判断系统需要哪些so文件,并正确下载安装?
我们把arm64-v8a,armeabi-v7a等abi分开打包,上传到服务器。使用时,本地判断abi支持,下载对应的abi包。这样做的优点是节省流量和下载后占据的空间。
至于判断系统需要哪些abi的so包,并按需正确应用,则比较简单,读取系统的SUPPORTED_ABIS常量,这里包含了系统支持的abi列表,而排在前面的表示优先级更高。我们只要遍历它,然后查找我们的动态资源包是否有匹配,就达到了正确加载的目标。
privateMap<String,DynamicPkgInfo> mSoInfos;
publicDynamicPkgInfo getPkgInfo(){
//获取本地系统支持的abi列表
String[] supportAbis =Build.SUPPORTED_ABIS;
if(supportAbis==null|| supportAbis.length==0){
returnnull;
}
//遍历abi支持列表
for(String abi : supportAbis){
//从so动态资源中,查找对应的abi信息
DynamicPkgInfo pkg = mSoInfos.get(abi);
//找到则直接返回该信息
if(pkg !=null){
return pkg;
}
}
returnnull;
}
### 如何下载so文件,并保证它的正确性?
复用通过资源加载流程即可。
### 如何将下载的动态so文件,正确应用到系统中?
这里需要首先了解一下,系统加载so库的工作流程,当我们调用 System#loadLibrary("xxx" ) 后,Android Framework 都干了些了啥?Android 的 so 加载机制,大致可以分为以下四个环节。
安装 APK 包的时候,PMS 根据当前设备的 abi 信息,从 APK 包里拷贝相应的 so 文件。
启动 APP 的时候, Android Framework 创建应用的 ClassLoader 实例,并将当前应用相关的所有 so 文件所在目录注入到当前 ClassLoader 相关字段。
调用 System.loadLibrary("xxx"), framework 从当前上下文 ClassLoader 实例(或者用户指定)的目录数组里查找并加载名为 libxxx.so 的文件。
调用 so 相关 JNI 方法。
而我们这里,由于so文件不存在于apk当中,而是需要动态下载,所以我们显然不能直接使用系统的System.loadLibrary方法加载so文件。
而动态加载so的方法,在热修复和插件化框架中,已经比较成熟了,我们参考了市面上的开源框架后,选择了腾讯的Tinker框架的加载方案,即使用反射classloader 将 so 包的路径写入 nativeLibraryPathElements 数组最前面,其流程图和解释如下图所示 。注意,此方法不同的android版本将有不同的实现。下面示例代码基于android9.0版本。
privatestaticvoid install(ClassLoader classLoader,File soFolder)throwsThrowable{
Field pathListField = findField(classLoader,"pathList");
Object dexPathList = pathListField.get(classLoader);
Field nativeLibraryDirectories = findField(dexPathList,"nativeLibraryDirectories");
List<File> libDirs =(List<File>) nativeLibraryDirectories.get(dexPathList);
libDirs.add(0, soFolder);
Field systemNativeLibraryDirectories =
findField(dexPathList,"systemNativeLibraryDirectories");
List<File> systemLibDirs =(List<File>) systemNativeLibraryDirectories.get(dexPathList);
Method makePathElements =
findMethod(dexPathList,"makePathElements",List.class);
libDirs.addAll(systemLibDirs);
Object[] elements =(Object[]) makePathElements.
invoke(dexPathList, libDirs);
Field nativeLibraryPathElements = findField(dexPathList,"nativeLibraryPathElements");
nativeLibraryPathElements.setAccessible(true);
nativeLibraryPathElements.set(dexPathList, elements);
}
pathList变量:DexPathList类的实例。
nativeLibraryDirectories列表:包含了本App自带so文件的查找路径(如data/app/包名/lib/arm64)
systemNativeLibraryDirectories列表:包含系统so文件查找路径(如system/lib64)
makePathElements:系统使用此方法,为所有so文件,生成对应的 NativeLibraryElement对象
nativeLibraryPathElements数组:系统用来存储所有的so文件路径
当外界调用System.loadLibrary方法时,系统最终会调用到DexPathList类的findLibrary方法,该方法会在nativeLibraryPathElements数组中查找对应的路径,我们将自己的so加入到nativeLibraryPathElements最前面,由此达到动态加入so的目标。
# so资源动态化的tips
为何要使用Relinker加载So文件
假如我们有2个so文件,libA.so 和 libB.so,libA依赖libB,则当我们调用System.loadLibrary("libA") 的时候,android framework 会通过上面提到的调用链最终通过 dlopen 加载 libA.so 文件,并接着通过其依赖信息,自动使用 dlopen 加载 libB.so。
在 Android N 以前,只要将 libA.so 和 libB.so 所在的文件目录路径都注入到当前 ClassLoader 的 nativeLibraryPathElements 里,则在加载 so 插件的时候,这两个文件都能正常被找到。
从 N 开始,libA.so 能正常加载,而 libB.so 会出现加载失败错误。
因为Android Native 用来链接 so 库的 Linker.cpp dlopen 函数 的具体实现变化比较大(主要是引入了 Namespace 机制):以往的实现里,Linker 会在 ClassLoder 实例的 nativeLibraryPathElements 里的所有路径查找相应的 so 文件。
更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libA.so 文件能找到,而 libB.so 找不到的情况。
至于 Namespace 机制的工作原理了,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合)。
解决该问题有如下几种思路:
自定义 System.loadLibrary,加载 SO 前,先解析 SO 的依赖信息,再递归加载其依赖的 SO 文件,这是开源库soLoader的解决方案。
自定义 Linker,完全自己控制 SO 文件的检索逻辑 ,这是开源库Relinder的解决方案。
替换 ClassLoader 。
本着不重复造轮子的原则,项目中使用了Relinker开源库,用来加载so文件。
## so库依赖分析工具
想要把 so 动态化技术应用到 APK 的瘦身项目中来,除了分析哪些 so 文件体积占比比较大之外,最好的做法是将其依赖的所有 so 文件一定挪到插件包里。怎么了解 APK 里所有 so 文件具体的依赖信息呢?这里推荐一款 Google 开源的 APK 解析工具android-classyshark,除了提供分析 APK dex/so 依赖信息之外,它还提供了 GUI 可视化界面,非常适合快速上手。
# so动态化流程
## so资源应用流程
获取系统支持abi列表,根据该列表,找到合适的so动态资源实体类。
如果该资源已经被加载缓存,则直接回调加载成功。
否则,开始资源通用加载流程,并异步等待资源加载成功(流程见第5章)。
再次判断下载校验后的资源,是否支持本机abi。
将so包路径加入DexPathList的数组头部。
遍历等待加载so列表,尝试加载所有so文件,并将成功加载的so文件,移除该列表。
将资源id和本地路径加入缓存,防止so被重复加载。
回调加载完成监听器。
## SoLoadUtil.loadLibrary方法流程
从上一章我们知道,我们会使用transform api加asm框架,将系统的System.loadLibrary方法替换成我们的SoloadUtil.loadLibrary方法。我们替换系统方法的目的。一个是为了保证so库不存在时,程序不崩溃,另外一个就是so库下载校验完成后,能自动完成之前失败的加载,为此,我们设计了如下流程。
其他方法调用到我们的SoloadUtil时,我们判断我们的加载系统是否初始化完成
已完成,则调用Relinkder库尝试加载so文件,未完成则将该so库加入待加载队列中。
如果Relinker加载so文件成功,我们从待加载队列中移除so,并且完成本次加载。
否则我们依然将so文件加入待加载队列中。
根据上面的so加载流程,当so动态资源真正下载校验完成后,我们会遍历待加载队列,并完成所有之前未成功的so库加载。
# dynamicresplugin插件流程
## 整体流程
前面我们已经分析了通用资源加载,内置资源应用,完成了动态资源管理系统的主要部分。只剩下资源打包部分了,而所有资源的打包操作,都由dyanmicresplugin插件来完成。为了完成打包功能,我们决定在这个dynamicresplugin插件内部,新建3个Task。
Hook System.loadLibrary方法的TransformTask。
系统打包流程中,删除并拷贝so文件的DeleteAndCopySoTask。
压缩so资源和其他多个文件资源(例如帧动画)的ZipResTask。
为每个动态资源生成其对应的DynamicPkgInfo常量的功能,仅实现为一个普通方法。
所以主流程也就出来了
读取并解析dynamic_plugin.gradle配置文件。
根据配置信息,决定是否将3个task加入任务队列。
启动任务队列。
## TransformTask流程
该task流程,主要就是通过tranform api和asm框架的使用,我们在其中加入了扫描class范围的可配置项。
等待asm框架扫描class。
判断该class名称是否在我们配置的替换列表中,如果不在,就直接返回。
创建ClassVisitor和MethodVisitor,等待asm框架扫描每个方法。
如果该方法的名称,参数列表和调用者,都和System.loadLibrary方法相符合。
我们替换为自己的SoloadUtil.loadLibrary方法。
## DeleteAndCopySoTask流程
根据配置文件,找到系统的merge和strip task。
将我们的task插入到2个系统task之间,并等待系统回调我们的doLast方法。
遍历系统的mergeTask的输出目录,判断该so文件是否在我们配置的待扫描列表中。
如果配置了需要拷贝so文件,则我们将它拷贝到指定位置。
如果配置了需要删除so文件,则我们将该so文件删除。
## ZipResTask流程
拷贝字体文件,将文件信息加入资源列表。
压缩帧动画文件,将压缩后的文件信息加入资源列表。
压缩so文件,将压缩后的文件信息加入资源列表。
压缩zip文件夹下文件,将压缩后的文件信息加入资源列表。
遍历资源文件,为其生成相应的资源实体类DynamicPkgInfo。
# dynamicresplugin插件类设计
可以与第4章,整体架构图结合起来看。
## 系统插件层
DynamicPlugin类,实现了系统gradle插件的plugin接口,为我们整个插件的入口,主要解析配置文件,并按照配置文件创建task信息。
DynamicParam类,提供了存储并解析dyanmic_plugin配置文件的方法。
## 任务模块层
ITask接口,代表了一个我们定义的任务。
DeleteAndCopySoTask,删除并拷贝so文件任务。
TransfomrTask,替换系统System.loadLibrary方法任务。
ZipResTask,压缩so和其他文件,并生成对应的java资源实体类方法。
## 底层实现层
SystemLoadClassVisitor类,Asm框架的class访问类。
SystemLoadMethodVisitor类,Asm框架的method访问类,用于替换System.loadLibrary方法。
JavaFileCreate类,使用javapoet框架产生java文件。
其他辅助类,在此省略
## 类uml图
# dynamic_config.gradle配置文件
该配置文件主要包含了配置dynamic_plugin插件运行步骤,插件输入输出路径,so文件扫描路径等信息。
dynamic_config =[
//是否执行替换System.loadlibrary操作
is_replace_load_library:false,
//是否执行替换System.load操作
is_replace_load :false,
//是否执行删除so文件操作
is_delete_so :false,
//是否执行将so文件拷贝到其他目录操作
is_copy_so :false,
//是否执行将动态资源打包,并生成java文件操作
is_zip_res :false,
//是否执行将so文件打包,并生成java文件操作
is_zip_so :false,
//是否自动上传所有资源,上传方法为dynamic_upload
is_upload_res :false,
//插件是否工作在Release模式下
is_release_type : isReleaseBuildType(),
//是否打印debug日志
is_debug_log :true,
//自动创建java文件时的包名
create_java_pkg_name :'com.test',
]
/**
* 配置要删除和拷贝的so文件
* map的key为压缩包名称,值为压缩包包含的so文件列表
* key为debug_all_test时,会压缩所有so包
*/
dynamic_scan_so_map =[
guang_dong :['libpajf.so','libpajf_av.so','libsqlite.so'],
]
dynamic_so_config =[
//so文件忽略列表,该表中的文件,不会被扫描。不在该列表中的文件都会被扫描
// (dynamic_scan_so_map为空时,本列表才生效)
ignore_so_files:[],
//so文件扫描abi目录,不在该目录下的so将不被扫描
scan_so_abis :["arm64-v8a","armeabi-v7a"],
//拷贝出来的so文件夹前缀,ignore_so_files生效时使用
so_input_prefix:'test',
]
dynamic_lib_list =[
//只有该列表中的包名,才会执行替换System.loadlibrary操作
//输入debug_all_test,则会替换所有System.loadLibrary方法,用于测试
scan_load_library_pkgs :[],
//在该列表中的包名或者类名,不会执行替换System.loadlibrary操作,和上面的配置可以同时生效
ignore_load_library_pkgs:[],
]
//该配置不要改动内容,需要改变路径的,直接改变对应的方法内容即可
dynamic_dir =[
//产生文件的输出目录
output : createOrGetOutputPath(),
//字体资源输入目录
typeface_input : createOrGetInputTypafacePath(),
//帧动画资源输入目录
frame_anim_input: createOrGetInputFrameAnimPath(),
//so文件资源输入目录
so_input : createOrGetInputSoPath(),
//zip包输入目录
zip_input : createOrGetInputZipPath()
]
//该配置项,配置了android 2个gradle task的名称
//主工程的mergeNativeLibs合并所有依赖的 native 库
//主工程的stripDebugSymbols从 Native 库中移除 Debug 符号。
dynamic_task =[
//自定义的task运行哪里
//true为mergeNativeLibs之后,stripDebugSymbols之前
//false为stripDebugSymbols之后,package之前
isTaskRunAfterMerge :true,
//debug状态下,mergeNativeLibs的task名称
debugMergeNativeLibs :"mergeDebugNativeLibs",
//release状态下,mergeNativeLibs的task名称
releaseMergeNativeLibs :"mergeReleaseNativeLibs",
//debug状态下,stripDebugSymbols的task名称
debugStripDebugSymbols :"stripDebugDebugSymbols",
//release状态下,stripDebugSymbols的task名称
releaseStripDebugSymbols:"stripReleaseDebugSymbols",
//debug状态下,系统打包task名称
debugPackage :"packageDebug",
//release状态下,系统打包task名称
releasePackage :"packageRelease",
//debug状态下,mergeNativeLibs的输出目录
debugNativeOutputPath :" $ { projectDir }/app/build/intermediates/merged_native_libs/debug/out/lib",
//release状态下,mergeNativeLibs的输出目录
releaseNativeOutputPath :" $ { projectDir }/app/build/intermediates/merged_native_libs/release/out/lib",
//debug状态下,,stripDebugSymbols的输出目录
debugStripOutputPath :" $ { projectDir }/app/build/intermediates/stripped_native_libs/debug/out/lib",
//release状态下,,stripDebugSymbols的输出目录
releaseStripOutputPath :" $ { projectDir }/app/build/intermediates/stripped_native_libs/release/out/lib",
]
//该闭包可以自动将文件上传到服务器,参数列表为资源id,资源文件路径
//我们可以再次执行上传服务器操作,并返回对应的url。
//当然也可以不实现上传操作,并自己手动上传资源。
dynamic_upload ={
id, path ->
println("dynamic_upload id $ { id } ,path $ { path }")
return'http://url'
}
# 优化效果
通过引入动态资源管理系统,并将一键报警sdk相关的so文件和其他普通资源动态化后,货拉拉用户端的包体积减少了8M,从54M变为了46M。后继将会继续尝试进行其他so文件的动态化。
# 参考文献
https://www.jianshu.com/p/260137fdf7c5
https://mp.weixin.qq.com/s/X58fK02imnNkvUMFt23OAg