1 背景
最近贝壳APP一直在做瘦身,需要对包体大小进行分析,但刚开始Android只能分析出apk中包含哪些文件,并不知道文件来源于哪个library,更不知道library对应的维护组,那么APK瘦身项目在实施时就没有一个明确的责任划分。经过调研发现Android业内还没有一个包体按照library维度进行分析的方案,比较有名的是腾讯开源的matrix,它也只能做到按照文件类型进行包体分析,并不能到按照library的维度进行分析整个包体。
2 apk文件结构
做包体分析之前,我们先了解一下apk文件的结构,其实就是一个压缩文件,大概结构如下:
assert目录:存放我们app/src/main/assets目录下的资源文件,另外flutter的资源文件也会放在该目录下
res目录:存放我们项目的资源文件,例如:图片,xml布局,values.xml和音频等资源
lib目录:存放我们项目中所有的so文件
.dex:所有的java代码先会通过javac命令编译成.class文件,然后通过dx工具转成dex文件
resources.arsc:资源映射表,通过AndroidStudio我们可以看到apk文件中资源文件类型,id,名称和资源所在目录
其它:主要是一些java库下的META-INFO目录下的文件。
我们都知道Android打包的过程中,会把Android工程和它引用的library的代码和资源进行校验和merge,如果发生冲突那么将会打包失败,如果打包成功的话将会生成我们的apk文件,那么在这个打包过程中会不会存在一些中间文件,来记录工程中library和它包含的文件之间的映射关系呢?
通过查看所有app/build/目录下的所有文件,发现确实存在这个中间文件,记录着library下有有哪些res资源,asset资源,so文件,META-INFO文件。那么在打包过程中我们可以收集这些中间文件,并上传到我们的maven;打包完成之后触发我们的分析job,先拿到apk文件中的所有文件,然后解析收集上来的这些中间文件,这样就可以实现按照library的维度来进行包大小分析了。
3 解析中间文件
3.1 中间文件位置
这些文件全部存放在app/build/intermediates/incremental目录中:
res资源合并的映射文件: app/build/intermediates/incremental/mergeDebugResources/merger.xml
assert资源合并映射表: app/build/intermediates/incremental/mergeDebugAssets/merger.xml
so文件合并映射表:有app/build/intermediates/incremental/a_plusDebug-mergeNativeLibs/merge-state
java资源合并映射表: app/build/intermediates/incremental/debug-mergeJavaRes/merge-state
3.2 收集中间文件
如果文件路径写死,那会存在兼容性问题,通过gradle api获取的这些文件路径的话,那么就不存在兼容性问题了。
def extension_merge_state = project.extensions.getByName("android")
extension_merge_state.applicationVariants.all { variant ->
def variant_name = variant.name
//1、res资源合并的映射文件:app/build/intermediates/incremental/mergeDebugResources/merger.xml
def mergeResourcesTask = variant.getMergeResources()
mergeResourcesTask.doLast{
def mergeResFile = mergeResourcesTask.incrementalFolder.absolutePath + "/merger.xml"
println(mergeResFile)
appendFilePath("mergeResources.xml",mergeResFile)
}
//2、assert资源合并映射表:app/build/intermediates/incremental/mergeDebugAssets/merger.xml
def mergeAssetsTask = variant.getMergeAssets()
mergeAssetsTask.doLast{
def assertMergerFile = mergeAssetsTask.incrementalFolder.absolutePath + "/merger.xml"
println(assertMergerFile)
appendFilePath("mergeAssets.xml", assertMergerFile)
}
//3、so文件合并映射表,通过序列化IncrementalFileMergerState实现:app/build/intermediates/incremental/a_plusDebug-mergeNativeLibs/merge-state
def container = variant.variantData.taskManager.taskFactory.taskContainer
Task mergeNativeLibsTask = container.getByName("merge${variant_name.capitalize()}NativeLibs")
mergeNativeLibsTask.doLast {
def cache_merge_state = mergeNativeLibsTask.cacheDir.getParent() + "/merge-state"
println(cache_merge_state)
appendFilePath("mergeNativeLibs_merge_state",cache_merge_state)
}
//4、java资源合并映射表,通过序列化IncrementalFileMergerState实现 app/build/intermediates/incremental/debug-mergeJavaRes/merge-state
Task mergeJavaResourceTask = container.getByName("merge${variant_name.capitalize()}JavaResource")
mergeJavaResourceTask.doLast {
def cache_merge_state = mergeJavaResourceTask.cacheDir.getParent() + "/merge-state"
println(cache_merge_state)
appendFilePath("mergeJavaRes_merge_state", cache_merge_state)
}
}
def void appendFilePath(file_key, file_path){
String input_dir = project.buildDir.toPath().toString() + "/merge_state"
File dir = new File(input_dir)
if(!dir.exists()){
dir.mkdir()
}
def inputFile = new File(dir.absolutePath,"merge_files.txt")
if(!inputFile.exists()){
inputFile.createNewFile()
}
inputFile.append("${file_key}:${file_path}\n")
}
在打包过程中,我会给app/build.gradle文件注入上面gradle脚本,这样做的好处是省去app接入的成本。知道这些文件的路径之后,我会通过python打包脚本上传这些文件到maven中。
3.3 中间文件解析
其中merger.xml文件比较好解析,但这个merge-state是个什么文件,通过在命令行中执行 【file merge-state文件路径】命令,发现这个文件是一个持久化java序列化对象产生的文件。
那这个文件到底是哪个对象序列化生成的呢,通过用AndroidStudio打开merge-state文件,可以猜到是com.android.builder.merge.IncrementalFileMergerState序列化生成的
通过在gradle plugin项目中解析发现,这确实是com.android.builder.merge.IncrementalFileMergerState这个类序列化生成的文件。
那么问题来了,分析是在python环境中进行的,那么我们怎么去解析这个merge-state文件呢?通过了解腾讯matrix的分析包体的方案,
我们可以把解析的代码打成一个jar包,然后通过在python中执行调用这个jar的命令就可以完成解析了。
解析merge_state的代码如下:
public class MergeStateParser {
public static void main(String[] args) {
parseObject(args[0],args[1]);
}
public static void parseObject(String mergeStatePath, String outputJsonPath){
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(new FileInputStream(mergeStatePath));
IncrementalFileMergerState merge_state = (IncrementalFileMergerState) ois.readObject();
System.out.println(merge_state);
String json = new Gson().toJson(merge_state);
FileWriter fw = null;
try {
fw = new FileWriter(outputJsonPath);
fw.write(json);
fw.flush();
fw.close();
} catch (IOException e) {
e.printStackTrace();
}
}catch (Exception ex){
System.out.println(ex.getMessage());
} finally {
try {
if (ois != null){
ois.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
解析的时候还要注意,com.android.builder.merge.IncrementalFileMergerState这个类是在第三方库中的,所以需要添加以下依赖才能打出jar包
implementation "com.google.guava:guava:27.0.1-jre"
3.4 中间文件内容
merger.xml:dataSet中 config属性的value值为library的信息,每个source下的file文件就是library中的文件。当然这里解析的时候,我们需要过滤一些特殊的字符,才能拿到library的group_id,artifact_id和version。
...
下面是res资源文件解析后的结果如下:
合并res资源的过程中需要注意:
1、所有library和工程中的values文件夹下的字符串,color这些属性,最终合并成一个文件,这里没有分类,责任划分的时候最终会算到APP维护组下名下。
2、同样所有aar库和工程中的AndroidManifest.xml在打包过程中也会进行合并
merge_state:解析出来的class实例,包含三个字段、两个map和一个List。
/**
* Names of all inputs to merge, in order.
*/
private final ImmutableList inputNames;
/**
* Maps OS-independent paths to the names of the input sets that were used to construct the
* merged output.
*/
private final ImmutableMap> origin;
private final ImmutableMap> byInput;
对我们有用的是byInput字段,结构如下:library产物路径对应着多个文件
我们在keOnes(持续集成系统)中会注册这些library的信息,通过merge.xml可以拿到library的group_id和artifact_id,通过调用api接口,就可以解析出library在数据库中的名称。但是通过解析merge_state文件,我们可以拿到jar和aar在gradle缓存目录的路径,例如:
~/gradle/gradle-4.1/caches/transforms-2/files-2.1/cd6b3a2f4da4a2ecf7cedbc4998ac5b2/jetified-lib_castscreen-1.1.1
/jars/classes.jar
~/gradle/gradle-4.1/caches/modules-2/files-2.1/com.ke.crashly/collector/1.6.5
/9176c716a002a64fe90bc9787272228b362a5d4a/collector-1.6.5.jar
备注:带有jetified这种一般是aar中class.jar的路径,如果依赖对应的产物是jar包的话,那么就没有jetified。
解析这个路径只能拿到artifact_id和library版本号,通过artifact_id请求keOnes(持续集成系统)接口便可获取到library在数据库中的组件名称。
4 library与维护组对应关系维护
上面讲到通过解析gradle构建过程中生成的中间文件,可以拿到文件和library的对应关系,但这还不够,如果不知道library是谁维护的,那整个包体分析将没多大价值,为了解决这个问题,我们在keOnes(持续集成系统)中维护了library和library对应的关系,维护界面如下:
library名称:library名称一般为group_id:artifact_name
dep_name:group_id:artifact_name:{version}@[aar/jar]
针对维护组与library对应关系的维护,我们遵循一个规则:一般情况下,谁维护的library维护组就是谁,对于一些第三方库那么谁引用的就算谁,系统和公共的库属于架构组。
5 该方案在包体分析功能的落地
5.1 业务线包体大小占比
前面我们拿到了res资源、assert资源、so文件和META-INF文件与library对应的关系,那么就好办了,我们可以通过拿到apk下所有的文件,根据这个对应关系就可以分析出在apk文件中library有哪些文件了;同时在keOnes(持续集成系统)我们会记录library和维护组之间的关系,这样我们就知道每个维护组包体大小的占比。目前keOnes(持续集成系统)系统已经上线了包体分析功能,效果如下所示:
5.2 大文件分析
apk中文件总共有res资源、assert资源、so文件和其它四个大类,每个大类下面我们会按照文件的后缀进行分类,并且会跟业务的同学沟通并设置一个合理的阈值。目前在keOnes(持续集成系统)的分析效果如下:
6 遇到问题
1、通过解析merge-state文件只能分析出library的artifact_id和version,而不能获取到group_id,那么可能会造成在keOnes(持续集成系统)中找出来的library名称会存在多个。
解决办法:构建时收集项目所有runtime依赖,然后再通过artifact_id和version进行匹配,最终找到group_id,这样从keOnes(持续集成系统)获取到的library名称就唯一了。
2、针对放在工程lib目录下的jar和aar,是没有group_id和artifact_id的。
解决办法:针对这种情况,建议把这些依赖库上传到maven,手动地给这些library设定一个group_id和artifact_id,同时也要在keOnes(持续集成系统)注册这些library,这样就工程所有的library都管理起来了。
3、dex文件不能按照library维度继续进行包体的分析了,这对我们的apk瘦身也没有很大的指导意义。但我们可以分析出每个library下有多个类,多少个方法。
4、项目构建统一是在gradle5.4.1进行的,经过测试gradle6.0+也没有问题,但一些低版本的gradle构建的时候,可能会没有这些中间文件。
7 总结
要做到按照library的维度去分析包体,关键在于发现并解析资源和代码合并过程中产生的中间文件。同时也需要注册项目中library和维护组的关系,贝壳B、C两端总注册了400多个library,注册这块工作量还是不小的。有了library和维护组的对应关系之后,后面我们对外还可以提供一个精准的分流服务了,运用场景将覆盖crash精准分流、SNAPSHOT依赖检测、权限检测等。