Android APK 中 dex 文件數量限制問題

問題

通過AS直接運行程序,啓動就報必現的ClassNotFoundException異常, 僅在5.X的系統版本 API 21和22的出現, 6.0以後的系統版本正常。並且僅在Debug模式下有問題,Release模式正常。

E/AndroidRuntime(7655): Caused by: java.lang.ClassNotFoundException: Didn't find class 
"com.test.utils.AppUtil" on path: DexPathList[[zip file "/data/app/com.test-1/base.apk"],
nativeLibraryDirectories=[/data/app/com.test-1/lib/arm, /vendor/lib, /system/lib]]
E/AndroidRuntime(7655): at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56)
E/AndroidRuntime(7655): at java.lang.ClassLoader.loadClass(ClassLoader.java:511)
E/AndroidRuntime(7655): at java.lang.ClassLoader.loadClass(ClassLoader.java:469)
E/AndroidRuntime(7655): ... 13 more
E/AndroidRuntime(7655): Suppressed: java.lang.ClassNotFoundException: com.test.utils.AppUtil
E/AndroidRuntime(7655): at java.lang.Class.classForName(Native Method)
E/AndroidRuntime(7655): at java.lang.BootClassLoader.findClass(ClassLoader.java:781)
E/AndroidRuntime(7655): at java.lang.BootClassLoader.loadClass(ClassLoader.java:841)
E/AndroidRuntime(7655): at java.lang.ClassLoader.loadClass(ClassLoader.java:504)
E/AndroidRuntime(7655): ... 14 more
E/AndroidRuntime(7655): Caused by: java.lang.NoClassDefFoundError: 
Class not found using the boot class loader; no stack available

問題背景

隨着應用發展App的方法數不斷的上漲,爲了加快Android的編譯速度,我們添加了以下內容:

android {
  defaultConfig {
      multiDexEnabled = true
      minSdkVersion 21
  }
  dexOptions {
      preDexLibraries = true
  }
}
  • multidexDexEnable
    分包設置: 當總方法數超過64k時,允許拆分成多個dex文件 (更多內容)。

  • minSdkVersion
    最低支持設備版本:Android 5.0開始ART虛擬機默認支持加載多dex文件。如果我們把值設置爲21或者更大,在編譯App時,2.3或者更高版本的AS會檢測所要安裝的設備是否大於5.0或者更高,是的話會開啓pre-dexing(更多內容)。

  • preDexLibaries
    預緩存dex文件:每個依賴對應一個classes.dex文件,保存在app\build\intermediates\transforms目錄中。下次編譯時,當存在對應的緩存dex文件時,將直接使用緩存文件,加快編譯速度(該配置爲可選配置,在高版本的AS 和AGP中會自動根據連接的設備進行設置)。
    dex緩存目錄

問題分析

關於類找不到的問題一般多發生於4.X版本的系統,系統本身不支持多dex的模式,需要使用MultiDex Library在第一次運行時對多個dex文件進行釋放和優化。這裏還涉及到一個MainDexList的問題,要求從Application啓動到MultiDex.install()間所有相關的類都必須在第一個dex文件中,否則一啓動就可能因爲找不到類而閃退。

關於dex文件的優化,還會遇到一些奇怪的問題,即使MultiDex.install()執行成功了,可還是會出現類找不到的問題,並且遇到這個問題的用戶還不在少數,問題都集中在4.X的版本。詳細內容可以查看 Tinker的issue解決方案

由於發生問題的是在Android 5.X版本的設備上,這顯然不是上面提到的問題。因爲新設備本身就有對多dex文件的支持,系統會在App安裝的時候通過dex2oat把多個dex合併爲一個oat文件。在6.0以上的設備是正常的,Deubg包也沒有開啓Proguard,並且在APK包的classes103.dex文件中也找到了AppUtil類和方法定義(由於開啓了preDexLibraries, 所以dex文件非常多),說明打包出來的APK文件也是沒有問題的。在這裏插入圖片描述
通過對比新舊版本App的安裝和啓動日誌,在5.X的設備上,並沒有發現兩個版本的日誌有什麼不同和異常。不過在6.0設備上發現了一條奇怪的Warn級別日誌,並且這條日誌在舊版本正常啓動的安裝日誌裏面是沒有的。

W/dex2oat: base.apk has in excess of 100 dex files. Please consider coalescing and shrinking 
the number to  avoid runtime overhead.

對比新舊版本的APK文件發現,舊版的Debug APK中的dex文件有93個,而新版的Debug APK有103個dex文件(新版升級到LeakCanary 2.0)。93個正常,103個則異常,再根據上面的日誌提示,是否有可能是因爲dex文件的增多導致的問題?


安裝流程

dex2oat編譯

我們應用在安裝的時候,系統會通過dex2oat工具將APK內的dex文件合併成oat文件。

 I/dex2oat: /system/bin/dex2oat 
 --zip-fd=12 
 --zip-location=/data/app/com.test-1/base.apk 
 --oat-fd=13 
 --oat-location=/data/dalvik-cache/arm/data@[email protected]@[email protected] 
 --instruction-set=x86 --instruction-set-features=default
 --runtime-arg -Xms64m --runtime-arg -Xmx512m4

