Gradle 4.0 jar包assets资源丢失原因分析

概述

在gradle升级到4.0.2时遇到了release包构建成功,并且在构建过程中无异常提示,但是百度地图BaiduLBS_Android.jar中assets下文件丢失,导致地图相应功能丢失,app崩溃情况。在解决问题的过程中,耗费时间较长,一度以为是混淆问题,因为debug包没问题,并且release在打开R8的情况下,也可以解决,gradle升级到4.1也可以解决。但是具体原因是什么呢?到底是混淆问题,还是其他原因,带着这个问题我们分析下第三方jar包assets下资源文件是如何打包到apk中的。

总流程

第三方jar包assets下资源文件构建总流程如下:

主要是分为以下几步:

  1. jar包中的assets下资源文件在生成aar后,不是在aar的assets目录下,而是在classed.jar的assets目录下,同时aar中libs下的jar包,不在包含assets目录,只有源码目录文件。
  2. 在构建过程中,通过mergeReleaseJavaResource合并java资源Task解析classes.jar并生成out.jar文件,out.jar中包含所有的非res文件,以及源码目录非claaes文件。例如:out/com/baidu/pano/platform/res/indoor_in.png
  3. 在minifyReleaseWithProguard的Task中,out.jar会被解析重新生成一个minified.jar文件,这个minified.jar文件不仅包含了out.jar内容,也包含了所有的classes文件。
  4. 在最后打包packageRelease的Task中,读取minified.jar文件中的assets目录文件,直接add到apk中。

PackageRelease 分析

先看下如下构建时序图:

  1. run:主要读取文件信息,manifest、dex、javaRes、assets、res、so、……等等。
  2. updateFiles:更新所有存档中所有新的、更改的和删除的文件,也就是dex、android res、java res、assets、so等等。
  3. addFiles:写入andoid 资源文件,和java资源文件。

在最后一步我们看到有两种文件处理方式:那么4.0版本我们用的是ApkZFileCreator,4.1版本我们用的是ApkFlinger。我们还可以通过设置android.useNewApkCreator配置决定使用哪个打包工具,值为true用ApkFlinger,否则用ApkZFileCreator,并且android.useNewApkCreator默认值为true,那么我们在4.0版本应该使用的是否则用ApkZFileCreator,然后我们跟踪一下4.0源码:

1
2
3
4
5
if (!apkFormatIsFile || !debuggableBuild) {
mApkCreatorType = ApkCreatorType.APK_Z_FILE_CREATOR;
} else {
mApkCreatorType = apkCreatorType;
}

我们可以发现,无论我们如何,release构建都是用的ApkZFileCreator,debug用的是ApkFlinger。然后我们在看一下4.1源码:

1
2
3
4
5
if (!apkFormatIsFile) {
mApkCreatorType = ApkCreatorType.APK_Z_FILE_CREATOR;
} else {
mApkCreatorType = apkCreatorType;
}

我们可以发现,无论我们如何,release构建都是用的ApkFlinger。

通过以上比较,和通过4.1进行构建打包,发现4.1是正常的,所以我们定位到4.0构建打包jar下assets目录文件和使用ApkZFileCreator文件有关。

ApkZFileCreator分析

下面的源码片段展示了写入的主要逻辑,分为如下 3 步:

  1. 创建 ZFile 对象,读取 zip 文件将 central directory 中的每项加入到 entries 中;
  2. 遍历 ZFile 中的 entries,将压缩的资源文件合并到 APK 文件中;
  3. 遍历 ZFile 中的 entries,将非压缩的资源文件写入到 APK 文件中;

ApkZFileCreator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void writeZip(File zip, @Nullable Function<String, String> transform, @Nullable Predicate<String> isIgnored) throws IOException {
Closer closer = Closer.create();
try {
ZFile toMerge = closer.register(ZFile.openReadWrite(zip));

Predicate<String> ignorePredicate;
if (isIgnored == null) {
ignorePredicate = s -> false;
} else {
ignorePredicate = isIgnored;
}

// Files that *must* be uncompressed in the result should not be merged and should be
// added after. This is just very slightly less efficient than ignoring just the ones
// that were compressed and must be uncompressed, but it is a lot simpler :)
Predicate<String> noMergePredicate =
v -> ignorePredicate.apply(v) || noCompressPredicate.apply(v);

this.zip.mergeFrom(toMerge, noMergePredicate);

for (StoredEntry toMergeEntry : toMerge.entries()) {
String path = toMergeEntry.getCentralDirectoryHeader().getName();
if (noCompressPredicate.apply(path) && !ignorePredicate.apply(path)) {
// This entry *must* be uncompressed so it was ignored in the merge and should
// now be added to the apk.
try (InputStream ignoredData = toMergeEntry.open()) {
this.zip.add(path, ignoredData, false);
}
}
}
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
}

ZFile.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void readData() throws IOException {
// ...
readEocd();
readCentralDirectory();
// ...
if (directoryEntry != null) {
// ...
for (StoredEntry entry : directory.getEntries().values()) {
// ...
entries.put(entry.getCentralDirectoryHeader().getName(), mapEntry);
//...
}

directoryStartOffset = directoryEntry.getStart();
} else {
// ...
}
// ...
}

public void mergeFrom(ZFile src, Predicate<String> ignoreFilter) throws IOException {
// ...
for (StoredEntry fromEntry : src.entries()) {
if (ignoreFilter.apply(fromEntry.getCentralDirectoryHeader().getName())) {
continue;
}
// ...
}
}

在调试过程中发现读取minified.jar文件创建的ZFile中的entries中文件读取不全,没有 Java 资源文件,而在前面 IncrementalSplitterRunnable.execute 中调 PackageAndroidArtifact.getChangedJavaResources获取改变的Java资源文件时,使用ZipCentralDirectory能正常读取到Java资源文件,由此说明ZFile存在缺陷。图示如下:
ZipCentralDirectory读取minified.jar:

ZFile读取minified.jar:

ZipCentralDirectory读取minified.jar:获取changeJavaResources文件数是101377

ZFile读取minified.jar:获取entries文件数是35841

两种方式读取的文件数完全不一样。

而且ZFile注释中所述,它不是通用的 zip 工具类,对 zip 格式和不支持的特性有严格的要求;它在某些特殊条件下存在限制,可能会出现读取文件缺失等问题

1
2
* <p>Because {@code ZFile} was designed to be used in a build system and not as general-purpose zip
* utility, it is very strict (and unforgiving) about the zip format and unsupported features.

那么为啥开启R8就没问题了呢?看一下R8的流程图:可以看到开启R8后,生成的java 资源文件是shrunkJavaRes.jar,这个shrunkJavaRes.jar文件和minified.jar相比仅仅包含了out.jar内容,不包含classes文件。

总结

  1. 4.0版本构建release包不管如何设置打包方式,都是执行的ApkZFileCreator;
  2. 4.0版本构建debug包,执行的新打包方式ApkFlinger;
  3. 4.1版本构建release包,可以通过android.useNewApkCreator设置打包方式(ApkZFileCreator/ApkFlinger),默认为true,执行ApkFlinger;
  4. 4.0版本构建release包虽然打包方式是ApkZFileCreator,但是经过了R8混淆,生成的java资源文件shrunkJavaRes.jar,并且此文件和minified.jar相比仅仅包含了out.jar内容,不包含classes文件;

参考

AGP 升级之旅