dex 優化編年史

語言: CN / TW / HK

本文作者:熊大

引言

在熱修復和插件化場景中會涉及動態加載 dex,要使它們中代碼的執行速度與安裝的 APK 相當,需要對它們進行正確的優化。根據以往的經驗,在熱修復場景中,錯誤的方式導致 dex 沒有得到優化時,修復後 App 的啟動速度比修復前慢 50%。本文將在下面的部分介紹在 Android 5.0 以來的各系統版本中對動態加載的 dex 進行優化的方式及原理。

Android 5

從 Android 5.0 開始,系統引入了預先編譯機制(AOT),在應用安裝時,使用 dex2oat 工具將 dex 編譯為可執行文件。

此時可以通過 DexFile.loadDex 來觸發 dex2oat 優化 dex,其調用過程如下: DexFile.loadDex -> new DexFile -> DexFile.openDexFile -> DexFile.openDexFileNative -> DexFile_openDexFileNative -> ClassLinker::OpenDexFilesFromOat -> ClassLinker::CreateOatFileForDexLocation -> ClassLinker::GenerateOatFile 可以看到在 ClassLinker::GenerateOatFile 函數中會執行 dex2oat 命令來優化 dex。 ````c++ // art/runtime/class_linker.cc(android-5.0.2) bool ClassLinker::GenerateOatFile(const char dex_filename, int oat_fd, const char oat_cache_filename, std::string* error_msg) { // ... std::vector argv; argv.push_back(dex2oat); argv.push_back("--runtime-arg"); argv.push_back("-classpath"); argv.push_back("--runtime-arg"); argv.push_back(Runtime::Current()->GetClassPathString());

Runtime::Current()->AddCurrentRuntimeFeaturesAsDex2OatArguments(&argv);

if (!Runtime::Current()->IsVerificationEnabled()) { argv.push_back("--compiler-filter=verify-none"); }

if (Runtime::Current()->MustRelocateIfPossible()) { argv.push_back("--runtime-arg"); argv.push_back("-Xrelocate"); } else { argv.push_back("--runtime-arg"); argv.push_back("-Xnorelocate"); }

if (!kIsTargetBuild) { argv.push_back("--host"); }

argv.push_back(boot_image_option); argv.push_back(dex_file_option); argv.push_back(oat_fd_option); argv.push_back(oat_location_option); const std::vector& compiler_options = Runtime::Current()->GetCompilerOptions(); for (size_t i = 0; i < compiler_options.size(); ++i) { argv.push_back(compiler_options[i].c_str()); }

return Exec(argv, error_msg); } ````

所以,可用 DexFile.loadDex 進行 dex 優化。

Android 7

從 Android 7.0 開始,為解決 AOT 帶來的安裝時間長和佔用空間大等問題,系統引入了配置文件引導型編譯,結合 AOT 和 即時編譯(JIT)一起使用: 1. 應用安裝時不再進行 AOT 編譯 2. 在應用的運行過程中,對未編譯的代碼進行解釋,將執行的方法信息記錄到配置文件中,並對經常執行的方法進行 JIT 編譯 3. 當設備閒置和充電時,根據生成的配置文件對常用代碼進行 AOT 編譯

配置文件引導型編譯跟以前的 AOT 編譯的一個主要區別是執行 dex2oat 時使用的編譯過濾器不同,前者使用 speed-profile,而後者使用 speed。dex2oat 的所有編譯過濾器定義在 compiler_filter.h 中,在不同系統版本中類型會有變化,主要有以下 4 種: * verify:僅運行 dex 代碼驗證 * quicken:運行 dex 代碼驗證,並優化一些 dex 指令,以獲得更好的解釋器性能(Android 8 引入,Android 12 移除) * speed:運行 dex 代碼驗證,並對所有方法進行 AOT 編譯 * speed-profile:運行 dex 代碼驗證,並對配置文件中列出的方法進行 AOT 編譯

編譯過濾器會影響 dex 優化的效果,回到前面給出的優化方法 DexFile.loadDex,其在新系統版本中會使用優化所需的編譯過濾器嗎?DexFile.loadDex 在 Android 7.0 上調用過程如下: DexFile.loadDex -> new DexFile -> DexFile.openDexFile -> DexFile.openDexFileNative -> DexFile_openDexFileNative -> OatFileManager::OpenDexFilesFromOat -> OatFileAssistant::MakeUpToDate -> OatFileAssistant::GenerateOatFile 依然會在 OatFileAssistant::GenerateOatFile 函數中執行 dex2oat 命令,使用的編譯過濾器是在調 OatFileAssistant::MakeUpToDate 函數時傳入的 speed,所以 DexFile.loadDex 在 Android 7.0 上依然適用。 ````c++ // art/runtime/oat_file_manager.cc(android-7.0.0) CompilerFilter::Filter OatFileManager::filter_ = CompilerFilter::Filter::kSpeed;

std::vector> OatFileManager::OpenDexFilesFromOat( const char dex_location, const char oat_location, jobject class_loader, jobjectArray dex_elements, const OatFile out_oat_file, std::vector error_msgs) { // ... if (!oat_file_assistant.IsUpToDate()) { // ... switch (oat_file_assistant.MakeUpToDate(filter_, /out*/ &error_msg)) { // ... } } // ...
} ````

Android 8

在 Android 8.0 中,DexFile.loadDex 的調用過程基本不變,但編譯過濾器改為通過 GetRuntimeCompilerFilterOption 函數得到。 c++ // art/runtime/oat_file_assistant.cc(android-8.0.0) OatFileAssistant::MakeUpToDate(bool profile_changed, std::string* error_msg) { CompilerFilter::Filter target; if (!GetRuntimeCompilerFilterOption(&target, error_msg)) { return kUpdateNotAttempted; } // ... } GetRuntimeCompilerFilterOption 函數優先取當前 Runtime 的啟動參數 --compiler-filter 指定的編譯過濾器,如不存在,則用默認的 quicken。 ````c++ // art/runtime/oat_file_assistant.cc(android-8.0.0) static bool GetRuntimeCompilerFilterOption(CompilerFilter::Filter filter, std::string error_msg) { // ... filter = OatFileAssistant::kDefaultCompilerFilterForDexLoading; for (StringPiece option : Runtime::Current()->GetCompilerOptions()) { if (option.starts_with("--compiler-filter=")) { const char compiler_filter_string = option.substr(strlen("--compiler-filter=")).data(); if (!CompilerFilter::ParseCompilerFilter(compiler_filter_string, filter)) { // ... return false; } } } return true; }

// art/runtime/oat_file_assistant.h(android-8.0.0) class OatFileAssistant { public: // The default compile filter to use when optimizing dex file at load time if they // are out of date. static const CompilerFilter::Filter kDefaultCompilerFilterForDexLoading = CompilerFilter::kQuicken; // ...
}; `Runtime` 的啟動參數 `--compiler-filter` 的值由設置的系統屬性 `vold.decrypt` 和 `dalvik.vm.dex2oat-filter` 決定: 1. 如果 `vold.decrypt` 屬性值等於 `trigger_restart_min_framework` 或 `1`,則為 `assume-verified` 2. 否則為 `dalvik.vm.dex2oat-filter` 屬性值c++ // frameworks/base/core/jni/AndroidRuntime.cpp(android-8.0.0) int AndroidRuntime::startVm(JavaVM pJavaVM, JNIEnv pEnv, bool zygote) { // ... property_get("vold.decrypt", voldDecryptBuf, ""); bool skip_compilation = ((strcmp(voldDecryptBuf, "trigger_restart_min_framework") == 0) || (strcmp(voldDecryptBuf, "1") == 0)); // ... if (skip_compilation) { addOption("-Xcompiler-option"); addOption("--compiler-filter=assume-verified"); // ... } else { parseCompilerOption("dalvik.vm.dex2oat-filter", dex2oatCompilerFilterBuf, "--compiler-filter=", "-Xcompiler-option"); } } ``` 通過 adb 用getprop命令查看系統屬性值,可知vold.decrypt的屬性值不等於trigger_restart_min_framework1,且dalvik.vm.dex2oat-filter屬性不存在,所以DexFile.loadDex最終使用的編譯過濾器為quicken`,達不到 dex 優化的要求。

無法實現對 dex 的所有方法進行 AOT 編譯,可以退而求其次,通過創建 BaseDexClassLoader 或其子類對象,讓動態加載的 dex 跟安裝的應用一樣,初始只做基本優化,隨着代碼的運行,常用代碼會被 AOT 編譯。

BaseDexClassLoader 的構造方法會依次執行以下 2 個步驟來分別實現基本優化和對常用代碼進行 AOT 編譯: 1. 創建 DexPathList 對象 2. 執行 DexLoadReporter.report 方法 ````java // libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java(android-8.0.0) public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) { super(parent); this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);

if (reporter != null) {
    reporter.report(this.pathList.getDexPaths());
}

} ````

首先,創建 DexPathList 對象會觸發創建 DexFile 對象,進而會如前文所述使用編譯過濾器 quicken 執行基本優化,調用過程如下: new DexPathList -> DexPathList.makeDexElements -> DexPathList.loadDexFile -> new DexFile

在介紹為什麼 DexLoadReporter.report 方法可以讓動態加載的 dex 能被 AOT 編譯之前,先看看 BaseDexClassLoader.reporter 的來源。在應用啟動過程中,系統會根據 dalvik.vm.usejitprofiles 屬性值來決定是否將 DexLoadReporter 單例設給 BaseDexClassLoader 的靜態變量 reporter,通過 getprop 命令查看可知 dalvik.vm.usejitprofiles 屬性值為 true,所以 BaseDexClassLoader.reporter 的值為 DexLoadReporter 單例。 java // frameworks/base/core/java/android/app/ActivityThread.java(android-8.0.0) private void handleBindApplication(AppBindData data) { // ... if (SystemProperties.getBoolean("dalvik.vm.usejitprofiles", false)) { BaseDexClassLoader.setReporter(DexLoadReporter.getInstance()); } // ... }

DexLoadReporter.report 方法通過執行如下 2 步來實現動態加載的 dex 會被系統執行基於配置文件的 AOT 編譯: 1. 向 PackageManagerService 註冊 dex 使用信息,使系統在執行後台 dex 優化時能獲得動態加載的 dex 信息進行優化 2. 向 VMRuntime 註冊記錄執行的方法信息的配置文件,使動態加載的 dex 中的方法被執行時也會被記錄 java // frameworks/base/core/java/android/app/DexLoadReporter.java(android-8.0.0) public void report(List<String> dexPaths) { // ... // Notify the package manager about the dex loads unconditionally. // The load might be for either a primary or secondary dex file. notifyPackageManager(dexPaths); // Check for secondary dex files and register them for profiling if // possible. registerSecondaryDexForProfiling(dexPaths); } notifyPackageManager 方法經過如下調用過程後,將 dex 信息註冊到 PackageDexUsage 中,並寫入到 /data/system/package-dex-usage.list 文件中。 DexLoadReporter.notifyPackageManager -> PackageManagerService.notifyDexLoad -> DexManager.notifyDexLoad -> PackageDexUsage.record -> PackageDexUsage.maybeWriteAsync registerSecondaryDexForProfiling 方法會以 dex 文件路徑加 .prof 後綴作為路徑創建配置文件,並將其註冊到 VMRuntime 中。在執行過程中會判斷 dex 文件是否是 secondary dex 文件,即非安裝的 APK 文件,判斷方式為 dex 文件是否位於應用的 data 目錄中,所以需要將動態加載的 dex 放在應用的 data 目錄中。

最後來分析下系統執行後台 dex 優化的流程,看看通過創建 BaseDexClassLoader 或其子類對象的方式註冊到 PackageDexUsage 中的 dex 能否被優化。系統會在啟動時向 JobScheduler 註冊後台 dex 優化任務,調用過程如下: SystemServer.run -> SystemServer.startOtherServices -> BackgroundDexOptService.schedule 後台 dex 優化任務會在設備空閒且充電時執行,任務執行時間間隔至少 1 天。 java // frameworks/base/services/core/java/com/android/server/pm/BackgroundDexOptService.java(android-8.0.0) public static void schedule(Context context) { JobScheduler js = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); // ... js.schedule(new JobInfo.Builder(JOB_IDLE_OPTIMIZE, sDexoptServiceName) .setRequiresDeviceIdle(true) .setRequiresCharging(true) .setPeriodic(IDLE_OPTIMIZATION_PERIOD) .build()); // ... }

系統執行後台 dex 優化任務的調用過程如下: BackgroundDexOptService.onStartJob -> BackgroundDexOptService.runIdleOptimization -> BackgroundDexOptService.idleOptimizationBackgroundDexOptService.idleOptimization 方法中,會根據 dalvik.vm.dexopt.secondary 屬性值決定是否對 secondary dex 進行優化,使用 getprop 命令查看可知該屬性值為 true,所以後台 dex 優化的目標包含 secondary dex。 java // frameworks/base/services/core/java/com/android/server/pm/BackgroundDexOptService.java(android-8.0.0) private int idleOptimization(PackageManagerService pm, ArraySet<String> pkgs, Context context) { // ... if (SystemProperties.getBoolean("dalvik.vm.dexopt.secondary", false)) { // ... result = optimizePackages(pm, pkgs, lowStorageThreshold, /*is_for_primary_dex*/ false, sFailedPackageNamesSecondary); } return result; }

後續對 secondary dex 進行優化的調用過程如下,最終通過從 ServiceManager 獲取的 installd 服務提供的 dexopt 接口執行 dex 優化。 BackgroundDexOptService.optimizePackages -> PackageManagerService.performDexOptSecondary -> DexManager.dexoptSecondaryDex -> DexManager.dexoptSecondaryDex -> PackageDexOptimizer.dexOptSecondaryDexPath -> PackageDexOptimizer.dexOptSecondaryDexPathLI -> Installer.dexoptDexManager.dexoptSecondaryDex 方法中,會先從 PackageDexUsage 獲取已註冊的 dex 信息,然後執行 dex 優化,所以只有已註冊到 PackageDexUsage 中的 dex 能被優化。 ````java // frameworks/base/services/core/java/com/android/server/pm/dex/DexManager.java(android-8.0.0) public boolean dexoptSecondaryDex(String packageName, String compilerFilter, boolean force) { // ... PackageUseInfo useInfo = getPackageUseInfo(packageName); // ... for (Map.Entry entry : useInfo.getDexUseInfoMap().entrySet()) { // ... int result = pdo.dexOptSecondaryDexPath(pkg.applicationInfo, dexPath, dexUseInfo.getLoaderIsas(), compilerFilter, dexUseInfo.isUsedByOtherApps()); // ... } // ... }

public PackageUseInfo getPackageUseInfo(String packageName) { return mPackageDexUsage.getPackageUseInfo(packageName); } 前文提到註冊 dex 時會將 dex 信息寫入文件,且在系統啟動創建 `PackageManagerService` 對象時會讀取文件中的 dex 信息,調用過程如下: new PackageManagerService -> DexManager.load -> DexManager.loadInternal -> PackageDexUsage.read ```` 所以即使從註冊 dex 到本次系統生命週期結束都沒滿足執行後台 dex 優化條件,但下次系統啟動後,以前註冊的 dex 還可以在滿足執行條件時被優化。

installd 服務運行於 installd 守護進程中,該進程在系統啟動時由 init 進程啟動,並在啟動時創建 installd 服務實例註冊到 ServiceManager 中。installd 服務的 dexopt 接口經過如下調用過程後,最終會執行 dex2oat 命令。 InstalldNativeService::dexopt -> android::installd::dexopt -> run_dex2oat

經過以上分析,可以確定創建 BaseDexClassLoader 或其子類對象可以讓動態加載的 dex 得到跟安裝的應用一樣的優化效果。

Android 10

創建 BaseDexClassLoader 或其子類對象,在 Android 10 及以上系統中,依然能在系統執行後台 dex 優化時對動態加載的 dex 進行優化,但從 Android 10 開始,系統引入了 class loader context,要求必須創建 PathClassLoaderDexClassLoaderDelegateLastClassLoader 的對象,可以選擇用 PathClassLoader。 ````java // frameworks/base/services/core/java/com/android/server/pm/dex/DexManager.java(android-10.0.0) /package/ void notifyDexLoadInternal(ApplicationInfo loadingAppInfo, List classLoaderNames, List classPaths, String loaderIsa, int loaderUserId) { // ... String[] classLoaderContexts = DexoptUtils.processContextForDexLoad( classLoaderNames, classPaths); // ... for (String dexPath : dexPathsToRegister) { // ... if (searchResult.mOutcome != DEX_SEARCH_NOT_FOUND) { // ... if (classLoaderContexts != null) { // ... if (mPackageDexUsage.record(searchResult.mOwningPackageName, dexPath, loaderUserId, loaderIsa, isUsedByOtherApps, primaryOrSplit, loadingAppInfo.packageName, classLoaderContext)) { mPackageDexUsage.maybeWriteAsync(); } } } else { // ... } // ... } }

// frameworks/base/services/core/java/com/android/server/pm/dex/DexoptUtils.java(android-10.0.0) /package/ static String[] processContextForDexLoad(List classLoadersNames, List classPaths) { // ... for (int i = 1; i < classLoadersNames.size(); i++) { if (!ClassLoaderFactory.isValidClassLoaderName(classLoadersNames.get(i)) || classPaths.get(i) == null) { return null; } // ... } // ... }

