继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

揭秘在安卓平台上奇慢无比的 ClassLoader.getResourceAsStream

米脂
关注TA
已关注
手记 492
粉丝 88
获赞 591

我们 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


public InputStream getResourceAsStream(String resName) {
    try {
        URL url = getResource(resName);
        if (url != null) {
            return url.openStream();
        }
    } catch (IOException ex) {
        // Don't want to see the exception.
    }
 
    return null;
}


代码很简单,首先我们查找资源对应的路径,如果不为 null,我们就为它打开一个输入流。在这里,路径是一个 java.net.URL 对象,有一个 openStream() 函数。

现在我们看一下 getResource() 的实现:


public URL getResource(String resName) {
    URL resource = parent.getResource(resName);
    if (resource == null) {
        resource = findResource(resName);
    }
    return resource;
}


继续跟进 findResource() 函数:


protected URL findResource(String resName) {
    return null;
}


findResource() 在这里没有被实现,而 ClassLoader 是一个抽象类,所以我们分析一下在 APP 运行时所使用的实现类。查看安卓开发者文档,我们可以发现安卓系统提供了好几个 ClassLoader 的实现类,通常情况下使用的是 PathClassLoader。

让我们 build AOSP 的代码,并通过日志查看 getResourceAsStream 和 getResource 使用的是哪一个实现类中的方法:


