dex 最佳化編年史

雲音樂技術團隊發表於2023-02-23
本文作者:熊大

引言

在熱修復和外掛化場景中會涉及動態載入 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。

// 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<std::string> 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<std::string>& 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 上依然適用。

// art/runtime/oat_file_manager.cc(android-7.0.0)
CompilerFilter::Filter OatFileManager::filter_ = CompilerFilter::Filter::kSpeed;

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

Android 8

在 Android 8.0 中,DexFile.loadDex 的呼叫過程基本不變,但編譯過濾器改為透過 GetRuntimeCompilerFilterOption 函式得到。

// 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

// 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.decryptdalvik.vm.dex2oat-filter 決定:

  1. 如果 vold.decrypt 屬性值等於 trigger_restart_min_framework1,則為 assume-verified
  2. 否則為 dalvik.vm.dex2oat-filter 屬性值

    // 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 方法

    // 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 單例。

// 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 中的方法被執行時也會被記錄

    // 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 天。

// 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.idleOptimization

BackgroundDexOptService.idleOptimization 方法中,會根據 dalvik.vm.dexopt.secondary 屬性值決定是否對 secondary dex 進行最佳化,使用 getprop 命令檢視可知該屬性值為 true,所以後臺 dex 最佳化的目標包含 secondary dex。

// 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.dexopt

DexManager.dexoptSecondaryDex 方法中,會先從 PackageDexUsage 獲取已註冊的 dex 資訊,然後執行 dex 最佳化,所以只有已註冊到 PackageDexUsage 中的 dex 能被最佳化。

// 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<String, DexUseInfo> 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

// frameworks/base/services/core/java/com/android/server/pm/dex/DexManager.java(android-10.0.0)
/*package*/ void notifyDexLoadInternal(ApplicationInfo loadingAppInfo,
        List<String> classLoaderNames, List<String> 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<String> classLoadersNames,
        List<String> 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 的最佳化。

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!

相關文章