dex2oat執行的主要流程如下:
在這裏插入圖片描述
上圖涉及的源碼在 dex2oat.ccdex_file.cc兩個部分。

dex2oat的main函數
int main(int argc, char** argv) {
  int result = art::dex2oat(argc, argv);
  // Everything was done, do an explicit exit here to avoid running Runtime destructors that take
  // time (bug 10645725) unless we're a debug build or running on valgrind. Note: The Dex2Oat class
  // should not destruct the runtime in this case.
  if (!art::kIsDebugBuild && (RUNNING_ON_VALGRIND == 0)) {
    exit(result);
  }
  return result;
}

// namespace art
static int dex2oat(int argc, char** argv) {
  b13564922();
  TimingLogger timings("compiler", false, false);
  Dex2Oat dex2oat(&timings);

  // Parse arguments. Argument mistakes will lead to exit(EXIT_FAILURE) in UsageError.
  dex2oat.ParseArgs(argc, argv);

  // Check early that the result of compilation can be written
  if (!dex2oat.OpenFile()) {
    return EXIT_FAILURE;
  }

  // Print the complete line when any of the following is true:
  //   1) Debug build
  //   2) Compiling an image
  //   3) Compiling with --host
  //   4) Compiling on the host (not a target build)
  // Otherwise, print a stripped command line.
  if (kIsDebugBuild || dex2oat.IsImage() || dex2oat.IsHost() || !kIsTargetBuild) {
    LOG(INFO) << CommandLine();
  } else {
    LOG(INFO) << StrippedCommandLine();
  }

  if (!dex2oat.Setup()) {
    dex2oat.EraseOatFile();
    return EXIT_FAILURE;
  }

  if (dex2oat.IsImage()) {
    return CompileImage(dex2oat);
  } else {
    return CompileApp(dex2oat);
  }
}

main函數的邏輯比較簡單,直接調用靜態的dex2oat函數並傳入參數,主要的工作在該函數中。dex2oat函數中主要包含幾個流程:ParseArgs, Setup, CompileApp

Dex2Oat.ParseArgs函數
  // Parse the arguments from the command line. In case of an unrecognized option or impossible
  // values/combinations, a usage error will be displayed and exit() is called. Thus, if the method
  // returns, arguments have been successfully parsed.
  void ParseArgs(int argc, char** argv) {
    //此處省略代碼
    for (int i = 0; i < argc; i++) {
      //此處省略代碼
      if (option.starts_with("--dex-file=")) {
        dex_filenames_.push_back(option.substr(strlen("--dex-file=")).data());
      } else if  (option.starts_with("--zip-fd=")) {
        const char* zip_fd_str = option.substr(strlen("--zip-fd=")).data();
        if (!ParseInt(zip_fd_str, &zip_fd_)) {
          Usage("Failed to parse --zip-fd argument '%s' as an integer", zip_fd_str);
        }
        if (zip_fd_ < 0) {
          Usage("--zip-fd passed a negative value %d", zip_fd_);
        }
      } else if (option.starts_with("--zip-location=")) {
        zip_location_ = option.substr(strlen("--zip-location=")).data();
      } 
      //此處省略代碼
      //由於命令行參數沒有指定 "--image="和"--boot-image=", 解析的結果爲空
      else if (option.starts_with("--image=")) {
        image_filename_ = option.substr(strlen("--image=")).data();
      }
      else if (option.starts_with("--boot-image=")) {
        boot_image_filename = option.substr(strlen("--boot-image=")).data();
      }
      //此處省略代碼 
    } 

    //給 "boot_image_filename"和"boot_image_option_"初始化默認值
    image_ = (!image_filename_.empty());
    if (!image_ && boot_image_filename.empty()) {
      boot_image_filename += android_root_;
      boot_image_filename += "/framework/boot.art";
    }
    if (!boot_image_filename.empty()) {
      boot_image_option_ += "-Ximage:";
      boot_image_option_ += boot_image_filename;
    }
    //此處省略代碼 
  }

ParseArgs()函數中會解析命令行中的參數--zip-fd文件id 和 --zip-location文件路徑,分別保存在zip_fd_zip_location_。由於不是通過--dex-file指定要編譯的文件dex_filenames_的值爲空,後面還有會給沒有指定值的boot_image_filename, boot_image_option_賦默認值。