public InputStream getResourceAsStream(String resName) {
  try {
      Logger.getLogger("NimbleDroid RESEARCH").info("this: " + this);
      URL url = getResource(resName);
      if (url != null) {
          return url.openStream();
      }
      ...
}


测试发现,实际调用的是 dalvik.system.PathClassLoader 类。然而查看 PathClassLoader 我们并未发现 findResource 的实现。这是因为 findResource() 在其父类 BaseDexClassLoader 中实现了。

/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java:


@Override
protected URL findResource(String name) {
    return pathList.findResource(name);
}


继续跟进 pathList:


public class BaseDexClassLoader extends ClassLoader {
  private final DexPathList pathList;
 
  /**
   * Constructs an instance.
   *
   * @param dexPath the list of jar/apk files containing classes and
   * resources, delimited by {@code File.pathSeparator}, which
   * defaults to {@code ":"} on Android
   * @param optimizedDirectory directory where optimized dex files
   * should be written; may be {@code null}
   * @param libraryPath the list of directories containing native
   * libraries, delimited by {@code File.pathSeparator}; may be
   * {@code null}
   * @param parent the parent class loader
   */
  public BaseDexClassLoader(String dexPath, File optimizedDirectory,
          String libraryPath, ClassLoader parent) {
      super(parent);
      this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
  }


继续跟进 DexPathList:

/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java


/**
 * A pair of lists of entries, associated with a {@code ClassLoader}.
 * One of the lists is a dex/resource path — typically referred
 * to as a "class path" — list, and the other names directories
 * containing native code libraries. Class path entries may be any of:
 * a {@code .jar} or {@code .zip} file containing an optional
 * top-level {@code classes.dex} file as well as arbitrary resources,
 * or a plain {@code .dex} file (with no possibility of associated
 * resources).
 *
 * <p>This class also contains methods to use these lists to look up
 * classes and resources.</p>
 */
/*package*/ final class DexPathList {


继续跟进 DexPathList.findResource


/**
 * Finds the named resource in one of the zip/jar files pointed at
 * by this instance. This will find the one in the earliest listed
 * path element.
 *
 * @return a URL to the named resource or {@code null} if the
 * resource is not found in any of the zip/jar files
 */
public URL findResource(String name) {
    for (Element element : dexElements) {
        URL url = element.findResource(name);
        if (url != null) {
            return url;
        }
    }
 
    return null;
}


Element 是 DexPathList 类的一个静态内部类。其中就包含了我们寻找的目标代码:


public URL findResource(String name) {
  maybeInit();
 
  // We support directories so we can run tests and/or legacy code
  // that uses Class.getResource.
  if (isDirectory) {
      File resourceFile = new File(dir, name);
      if (resourceFile.exists()) {
          try {
              return resourceFile.toURI().toURL();
          } catch (MalformedURLException ex) {
              throw new RuntimeException(ex);
          }
      }
  }
 
  if (zipFile == null || zipFile.getEntry(name) == null) {
      /*
       * Either this element has no zip/jar file (first
       * clause), or the zip/jar file doesn't have an entry
       * for the given name (second clause).
       */
      return null;
  }
 
  try {
      /*
       * File.toURL() is compliant with RFC 1738 in
       * always creating absolute path names. If we
       * construct the URL by concatenating strings, we
       * might end up with illegal URLs for relative
       * names.
       */
      return new URL("jar:" + zip.toURL() + "!/" + name);
  } catch (MalformedURLException ex) {
      throw new RuntimeException(ex);
  }
}


现在我们分析一下,我们知道,APK 文件实际上就是一个 zip 文件,从这行代码我们看到:


if (zipFile == null || zipFile.getEntry(name) == null) {


这里会尝试查找指定名称的 ZipEntry,如果查找成功,我们就会返回这个资源对应的 URL。这个查找操作可能是非常耗时的,但是查看 getEntry 的实现,我们它的原理就是遍历一个 LinkedHashMap:

/libcore/luni/src/main/java/java/util/zip/ZipFile.java


...
  private final LinkedHashMap<String, ZipEntry> entries = new LinkedHashMap<String, ZipEntry>();
...
  public ZipEntry getEntry(String entryName) {
      checkNotClosed();
      if (entryName == null) {
          throw new NullPointerException("entryName == null");
      }
 
      ZipEntry ze = entries.get(entryName);
      if (ze == null) {
          ze = entries.get(entryName + "/");
      }
      return ze;
  }


这个操作不会特别快,但肯定也不会特别慢。

这里我们遗漏了一个细节,在读取这个 zip 文件之前,我们肯定需要打开这个 zip 文件,再次查看 DexPathList.Element.findResource() 函数的代码,我们发现在第一行调用了 maybeInit():


public synchronized void maybeInit() {
  if (initialized) {
      return;
  }
 
  initialized = true;
 
  if (isDirectory || zip == null) {
      return;
  }
 
  try {
      zipFile = new ZipFile(zip);
  } catch (IOException ioe) {
      /*
       * Note: ZipException (a subclass of IOException)
       * might get thrown by the ZipFile constructor
       * (e.g. if the file isn't actually a zip/jar
       * file).
       */
      System.logE("Unable to open zip file: " + zip, ioe);
      zipFile = null;
  }
}


找到了!就是这一行:


zipFile = new ZipFile(zip);


打开了 zip 文件读取内容:


public ZipFile(File file) throws ZipException, IOException {
    this(file, OPEN_READ);
}


在构造函数中初始化了一个叫 entries 的 LinkedHashMap 对象。(如果要查看 ZipFile 内部的数据结构,可以查看源码) 显然,APK 文件越大,打开 zip 文件需要的时间就会越长。

这里我们发现了 getResourceAsStream 第一个耗时操作。这个过程很有趣,也很复杂,但这只是开始 :) 如果我们在源码中加入下面的测量代码:


  public InputStream getResourceAsStream(String resName) {
    try {
      long start; long end;
 
      start = System.currentTimeMillis();
      URL url = getResource(resName);
      end = System.currentTimeMillis();
      Logger.getLogger("NimbleDroid RESEARCH").info("getResource: " + (end - start));
 
      if (url != null) {
          start = System.currentTimeMillis();
          InputStream inputStream = url.openStream();
          end = System.currentTimeMillis();
          Logger.getLogger("NimbleDroid RESEARCH").info("url.openStream: " + (end - start));
 
          return inputStream;
      }
      ...


我们发现打开 zip 文件的耗时并不是 getResourceAsStream 的所有耗时,url.openStream() 耗费的时间远比 getResource() 要长,所以我们继续深挖。

查看 url.openStream() 的调用栈,我们发现了 /libcore/luni/src/main/java/libcore/net/url/JarURLConnectionImpl.java


@Override
public InputStream getInputStream() throws IOException {
    if (closed) {
        throw new IllegalStateException("JarURLConnection InputStream has been closed");
    }
    connect();
    if (jarInput != null) {
        return jarInput;
    }
    if (jarEntry == null) {
        throw new IOException("Jar entry not specified");
    }
    return jarInput = new JarURLConnectionInputStream(jarFile
            .getInputStream(jarEntry), jarFile);
}


先看看 connect():


@Override
public void connect() throws IOException {
    if (!connected) {
        findJarFile(); // ensure the file can be found
        findJarEntry(); // ensure the entry, if any, can be found
        connected = true;
    }
}


继续跟进:


private void findJarFile() throws IOException {
    if (getUseCaches()) {
        synchronized (jarCache) {
            jarFile = jarCache.get(jarFileURL);
        }
        if (jarFile == null) {
            JarFile jar = openJarFile();
            synchronized (jarCache) {
                jarFile = jarCache.get(jarFileURL);
                if (jarFile == null) {
                    jarCache.put(jarFileURL, jar);
                    jarFile = jar;
                } else {
                    jar.close();
                }
            }
        }
    } else {
        jarFile = openJarFile();
    }
 
    if (jarFile == null) {
        throw new IOException();
    }
}


getUseCaches() 会返回 true:


public abstract class URLConnection {
...
  private static boolean defaultUseCaches = true;
  ...


跟进 openJarFile():


private JarFile openJarFile() throws IOException {
  if (jarFileURL.getProtocol().equals("file")) {
      String decodedFile = UriCodec.decode(jarFileURL.getFile());
      return new JarFile(new File(decodedFile), true, ZipFile.OPEN_READ);
  } else {
    ...


可以看到,这里打开了一个 JarFile,而不是 ZipFile。不过 JarFile 继承自 ZipFile。这里我们发现了 getResourceAsStream 的第二个耗时操作:安卓系统需要再次打开 ZipFile 并索引其内容。

读取 APK 文件内容并建立索引两次,就使得开销加大了两倍,已经是非常严重的问题了,但这依然不是 getResourceAsStream 的所有耗时。所以我们继续跟进 JarFile 的构造函数:


/**
 * Create a new {@code JarFile} using the contents of file.
 *
 * @param file
 *            the JAR file as {@link File}.
 * @param verify
 *            if this JAR filed is signed whether it must be verified.
 * @param mode
 *            the mode to use, either {@link ZipFile#OPEN_READ OPEN_READ} or
 *            {@link ZipFile#OPEN_DELETE OPEN_DELETE}.
 * @throws IOException
 *             If the file cannot be read.
 */
public JarFile(File file, boolean verify, int mode) throws IOException {
    super(file, mode);
 
    // Step 1: Scan the central directory for meta entries (MANIFEST.mf
    // & possibly the signature files) and read them fully.
    HashMap<String, byte[]> metaEntries = readMetaEntries(this, verify);
 
    // Step 2: Construct a verifier with the information we have.
    // Verification is possible *only* if the JAR file contains a manifest
    // *AND* it contains signing related information (signature block
    // files and the signature files).
    //
    // TODO: Is this really the behaviour we want if verify == true ?
    // We silently skip verification for files that have no manifest or
    // no signatures.
    if (verify && metaEntries.containsKey(MANIFEST_NAME) &&
            metaEntries.size() > 1) {
        // We create the manifest straight away, so that we can create
        // the jar verifier as well.
        manifest = new Manifest(metaEntries.get(MANIFEST_NAME), true);
        verifier = new JarVerifier(getName(), manifest, metaEntries);
    } else {
        verifier = null;
        manifestBytes = metaEntries.get(MANIFEST_NAME);
    }
}


在这里我们发现了第三个耗时操作,所有的 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 类中我们可以看到:


public Class findClass(String name, List<Throwable> suppressed) {
  for (Element element : dexElements) {
      DexFile dex = element.dexFile;
 
      if (dex != null) {
          Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
          if (clazz != null) {
              return clazz;
          }
      }
  }
  if (dexElementsSuppressedExceptions != null) {
      suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
  }
  return null;
}


这个过程中没有涉及到 ZipFile 和 JarFile。

Q: 为什么安卓系统的 Resources.get*(resId) 函数不存在此问题?

A: 安卓系统对资源文件的处理有单独的索引和加载机制,没有涉及到 ZipFile 和 JarFile。

转自: Piasy

 原文链接:http://www.apkbus.com/blog-705730-60552.html

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP