插件化框架相关性能优化原创
一、插件化概述
插件化框架的发展经历了四个阶段:探索阶段、发展阶段、品质阶段、体验阶段。各个阶段有各个阶段需要完成的使命。
插件化框架的发展阶段
探索阶段,完成对诉求的确认;发展阶段,完成功能的开发与交付,完成插件化框架的架构和核心实现的落地。
插件化框架的架构
插件化框架的架构:
- API 层:主要是对外提供插件化的能力,允许业务方的封装、调用,实现相关需求;
- 框架层:主要是框架能力的实现层,包含着插件化框架的核心实现、核心功能;
- 基础层:主要是将核心实现中较为通用的能力,进行下沉,封装成基础能力,不仅可支持框架内使用,也可以对外赋能。
品质阶段,完成对功能问题的修复,保证功能的前提下,提升品质;体验阶段,完成对体验问题的挖掘和调优,提升用户体验,提升用户粘性。
插件化框架体验阶段遇到的问题
体验阶段遇到的问题:
- 功能引入的问题:
-
- 对于插件化框架来说,其功能实现存在对系统原有功能的改造,使用自定义或者Hook的方式来保证其功能,这个期间就会存在对系统一些内在优化的破坏,从而引入性能问题;
- 还存在一些功能,完全照搬了系统的官方实现,但并不一定适用于插件场景,应该跳出固有思维的限制,从外部思维重建流程,完成优化。
- 业务诉求引入的问题:
-
- 对于有些业务方,比如TV 端,由于其发版放量都比较麻烦,发版节奏基本上是半年一次,发版一次,周期基本上是一个月。所以业务方对工程做了改造,需要将宿主作为壳工程,然后将大部分的功能都放到插件中,来保证其功能尽可能的动态更新。那么此时这个插件就比较大,且对插件的整个流程的及时性要求就比较高,就存在性能瓶颈问题。
- 系统引入的问题:
-
- 这个同其他通用的优化类似,主要就是挖掘并解除系统一些性能限制,让插件各个场景的体验可以得到提升。
通过上述内容,应该对插件化整体有了一定的认识,当前字节内部插件化框架正处于体验阶段,如何挖掘并解决体验问题,将是此阶段的重点。
下面带着疑问和好奇,开始本文后面的探索~
二、性能优化
2.1 Multidex 优化
2.1.1 问题背景
通过线上监控发现,鲜时光App
在插件加载期间会出现由于Multidex
的执行(IO、反射等),导致的卡顿和ANR的问题。
鲜时光App
,主要面向于TV
端,其机型分布主要是Android 4
居多 ,对于Android 4
的版本上是需要进行Multidex
(主要解决Android 5 以下
的版本上,多dex
场景下,dex
的加载问题)的操作的。
2.1.2 问题分析
Multidex
的执行为什么会导致卡顿和ANR
的问题?
首先,Multidex
的执行,目前是在主线程中,其次Multidex
操作都比较重,其操作主要包含两步:
- 解压&压缩:将
Apk
中的除主Dex
以外的Dex
解压出来,再压缩成Zip
格式; - 反射填充:将除主
Dex
以外的Dex
,进行加载,然后将其加载后的内容,通过反射的方式,填充到原ClassLoader
中。
这样看Multidex
导致卡顿和ANR
的问题好像理由挺充分的…
那不执行Multidex
是否可行呢?
不行,原因在背景中也提到了,机型分布上,Android 4
的占比比较大,不能直接去掉。
那是否可以不在主线程中触发呢?
通过看目前框架的实现是不行的,原因在于,插件环境同宿主环境类似,插件同宿主中处理Multidex
的逻辑也一致,就是在插件的Application#onCreate
(其流程包含在插件加载中,且插件化框架保证其在主线程中执行)中触发Multidex
的行为。
但仔细想一下,宿主环境中处理Multidex
必须在Application#onCreate
中触发是有原因的,是因为宿主要保证尽可能早的完成除主Dex
以外,其他Dex
的加载,来保证宿主中使用这些非主Dex
的时候,也是OK的,这个是宿主中较早的时机。但是对于插件来说并不是,插件的流程中,插件加载是最后一步,前面还有下载、安装等行为,也就是说在插件加载之前,Multidex
的解压&压缩操作能够执行完成,然后插件加载的时候可以直接使用压缩后的Dex
路径,即可满足要求。
所以不在主线程中触发,是可行的!
而且在分析问题期间,通过阅读插件化框架源码发现,原本在插件加载期间触发的Multidex
同宿主一样并没有版本的概念,但是插件有版本的概念,就会导致为确保插件每次都能加载到正确版本下的内容,在插件每次加载的时候,都需要先进行清理旧的Multidex
产物(IO),然后再重新执行Multidex
(IO、反射),这个地方的操作明显是冗余的且没有必要的。
2.1.3 问题解决
目前看优化的思路也很清晰,主要是两点:1. 更改Multidex
的执行时机,确保不在主线程中执行; 2. 对Multidex
的产物区分版本,确保同一版本不重复触发执行。
通过修改插件化框架的源码,将Multidex
的解压&压缩操作的执行时机,修改为插件安装时(子线程中触发),且根据版本,将Multidex
产物放置到不同的路径下,这样插件加载时,只需获取重新压缩后的Dex
路径,无需反射,无需重复执行即可(无反射、无IO)。
2.1.4 优化效果
鲜时光上相关卡顿和ANR降低,App的整体使用时长和留存增加。
2.2 Dex2Oat & DexOpt 优化
对于Dex2Oat
、DexOpt
来说,它们是系统在运行时使用编译的方式来优化运行效果的手段。简单说就是将需要解释执行的指令,编译为机器码,从而加快运行时的速度,提升性能。
比较容易想到的肯定是,尽可能的将代码编译为机器码,来提升运行速度,从而往往忽略一个问题,就是Dex2Oat
、DexOpt
编译的耗时是很严重的,如果不能好好使用,反而可能会给性能带来负向。
下面将从两个方面来说一下Dex2Oat
、DexOpt
~
2.2.1 编译优化(10+编译优化)
2.2.1.1 问题背景
插件相较于宿主而言,也同样会进行Dex2Oat
、DexOpt
用于优化,插件上的操作是通过拼接Dex2Oat
的命令进行触发的。
但是通过观察,当应用targetSdkVersion>=29
且 Android 10+
的时候,插件没有相关的编译产物(odex
等),其原因是系统在targetSdkVersion=29
的时候,对此做了限制,不允许应用进程
上触发dex2oat
编译(Android 运行时 (ART) 不再从应用进程调用 dex2oat。这项变更意味着 ART 将仅接受系统生成的 OAT 文件)(OAT
为dex2oat
后的产物)
https://developer.android.com/about/versions/10/behavior-changes-10?hl=zh-cn#system-only-oat
2.2.1.2 问题分析
下面开始探索,限制究竟是什么?
通过对比Android 9
和 Android 10
上的相关代码,以及手动触发dex2oat
命令时候的错误日志,可以得知:
- 限制1:
Android 10+
系统删除了在构建ClassLoader
时触发dex2oat
的相关代码,来限制从应用进程
触发dex2oat
的入口。 - 限制2:
Android 10+
系统的相关SELinux
规则变更,限制targetSdkVersion>=29
的时候从应用进程
触发dex2oat
。
既然知道限制是什么以及如何生效的,那是否可以绕过呢?
通过上面对限制的了解,可以先大胆的假设绕过的方式:
targetSdkVersion
设置小于29
;- 伪装应用进程为系统进程;
- 关闭
Android
系统的SELinux
检测; - 修改规则移除限制;
下面开始小心求证,上述假设是否可行?
对于假设1
来说,如果全局设置targetSdkVersion
小于29
的话,则会影响App
后续在应用商店的上架,如果局部设置targetSdkVersion
小于29
的话,不仅难以修改且时机难以把握,dex2oat
是单独的进程进行编译操作的,不同的进程对其进行触发编译的时候,会将进程的targetSdkVersion
信息作为参数传给它,用于它内部逻辑的判断,而进程信息是存在于系统进程的。
对于假设2
来说,目前还没相关的已知操作可以做到类似效果…
对于假设3
来说,Android
系统确实也提供了关闭SELinux
检测的方法,但是需要Root
权限。
对于假设4
来说,如果全局修改规则,需要重新编译系统,才可以生效,如果局部修改规则(内存中修改),此处所需的权限也比较高,也无权操作。
所以,从目前来看,绕过基本不可行了…
或许谜底就在谜面上,既然Android
系统限制只能使用系统生成的,那我们就用系统生成的?
只需要让系统可以感知到我们的操作,可以根据我们提供的操作去生成,可以由我们去控制生成的时机以及效果,这样不如同在应用进程
触发dex2oat
有一样的效果了嘛?
那如何操作呢?
系统是否提供了可以供应用进程
触发系统行为,然后由系统触发dex2oat
的方式?
通过查阅Android
的官方文档以及相关代码发现可以通过如下方式进行操作(强制编译):
- 基于配置文件编译:
adb shell cmd package compile -m speed-profile -f my-package
- 全面编译:
adb shell cmd package compile -m speed -f my-package
强制编译:https://source.android.com/devices/tech/dalvik/jit-compiler#force-compilation-of-a-specific-package
上述命令不仅支持选择编译模式(speed-profile
or speed
),而且还可以选择特定的App
进行操作(my-package
)。
通过运行上述命令发现确实可以在targetSdkVersion>=29
且Android 10+
的系统上编译出对应的dex2oat
产物,且可以正常加载使用!
但是上述命令仅支持宿主Apk
并不支持插件Apk
,感觉它的功能还远不止于此,还可以继续挖掘一下这个命令的潜力,下面看下这个命令的实现。
分析之前需要先确定命令对应的代码实现,这里使用了个小技巧,通过故意输错命令,发现最终崩溃的位置在PackageManagerShellCommand
,然后通过debug
源码,梳理了一下完整的代码调用流程,细节如下:
下图为宿主Apk
的编译流程:
在梳理宿主Apk
的编译流程的时候,发现代码中也有处理插件Apk
的方法,下面梳理流程如下:
然后根据其代码,梳理其编译命令为:adb shell cmd package compile -m speed -f --secondary-dex my-package
至此,我们已经得到了一种可以借助命令使系统触发dex2oat
编译的方式,且可以支持宿主Apk
和插件Apk
(secondary-dex
)。
还有一些细节需要注意,宿主Apk
的命令传入的是App
的包名,插件Apk
的命令传入的也是App
的包名,那哪些插件Apk
会参与编译呢?
这就涉及到插件Apk
的注册了,只有注册了的插件Apk
才会参与编译。
下面是插件Apk
注册的流程:
对于插件Apk
来说只注册不反注册也不行,因为对于插件Apk
来说,每次编译仅想编译新增的或者未被编译过的,对于已经编译过的,是不想其仍参与编译,所以这些已经编译过的,就需要进行反注册。
下面是插件Apk
反注册的流程:
而且通过查看源码发现,触发此处的方式其实有两种:
- 方式一:使用
adb shell cmd package + 命令
。例如adb shell cmd package compile -m quicken com.bytedance.demo
,其底层通过socket+binder
完成通信,最终交由PackageManager
的Binder
处理。 - 方式二:使用
PackageManager
的Binder
,并设定code=SHELL_COMMAND_TRANSACTION
,然后将命令以数组的形式封装到data
内即可。
对于方式一来说,依赖adb
的实现,底层通信需要依赖socket + binder
,而对于方式二来说,底层通信直接使用binder
,相比来说更高效,所以最终选择第二种方式。
📌小结:
在得知限制无法被绕过后,就想到是否可以使得应用进程
可以触发系统行为,然后由系统触发dex2oat
,然后通过查阅官方文档找到对应的adb命令
可以满足诉求,不过此时仅看到宿主Apk
的相关实现,然后继续通过查看代码验证其流程,找到插件Apk
(secondary-dex
)的相关实现,然后根据实际场景的需要,又继续查看代码,找到注册插件Apk
和反注册插件Apk
的方法,然后通过对比adb命令
的实现和binder
的实现差异,最终选用binder
的实现方式,来完成上述操作。
2.2.1.3 问题解决
通过在问题分析中,从限制到实现,基本都探索清晰了,解决的方式就是将上述查到的代码转化为解决问题的代码即可。
操作
示例代码如下:
//执行快速编译
@Override
public void dexOptQuicken(String pluginPackageName, int version) {
//step1:如果没有初始化则初始化
maybeInit();
//step2:将apk路径进行注册到PMS
registerDexModule(pluginPackageName, version);
//step3:使用binder触发快速编译
dexOpt(COMPILE_FILTER_QUICKEN, pluginPackageName, version);
//step4:将apk路径反注册到PMS
unregisterDexModule(pluginPackageName, version);
}
//执行全量编译
@Override
public void dexOptSpeed(String pluginPackageName, int version) {
//step1:如果没有初始化则初始化
maybeInit();
//step2:将apk路径进行注册到PMS
registerDexModule(pluginPackageName, version);
//step3:使用binder触发全量编译
dexOpt(COMPILE_FILTER_SPEED, pluginPackageName, version);
//step4:将apk路径反注册到PMS
unregisterDexModule(pluginPackageName, version);
}
实现
/** * Try To Init (Build Base env) */ private void maybeInit() {
if (mContext == null || mPmBinder != null) {
return;
}
PackageManager packageManager = mContext.getPackageManager();
Field mPmField = safeGetField(packageManager, "mPM");
if (mPmField == null) {
return;
}
mPmObj = safeGetValue(mPmField, packageManager);
if (!(mPmObj instanceof IInterface)) {
return;
}
IInterface mPmInterface = (IInterface) mPmObj;
IBinder binder = mPmInterface.asBinder();
if (binder != null) {
mPmBinder = binder;
}
}
/** * DexOpt (Add Retry Function) */ private void dexOpt(String compileFilter, String pluginPackageName, int version) {
String tempFilePath = PluginDirHelper.getTempSourceFile(pluginPackageName, version);
String tempCacheDirPath = PluginDirHelper.getTempDalvikCacheDir(pluginPackageName, version);
String tempOatDexFilePath = tempCacheDirPath + File.separator + PluginDirHelper.getOatFileName(tempFilePath);
File tempOatDexFile = new File(tempOatDexFilePath);
for (int retry = 1; retry <= MAX_RETRY_COUNT; retry++) {
execCmd(buildDexOptArgs(compileFilter), null);
if (tempOatDexFile.exists()) {
break;
}
}
}
/** * Register DexModule(dex path) To PMS */ private void registerDexModule(String pluginPackageName, int version) {
if (pluginPackageName == null || mContext == null) {
return;
}
String originFilePath = PluginDirHelper.getSourceFile(pluginPackageName, version);
String tempFilePath = PluginDirHelper.getTempSourceFile(pluginPackageName, version);
safeCopyFile(originFilePath, tempFilePath);
String loadingPackageName = mContext.getPackageName();
String loaderIsa = getCurrentInstructionSet();
notifyDexLoad(loadingPackageName, tempFilePath, loaderIsa);
}
/** * Register DexModule(dex path) To PMS By Binder */ private void notifyDexLoad(String loadingPackageName, String dexPath, String loaderIsa) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
//deal android 11\12
realNotifyDexLoadForR(loadingPackageName, dexPath, loaderIsa);
} else {
//deal android 10
realNotifyDexLoad(loadingPackageName, dexPath, loaderIsa);
}
}
/** * Register DexModule(dex path) To PMS By Binder for R+ */ private void realNotifyDexLoadForR(String loadingPackageName, String dexPath, String loaderIsa) {
if (mPmObj == null || loadingPackageName == null || dexPath == null || loaderIsa == null) {
return;
}
Map<String, String> maps = Collections.singletonMap(dexPath, "PCL[]");
safeInvokeMethod(mPmObj, "notifyDexLoad",
new Object[]{loadingPackageName, maps, loaderIsa},
new Class[]{String.class, Map.class, String.class});
}
/** * Register DexModule(dex path) To PMS By Binder for Q */ private void realNotifyDexLoad(String loadingPackageName, String dexPath, String loaderIsa) {
if (mPmObj == null || loadingPackageName == null || dexPath == null || loaderIsa == null) {
return;
}
List<String> classLoadersNames = Collections.singletonList("dalvik.system.DexClassLoader");
List<String> classPaths = Collections.singletonList(dexPath);
safeInvokeMethod(mPmObj, "notifyDexLoad",
new Object[]{loadingPackageName, classLoadersNames, classPaths, loaderIsa},
new Class[]{String.class, List.class, List.class, String.class});
}
/** * UnRegister DexModule(dex path) To PMS */ private void unregisterDexModule(String pluginPackageName, int version) {
if (pluginPackageName == null || mContext == null) {
return;
}
String originDir = PluginDirHelper.getSourceDir(pluginPackageName, version);
String tempDir = PluginDirHelper.getTempSourceDir(pluginPackageName, version);
safeCopyDir(tempDir, originDir);
String tempFilePath = PluginDirHelper.getTempSourceFile(pluginPackageName, version);
safeDelFile(tempFilePath);
reconcileSecondaryDexFiles();
}
/** * Real UnRegister DexModule(dex path) To PMS (By Binder) */ private void reconcileSecondaryDexFiles() {
execCmd(buildReconcileSecondaryDexFilesArgs(), null);
}
/** * Process CMD (By Binder)(Have system permissions) */ private void execCmd(String[] args, Callback callback) {
Parcel data = Parcel.obtain();
Parcel reply = Parcel.obtain();
data.writeFileDescriptor(FileDescriptor.in);
data.writeFileDescriptor(FileDescriptor.out);
data.writeFileDescriptor(FileDescriptor.err);
data.writeStringArray(args);
data.writeStrongBinder(null);
ResultReceiver resultReceiver = new ResultReceiverCallbackWrapper(callback);
resultReceiver.writeToParcel(data, 0);
try {
mPmBinder.transact(SHELL_COMMAND_TRANSACTION, data, reply, 0);
reply.readException();
} catch (Throwable e) {
//Report info
} finally {
data.recycle();
reply.recycle();
}
}
/** * Build dexOpt args * * @param compileFilter compile filter * @return cmd args */ private String[] buildDexOptArgs(String compileFilter) {
return buildArgs("compile", "-m", compileFilter, "-f", "--secondary-dex",
mContext == null ? "" : mContext.getPackageName());
}
/** * Build ReconcileSecondaryDexFiles Args * * @return cmd args */ private String[] buildReconcileSecondaryDexFilesArgs() {
return buildArgs("reconcile-secondary-dex-files", mContext == null ? "" : mContext.getPackageName());
}
/** * Get the InstructionSet through reflection */ private String getCurrentInstructionSet() {
String currentInstructionSet;
try {
Class vmRuntimeClazz = Class.forName("dalvik.system.VMRuntime");
currentInstructionSet = (String) MethodUtils.invokeStaticMethod(vmRuntimeClazz,
"getCurrentInstructionSet");
} catch (Throwable e) {
currentInstructionSet = "arm64";
}
return currentInstructionSet;
}
2.2.2 不编译优化(4~9 不编译优化)
2.2.2.1 问题背景
通过线上监控发现,鲜时光App
在App启动期间较早的时机就开始加载插件,而加载需要等待插件安装完成,安装期间存在编译行为,其耗时比较长,从而导致整个的耗时也会较长,从而导致出现的ANR比较多,非常影响用户的使用体验,导致了比较多的用户流失,尤其是对新用户的影响较大。
2.2.2.2 问题分析
通过问题背景可以基本了解到,主要是Dex2Oat
、DexOpt
的编译影响到了用户的使用体验,如何能在没有编译产物的时候,跳过编译,让用户直接使用,有产物的时候,无需编译,也是让用户直接使用即可解决上述问题。
Android 4.x
在查阅相关文档的时候,发现抖音之前针对Multidex
做过优化(BoostMultidex),其中有一项操作,是以读取Dex
的byte
数组进行加载Dex
的操作,而此操作通过查阅源码发现并不会触发DexOpt
,从而可以用于跳过DexOpt
编译的方案。
BoostMultidex:https://github.com/bytedance/BoostMultiDex/blob/master/boost_multidex/src/main/cpp/boost_multidex.cpp
Android 5~7
对于Android 5~7
的版本上来说,有三种方案可以选择:
- 通过
Native Hook
的方式,关闭dex2oat
的开关,从而绕过产物生成; - 通过类似
OatMeal
的方式,生成Fake
的产物(基于原Dex
生成的编译产物,符合编译产物的格式,不会进行真正的编译),从而绕过产物的生成; - 通过提供不可用的编译产物路径,从而绕过产物生成;(当编译路径不可用时,编译会失败,且会走到fallback逻辑,从而加载原dex,保证功能可用)
下面分别分析下三种方案:
- 方案1:一方面,它关闭
dex2oat
的开关后,影响范围较大,不仅影响插件相关的产物编译,也会影响宿主的,相对来说更不可控(范围、影响等不可控);另一方面,Native Hook
对版本兼容要求较高,兼容成本和效果需要一定的时间来验证(时间、成本、效果等不可控)。 - 方案2:一方面
OatMeal
不支持64
位产物的生成;另一方面OatMeal
是按照Odex
、Vdex
产物的格式进行生成的,如果某些Rom
调整了相关的结构,就可能存在不兼容,兼容成本较高,范围可控(仅影响插件)。 - 方案3:通过提供不可用的编译产物路径,编译会立即失败,从而使得虚拟机走
fallback
流程,加载原dex
文件,即保证了绕过dex2oat
的编译,也无需Hook
,也无需做兼容处理,且范围可控,可谓成本低,收益大。
通过比对方案,最终选择方案3,用于Android 5~7
上跳过dex2oat
的编译方案(后续看诉求,可以将方案2进一步的完善,方案2的理论性能要更优于方案3)。
Android 8~9
对于Android 8.x
上新增的InMemoryClassLoader
就是专门用于在内存中加载dex
的方式,不会触发dex2oat
的编译,可以用于Android 8~9
上跳过dex2oat
的编译方案。
由于插件有自己自定义的ClassLoader
,并不是继承于InMemoryClassLoader
,需要一定的改造成本,在改造之前,可以先了解下其实现原理。
原理:Java
层中的DexFile
,将Dex
读取为ByteBuffer
的形式作为入参,然后再在Native
层中将ByteBuffer
的数据复制到MemMap
中,然后Native
层中的DexFile
接收 MemMap
作为入参,持有MemMap
的首地址和大小,用于后续使用的时候的内容读取。通过阅读源码发现官方的InMemoryClassLoader
的流程存在优化的点:
- 官方流程比较冗余,
Dex
的数据辗转传递多次(Zip->ByteBuffer->MemMap);通过阅读源码发现,Native
中存在对应的方法符号,可以将Zip
中的Dex
直接提取到MemMap
,并通过MemMap
创建对应的Native
层中的DexFile
(对应Java
层中DexFile#mCookie
,一个Java
层DexFile
可以对应多个Native
层中的DexFile
,所以Java
层中DexFile#mCookie
是个数组。) - 官方流程中
Java
层DexFile
同Native
层DexFile
一一对应关系; 前面也提到了Java
层中DexFile#mCookie
是个数组,可以存多个Native
层DexFile
,所以可以将创建出的多个Native
层DexFile
,进行合并,这样可以减少类查找时的JNI
切换,提升类查找速度。
2.2.2.3 问题解决
通过问题分析,对解决方案也就基本很清晰了,下面说明下不同版本上的处理:
- Android 4.x :对dex的读取和加载,其原理就是前面提到的,读取dex的内容为byte数组,然后使用byte数组进行加载dex,从而跳过Android 4.x 上的DexOpt;
- Android 5-7 : 在创建ClassLoader的时候,传入不可用的编译产物路径,从而导致编译失败,走虚拟机的fallback 流程,从而跳过Android 5~7 上的Dex2Oat;
- Android 8-9:对dex的读取和加载,其原理就是前面提到的,直接通过符号,将Apk中的dex提取到MemMap,并将每个dex生成对应的Native层DexFile,然后合并到一个Java层DexFile#mCookie数组中,从而跳过Android 8-9 上的Dex2Oat;
2.3 类加载优化
2.3.1 问题背景
对于插件化框架来说,为保证宿主、插件间的类能互通,需要对ClassLoader
的链路做改造。字节插件化框架上,是通过修改当前ClassLoader
的Parent
为PluginClassLoader
,从而完成类查找的分发,来确保宿主和插件之间,插件和插件之间的类查找可以互通。
通过线上数据观察,由于Plugin框架的这个改造,导致无法使用Native
层类查找链路,从而导致App
的整体性能都有一定劣化。
2.3.2 问题分析
在分析之前需要先了解一些前置的信息。
前提:ClassLoader B 的Parent ClassLoader 是 ClassLoader A。
- Java 层的类查找链路 A1 (纯Java环境):
可以发现,当从ClassLoader B
开始进行类查找的时候,会先使用ClassLoader B
的findLoadedClass
查找,确保B
中是否可以命中已加载的Class
,如果命中则停止查找,反之则继续查找其Parent ClassLoader ClassLoader A
,如果此时命中则停止查找,反之则触发ClassLoader A
中的findClass
方法,如果命中则停止查找,反之则会触发ClassLoader B
的findClass
方法进行查找,这就是一套符合双亲委派机制的类查找流程。
- Java层的类查找链路 A2 (Java环境+Native 环境)
这个是比较完整的Java
层的类查找链路,此处为了方便理解先暂时屏蔽掉Native
层类查找链路的入口。
可以看到,Java
环境中的FindLoadedClass
其实就是对应Native
环境中的ClassLinker#LookupClass
的逻辑,Java
环境中的FindClass
就是对应Native
环境中的ClassLinker#DefineClass
(会遍历对应ClassLoader
的DexFile
,遍历调用ClassLinker#DefineClass
这个方法)的逻辑。
Native
层的类查找链路B1
(Native
环境)
以Android 10 代码为例
可以看到,Native
层链路和Java
层链路基本上保持一致,也是双亲委派机制,Native
层链路的实现主要集中在ClassLinker#FindClassInBaseDexClassLoader
这个方法中 ,也就是说,Native
层中基本保留了一套同Java
层类查找链路一致的查找方案。
Java
层 +Native
层的类查找链路C1
(Java
环境 +Native
环境)
通过上图,可以看到,Java
层是如何同Native
层的类查找链路关联起来的。
在Java
层中触发ClassLoader#findLoadedClass
的时候,会先通过JNI
调用ClassLinker#LookupClass
方法,此时还是Java
层的类查找链路,但是此处不一样的地方就是,当ClassLinker#LookupClass
未命中对应的类且满足Native
链路的查找条件的时候,则会触发ClassLinker#FindClassInBaseDexClassLoader
方法,开始Native
层的类查找链路(参考B1
)。
还有一种Java
层类查找链路会切换到Native
层类查找链路,就是当Java
环境中触发findClass
的时候,当前类可以正常加载的时候,会判断其父类、父接口是否已经加载,此时会触发ClassLinker#resolvedClass方法,最终会调用到ClassLinker#FindClassInBaseDexClassLoader
方法,开始Native
层的类查找链路(参考B1
)
如果这种情况下不满足Native
层类查找链路的话,Native
环境通过反射调用ClassForName
方法切到Java
层类查找链路(参考A2),如果满足Native
层类查找链路的条件的话,则会继续使用Native
层类查找链路。
📌小结:
- 对于不满足
Native
层类查找链路条件的,则仅会在Java
层类查找链路中进行类的查找,即使进入Native
环境,也会通过ClassForName
的方式重新切回Java
层类查找链路中或者通过找不到类的方式切回Java
层类查找链路中。 - 对于满足
Native
层类查找链路条件的,会在Java
层类查找链路中触发ClassLoader#findLoadedClass
进入Native
环境,并进入Native
层类查找链路,完成类的查找;会在Java
层类查找链路触发ClassLoader#findClass
进入Native
环境,并进入Native
层类查找链路,完成类的查找。
通过前面的描述应该对Java
层类查找链路和Native
层类查找链路有了大致的认识,目前的关键点其实就是Native
层类查找链路条件是什么?以及如何才能满足其条件?
ClassLinker#FindClassInBaseDexClassLoader
判断&查找流程如下:
可以看到其关键的判断就是,是否是系统官方的ClassLoader
,这个判断是判断ClassLoader
的类否是官方的ClassLoader
,继承关系也不允许,主要是系统可能觉得非系统官方的ClassLoader其类查找链路是不可控的,所以不允许使用,这个也就是为什么增加了PluginClassLoader
就会打断其Native
层类查找链路。
现在由于PluginClassLoader
的加入,导致原本类的查找可以在一次JNI
调用中完成,而现在Native
链路不允许使用,则只能使用Java
层类查找链路,就会导致类的查找会存在额外的多次JNI
的调用切换(Java层查找失败则会尝试使用Native层查找,如果此时能找到,则查找结束,仅一次JNI调用,反之则失败,会返回Java层继续后续的流程,此时就会增加JNI调用),从而导致耗时增加。
2.3.3 问题解决
那如何解决呢?
其实目前看,主要的关键点就在于Native层链路中的判断,导致目前引入PluginClassLoader后无法通过校验,从而导致无法使用Native层类查找链路。
简单的想法,肯定是将Native层链路中的判断进行绕过,但是发现,其判断都是内联的,修改起来比较麻烦(可能会影响稳定性)。
那如果自定义Native层的类查找链路呢?这样判断与否,就由自己确定即可,而且原有的Native层类查找链路,对于插件场景并不支持,而自定义Native层的类查找链路,就可以根据现有的场景,进行支持,覆盖可以更广,可以减少更多的JNI调用,而且原本的Native层的类查找链路,还缺少类似findLoadedClass的逻辑,这个也可以在自定义Native层的类查找链路中补全。
如何自定义Native层的类查找链路呢?
目前看Native层的类查找链路的实现主要集中在ClassLinker#FindClassInBaseDexClassLoader 方法中,简单的说,也就是将其实现进行替换即可达到预期的目的。
如何替换呢?
这就需要分析ClassLinker#FindClassInBaseDexClassLoader方法的入口都在哪里,然后通过BL Hook,将预期跳转到ClassLinker#FindClassInBaseDexClassLoader方法,跳转到代理方法内即可。
上面在分析Java层和Native层类查找链路的时候,已经找到两处入口,还有一些其他的入口,比如VerifyClass 的时候,查找相关的类的时候,其入口。最后通过查阅源码发现,入口可以归纳为两处:
- ClassLinker#findClass
- VMClassLoader_findLoadedClass (ClassLoader#findLoadedClassJNI方法对应的Native方法)
也就是说,通过去找到上述两处方法中对ClassLinker#FindClassInBaseDexClassLoader的调用,然后进行替换即可。
需要注意的就是,对于Android 10 来说,其实现是比较完整的逻辑,而低版本上的逻辑实现存在遗漏,需要对其进行流程和功能上的补齐 。
三、总结
需要通过不断的熟读系统的代码和写的老代码,不断的挖掘瓶颈问题,见微知著,保持好奇,勇于尝试。
四、 团队介绍
AppHealth是Client Infrastructure下的一个端技术专家团队,专注于App性能、稳定性、构建等方向。旨在通过对操作系统内核、虚拟机、工具链、编译器方向的深度优化和建设包括稳定性、流畅性、电量在内全链路的监控体系和调试工具,为各个业务线提供行业领先的终端体验优化能力,助力公司业务的高效、高质发展。