Dex2Oat.Setup函數
  // Set up the environment for compilation. Includes starting the runtime and loading/opening the
  // boot class path.
  bool Setup() {
     //此處省略代碼 
     //這裏boot_image_option_不爲空
    if (boot_image_option_.empty()) {
      dex_files_ = Runtime::Current()->GetClassLinker()->GetBootClassPath();
    } else {
      //這裏dex_filenames_爲空
      if (dex_filenames_.empty()) {
        ATRACE_BEGIN("Opening zip archive from file descriptor");
        std::string error_msg;
        std::unique_ptr<ZipArchive> zip_archive(ZipArchive::OpenFromFd(zip_fd_,
                                                                       zip_location_.c_str(),
                                                                       &error_msg));
        if (zip_archive.get() == nullptr) {
          LOG(ERROR) << "Failed to open zip from file descriptor for '" << zip_location_ << "': "
              << error_msg;
          return false;
        }
        if (!DexFile::OpenFromZip(*zip_archive.get(), zip_location_, &error_msg, &opened_dex_files_)) {
          LOG(ERROR) << "Failed to open dex from file descriptor for zip file '" << zip_location_
              << "': " << error_msg;
          return false;
        }
        for (auto& dex_file : opened_dex_files_) {
          dex_files_.push_back(dex_file.get());
        }
        ATRACE_END();
      } else {
       //此處省略代碼 
      }
    }
    //此處省略代碼 
    return true;
  }

函數中會進行boot_image_option_dex_filenames_的判斷。根據ParseArgs()函數解析得到的值,通過ZipArchive::OpenFromFd()打開APK文件,並進入到DexFile::OpenFromZip()的分支邏輯中。

DexFile::OpenFromZip函數
// Technically we do not have a limitation with respect to the number of dex files that can be in a
// multidex APK. However, it's bad practice, as each dex file requires its own tables for symbols
// (types, classes, methods, ...) and dex caches. So warn the user that we open a zip with what
// seems an excessive number.
static constexpr size_t kWarnOnManyDexFilesThreshold = 100;

bool DexFile::OpenFromZip(const ZipArchive& zip_archive, const std::string& location,
                          std::string* error_msg,
                          std::vector<std::unique_ptr<const DexFile>>* dex_files) {
  DCHECK(dex_files != nullptr) << "DexFile::OpenFromZip: out-param is nullptr";
  ZipOpenErrorCode error_code;
  std::unique_ptr<const DexFile> dex_file(Open(zip_archive, kClassesDex, location, error_msg,
                                               &error_code));
  if (dex_file.get() == nullptr) {
    return false;
  } else {
    // Had at least classes.dex.
    dex_files->push_back(std::move(dex_file));
    for (size_t i = 1; ; ++i) {
      std::string name = GetMultiDexClassesDexName(i);
      std::string fake_location = GetMultiDexLocation(i, location.c_str());
      std::unique_ptr<const DexFile> next_dex_file(Open(zip_archive, name.c_str(), fake_location,
                                                        error_msg, &error_code));
      if (next_dex_file.get() == nullptr) {
        if (error_code != ZipOpenErrorCode::kEntryNotFound) {
          LOG(WARNING) << error_msg;
        }
        break;
      } else {
        dex_files->push_back(std::move(next_dex_file));
      }

      if (i == kWarnOnManyDexFilesThreshold) {
        LOG(WARNING) << location << " has in excess of " << kWarnOnManyDexFilesThreshold
                     << " dex files. Please consider coalescing and shrinking the number to "
                        " avoid runtime overhead.";
      }

      if (i == std::numeric_limits<size_t>::max()) {
        LOG(ERROR) << "Overflow in number of dex files!";
        break;
      }
    }
    return true;
  }
}

函數會循環讀取APK文件中的classes.dex和classesN.dex文件,並生成對應DexFile對象。在這裏我們也看到了

W/dex2oat: base.apk has in excess of 100 dex files. Please consider coalescing and shrinking 
the number to  avoid runtime overhead.

日誌的出處。當APK文件中的dex文件的數量超過100個的時候,會打印這條警告日誌。但這裏僅是打印日誌,生成的DexFile對象還是會被添加到dex_files,並不影響後續的編譯和應用功能。

我們再看下是5.X版本中的DexFile::OpenFromZip()的邏輯:

bool DexFile::OpenFromZip(const ZipArchive& zip_archive, const std::string& location,
                          std::string* error_msg, std::vector<const DexFile*>* dex_files) {
  ZipOpenErrorCode error_code;
  std::unique_ptr<const DexFile> dex_file(Open(zip_archive, kClassesDex, location, error_msg,
                                               &error_code));
  if (dex_file.get() == nullptr) {
    return false;
  } else {
    // Had at least classes.dex.
    dex_files->push_back(dex_file.release());
    // Now try some more.
    size_t i = 2;
    // We could try to avoid std::string allocations by working on a char array directly. As we
    // do not expect a lot of iterations, this seems too involved and brittle.
    while (i < 100) {
      std::string name = StringPrintf("classes%zu.dex", i);
      std::string fake_location = location + kMultiDexSeparator + name;
      std::unique_ptr<const DexFile> next_dex_file(Open(zip_archive, name.c_str(), fake_location,
                                                        error_msg, &error_code));
      if (next_dex_file.get() == nullptr) {
        if (error_code != ZipOpenErrorCode::kEntryNotFound) {
          LOG(WARNING) << error_msg;
        }
        break;
      } else {
        dex_files->push_back(next_dex_file.release());
      }
      i++;
    }
    return true;
  }
}

在5.X的版本中,dex2oat僅會加載前99個classesN.dex文件。當APK中的dex文件的數量超過99的時候,超過的這些dex文件將不會被載入和參與OAT優化,這也就造成了開頭類找不到的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章