我们 NimbleDroid 经过大量的分析,发现了一些避免 APP 整体变慢,让 APP 快速启动以及迅速响应的技巧。其中有一个就是奇慢无比的 ClassLoader.getResourceAsStream
函数,这个函数可以让 APP 通过名字访问资源。在传统的 Java 程序开发中,这个函数用得非常普遍,但是在安卓平台上,这个函数在第一次调用时执行时间非常长,会严重拖慢安卓 APP 的运行。在我们分析的 APP 和 SDK 中(我们分析了大量的 APP 和 SDK ),我们发现超过 10% 的 APP 和 20% 的 SDK 都由于使用了这个函数而急剧变慢。那究竟为什么这个函数如此之慢呢?我们将在这边文章中进行深度揭秘。
榜单 APP 中被拖慢的案例
亚马逊的 Kindle 安卓版,拥有过亿的下载量,4.15.0.48 版本中,由于使用了这个函数,导致了 1315 毫秒的延迟。
另一个例子是 TuneIn 13.6.1 版本,因此导致了 1447 毫秒的延迟。在这里 TuneIn 调用了两次 getResourceAsStream
函数,第二次调用时就很快了(只需要 6 毫秒)。
下面我们列出了受此问题影响的 APP:
在我们分析的 APP 中,有超过 10% 的 APP 都受此问题的影响。
调用了 getResourceAsStream
函数的 SDK
为了行文简洁,我们用 SDK 来指代所有的库,无论是像 Amazon AWS 这样提供特定服务的库,还是像 Joda-Time 这样更通用的库。
通常,一个 APP 不会直接调用 getResourceAsStream
函数,而是这个 APP 使用的某个 SDK 调用了这个函数。由于开发者通常不会关注使用的 SDK 的实现细节,所以他们通常都不知道自己的 APP 存在这样的问题。
下面我们列出了一些知名的调用了 getResourceAsStream
函数的 SDK:
- mobileCore
- SLF4J
- StartApp
- Joda-Time
- TapJoy
- Google Dependency Injection
- BugSense
- RoboGuice
- OrmLite
- Appnext
- Apache log4j
- Twitter4J
- Appcelerator Titanium
- LibPhoneNumbers (Google)
- Amazon AWS
总的来说,我们分析的 SDK 中,有超过 20% 的 SDK 都存在此问题,由于篇幅有限,上面的列表中我们只列出了少数较为知名的 SDK。 这个问题在 SDK 中如此普遍,原因之一就是 getResourceAsStream()
函数在非安卓平台上都是很快的。由于很多从 Java 转型的安卓开发者都使用了他们比较熟悉的库,例如使用了 Joda-Time 而不是 Dan Lew 开源的 Joda-Time-Android,因此很多 APP 都受到了这个问题的影响。
为什么 getResourceAsStream
函数在安卓平台如此之慢
发现了 getResourceAsStream
函数在安卓平台如此之慢,我们理所当然的需要分析一下它为什么如此之慢。经过深入的分析,我们发现这个函数第一次被调用时,系统会执行三个非常耗时的操作:(1) 以 zip 压缩包的方式打开 APK 文件,为 APK 内的所有内容建立索引;(2) 再次打开 APK 文件,并再次索引所有的内容;(3) 校验 APK 文件被正确的进行了签名操作。上述三个操作都非常慢,总的延迟和 APK 文件的大小呈线性关系。例如一个 20MB 的 APK 文件执行上述操作需要 1-2 秒的延迟。在附录中,我们具体描述了这个分析的过程。
建议:避免调用 ClassLoader.getResource*() 函数,而是使用安卓系统提供的 Resources.get*(resId) 函数
建议:测量你的 APP,查看是否使用的 SDK 调用了 ClassLoader.getResource*() 函数。将这些 SDK 替换为更高效的版本,或者至少不要在主线程触发这些函数的调用。
立即查看你的 APP 有没有被 ClassLoader.getResource*() 函数拖慢!
附录:我们是如何定位 getResourceAsStream 函数中的耗时操作的
为了理解这个问题的根本原因,我们分析一下安卓系统的源码。我们分析的是 AOSP 的 android-6.0.1_r11 分支。我们首先看一下 ClassLoader 的代码:
libcore/libart/src/main/java/java/lang/ClassLoader.java
代码很简单,首先我们查找资源对应的路径,如果不为 null,我们就为它打开一个输入流。在这里,路径是一个 java.net.URL 对象,有一个 openStream() 函数。
现在我们看一下 getResource() 的实现:
继续跟进 findResource() 函数:
findResource() 在这里没有被实现,而 ClassLoader 是一个抽象类,所以我们分析一下在 APP 运行时所使用的实现类。查看安卓开发者文档,我们可以发现安卓系统提供了好几个 ClassLoader 的实现类,通常情况下使用的是 PathClassLoader。
让我们 build AOSP 的代码,并通过日志查看 getResourceAsStream 和 getResource 使用的是哪一个实现类中的方法:
测试发现,实际调用的是 dalvik.system.PathClassLoader 类。然而查看 PathClassLoader 我们并未发现 findResource 的实现。这是因为 findResource() 在其父类 BaseDexClassLoader 中实现了。
/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java:
继续跟进 pathList:
继续跟进 DexPathList:
/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
继续跟进 DexPathList.findResource
:
Element 是 DexPathList 类的一个静态内部类。其中就包含了我们寻找的目标代码:
现在我们分析一下,我们知道,APK 文件实际上就是一个 zip 文件,从这行代码我们看到:
这里会尝试查找指定名称的 ZipEntry,如果查找成功,我们就会返回这个资源对应的 URL。这个查找操作可能是非常耗时的,但是查看 getEntry 的实现,我们它的原理就是遍历一个 LinkedHashMap:
/libcore/luni/src/main/java/java/util/zip/ZipFile.java
这个操作不会特别快,但肯定也不会特别慢。
这里我们遗漏了一个细节,在读取这个 zip 文件之前,我们肯定需要打开这个 zip 文件,再次查看 DexPathList.Element.findResource() 函数的代码,我们发现在第一行调用了 maybeInit():
找到了!就是这一行:
打开了 zip 文件读取内容:
在构造函数中初始化了一个叫 entries 的 LinkedHashMap 对象。(如果要查看 ZipFile 内部的数据结构,可以查看源码) 显然,APK 文件越大,打开 zip 文件需要的时间就会越长。
这里我们发现了 getResourceAsStream 第一个耗时操作。这个过程很有趣,也很复杂,但这只是开始 :) 如果我们在源码中加入下面的测量代码:
我们发现打开 zip 文件的耗时并不是 getResourceAsStream 的所有耗时,url.openStream() 耗费的时间远比 getResource() 要长,所以我们继续深挖。
####url.openStream()
查看 url.openStream() 的调用栈,我们发现了 /libcore/luni/src/main/java/libcore/net/url/JarURLConnectionImpl.java
先看看 connect():
继续跟进:
getUseCaches() 会返回 true:
跟进 openJarFile():
可以看到,这里打开了一个 JarFile,而不是 ZipFile。不过 JarFile 继承自 ZipFile。这里我们发现了 getResourceAsStream 的第二个耗时操作:安卓系统需要再次打开 ZipFile 并索引其内容。
读取 APK 文件内容并建立索引两次,就使得开销加大了两倍,已经是非常严重的问题了,但这依然不是 getResourceAsStream 的所有耗时。所以我们继续跟进 JarFile 的构造函数:
在这里我们发现了第三个耗时操作,所有的 APK 文件都是被签名过的,所以 JarFile 会进行签名验证。这个验证过程也会很慢,当然,对签名过程的深入分析就不是本文的内容了,有兴趣可以继续深入学习。
总结
ClassLoader.getResourceAsStream 之所以慢,是由于以下三个原因:(1) 以 zip 压缩包的方式打开 APK 文件,为 APK 内的所有内容建立索引;(2) 再次打开 APK 文件,并再次索引所有的内容;(3) 校验 APK 文件被正确的进行了签名操作。
其他备注
Q: ClassLoader.getResource*() 在 Dalvik 和 ART 中一样慢吗?
A: 是的,我们测试了两个 AOSP 分支,android-6.0.1_r11 使用了 ART 技术,android-4.4.4_r2 使用的是 Dalvik。两种环境下 getResource*()
都很慢。
Q: 为什么 ClassLoader.findClass() 没有如此之慢?
A: 安卓会在安装 APK 的时候解压 DEX 文件,因此执行 ClassLoader.findClass() 时,无需再次打开 APK 文件查找内容了。
此外,在 DexPathList 类中我们可以看到:
这个过程中没有涉及到 ZipFile 和 JarFile。
Q: 为什么安卓系统的 Resources.get*(resId) 函数不存在此问题?
A: 安卓系统对资源文件的处理有单独的索引和加载机制,没有涉及到 ZipFile 和 JarFile。