public static boolean isValidClassLoaderName(String name) { // This method is used to parse package data and does not accept null names. return name != null && (isPathClassLoaderName(name) || isDelegateLastClassLoaderName(name)); } ````

從 Android 10 開始,創建 DexFile 對象不再會觸發執行 dex2oat 命令,所以創建 PathClassLoader 對象已無法實現在初始時對 dex 進行基本優化。

同時,從 Android 10 開始,SELinux 增加了對應用執行 dex2oat 命令的限制,所以也無法通過 ProcessBuilderRuntime 執行 dex2oat 命令來對 dex 進行基本優化。在 file_contexts 文件中定義了 dex2oat 工具的安全上下文,指定了只有擁有 dex2oat_exec 的權限的進程才能執行 dex2oat。 ````

system/sepolicy/private/file_contexts(android-10.0.0)

/system/bin/dex2oat(d)? u:object_r:dex2oat_exec:s0 在 `seapp_contexts` 文件中指定了不同 targetSdkVersion 對應的進程安全上下文類型,例如當應用的 targetSdkVersion 大於等於 29 時,其進程安全上下文類型為 untrusted_app。

system/sepolicy/private/seapp_contexts(android-10.0.0)

user=_app minTargetSdkVersion=29 domain=untrusted_app type=app_data_file levelFrom=all user=_app minTargetSdkVersion=28 domain=untrusted_app_27 type=app_data_file levelFrom=all user=_app minTargetSdkVersion=26 domain=untrusted_app_27 type=app_data_file levelFrom=user user=_app domain=untrusted_app_25 type=app_data_file levelFrom=user ``` 可通過ps -Z命令來查看進程的安全上下文,結果跟規則指定的一致: * targetSdkVersion = 28:u:r:untrusted_app_27:s0:c101,c259,c512,c768* targetSdkVersion = 29:u:r:untrusted_app:s0:c101,c259,c512,c768`

不同進程安全上下文類型所擁有的權限定義在跟類型對應的文件中,與 targerSdkVersion 小於 29 的應用對應的權限規則文件中聲明瞭應用進程有 dex2oat_exec 類型的讀和執行權限,而與 targerSdkVersion 大於等於 29 的應用對應的文件中沒有對 dex2oat_exec 類型的權限的聲明,所以在 Android 10 及以上系統中,當應用的 targetSdkVersion 大於等於 29 時,無法在應用進程中執行 dex2oat 命令。 ````

system/sepolicy/private/untrusted_app_27.te(android-10.0.0)

The ability to invoke dex2oat. Historically required by ART, now only

allowed for targetApi<=28 for compat reasons.

allow untrusted_app_27 dex2oat_exec:file rx_file_perms; userdebug_or_eng(`auditallow untrusted_app_27 dex2oat_exec:file rx_file_perms;')

system/sepolicy/private/untrusted_app.te(android-10.0.0)

typeattribute untrusted_app coredomain;

app_domain(untrusted_app) untrusted_app_domain(untrusted_app) net_domain(untrusted_app) bluetooth_domain(untrusted_app) ````

PMS 通過 aidl 提供了 performDexOptSecondary 接口,可對 secondary dex 進行優化,且能指定編譯過濾器,可用來實現初始時的基本優化。該接口通過 Binder 的 shell command 方式對外暴露,調用過程如下: Binder.onTransact -> Binder.shellCommand -> PackageManagerService.onShellCommand -> ShellCommand.exec -> PackageManagerShellCommand.onCommand -> PackageManagerShellCommand.runCompile -> PackageManagerService.performDexOptSecondary 所以可通過反射獲取 PMS 的 Binder 接口實例,然後用對應的 transation code 來調 Binder 的 shell command 接口,傳入調 performDexOptSecondary 接口所需的參數的方式來讓 PMS 執行對 secondary dex 的優化。 kotlin fun performDexOptSecondaryByShellCommand(context: Context) { runCatching { val pm = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String::class.java).invoke(null, "package") as? IBinder var data: Parcel? = null var reply: Parcel? = null val lastIdentity = Binder.clearCallingIdentity() try { data = Parcel.obtain() reply = Parcel.obtain() data.writeFileDescriptor(FileDescriptor.`in`) data.writeFileDescriptor(FileDescriptor.out) data.writeFileDescriptor(FileDescriptor.err) val args = arrayOf("compile", "-f", "--secondary-dex", "-m", if (Build.VERSION.SDK_INT >= 31) "verify" else "speed-profile", context.packageName) data.writeStringArray(args) data.writeStrongBinder(null) ResultReceiver(Handler(Looper.getMainLooper())).writeToParcel(data, 0) val shellCommandTransaction: Int = '_'.toInt() shl 24 or ('C'.toInt() shl 16) or ('M'.toInt() shl 8) or 'D'.toInt() pm?.transact(shellCommandTransaction, data, reply, 0) reply.readException() } finally { reply?.recycle() data?.recycle() Binder.restoreCallingIdentity(lastIdentity) } }.onFailure { it.printStackTrace() } } 初始時對 dex 進行基本優化與應用安裝對應,使用的編譯過濾器也應保持一致,應用安裝場景使用的編譯過濾器由 pm.dexopt.install 系統屬性指定,其值為 speed-profile,在 Android 12 及以上系統中,可用新引入的 pm.dexopt.install-bulk-secondary 屬性的值 verify

綜上,可結合創建 PathClassLoader 對象和調 PMS 提供的 performDexOptSecondary 接口來對動態加載的 dex 進行效果跟安裝的應用一樣的優化。

小結

本文在分析系統相關實現的基礎上,介紹了在 Android 5.0 以來的各系統版本中實現對動態加載的 dex 進行優化,使執行速度與安裝的 APK 相當的方式: 1. 系統版本大於等於 5.0 且小於 8.0:使用 DexFile.loadDex 2. 系統版本大於等於 8.0 且小於 10:創建 PathClassLoader 對象 3. 系統版本大於等於 10:創建 PathClassLoader 對象,並通過 PMS Binder 的 shell command 調 performDexOptSecondary 接口

參考資料

本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!