性能文章>插件化框架相关性能优化>

插件化框架相关性能优化原创

345223

一、插件化概述

插件化框架的发展经历了四个阶段:探索阶段、发展阶段、品质阶段、体验阶段。各个阶段有各个阶段需要完成的使命。

插件化框架的发展阶段

探索阶段,完成对诉求的确认;发展阶段,完成功能的开发与交付,完成插件化框架的架构和核心实现的落地。

插件化框架的架构

插件化框架的架构:

  • 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 优化

对于Dex2OatDexOpt 来说,它们是系统在运行时使用编译的方式来优化运行效果的手段。简单说就是将需要解释执行的指令,编译为机器码,从而加快运行时的速度,提升性能。

比较容易想到的肯定是,尽可能的将代码编译为机器码,来提升运行速度,从而往往忽略一个问题,就是Dex2OatDexOpt 编译的耗时是很严重的,如果不能好好使用,反而可能会给性能带来负向。

下面将从两个方面来说一下Dex2OatDexOpt~

2.2.1 编译优化(10+编译优化)

2.2.1.1 问题背景

插件相较于宿主而言,也同样会进行Dex2OatDexOpt 用于优化,插件上的操作是通过拼接Dex2Oat的命令进行触发的。

但是通过观察,当应用targetSdkVersion>=29 且 Android 10+的时候,插件没有相关的编译产物(odex等),其原因是系统在targetSdkVersion=29的时候,对此做了限制,不允许应用进程上触发dex2oat编译(Android 运行时 (ART) 不再从应用进程调用 dex2oat。这项变更意味着 ART 将仅接受系统生成的 OAT 文件)(OATdex2oat后的产物)

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>=29Android 10+的系统上编译出对应的dex2oat产物,且可以正常加载使用!

但是上述命令仅支持宿主Apk并不支持插件Apk,感觉它的功能还远不止于此,还可以继续挖掘一下这个命令的潜力,下面看下这个命令的实现。

分析之前需要先确定命令对应的代码实现,这里使用了个小技巧,通过故意输错命令,发现最终崩溃的位置在PackageManagerShellCommand,然后通过debug源码,梳理了一下完整的代码调用流程,细节如下:

下图为宿主Apk的编译流程:

在梳理宿主Apk的编译流程的时候,发现代码中也有处理插件Apk的方法,下面梳理流程如下:

然后根据其代码,梳理其编译命令为:adb shell cmd package compile -m speed -f --secondary-dex my-package

至此,我们已经得到了一种可以借助命令使系统触发dex2oat编译的方式,且可以支持宿主Apk插件Apksecondary-dex)。

还有一些细节需要注意,宿主Apk的命令传入的是App的包名,插件Apk的命令传入的也是App的包名,那哪些插件Apk会参与编译呢?

这就涉及到插件Apk的注册了,只有注册了的插件Apk才会参与编译。

下面是插件Apk注册的流程:

对于插件Apk来说只注册不反注册也不行,因为对于插件Apk来说,每次编译仅想编译新增的或者未被编译过的,对于已经编译过的,是不想其仍参与编译,所以这些已经编译过的,就需要进行反注册。

下面是插件Apk反注册的流程:

而且通过查看源码发现,触发此处的方式其实有两种:

  1. 方式一:使用adb shell cmd package + 命令。例如adb shell cmd package compile -m quicken com.bytedance.demo ,其底层通过socket+binder完成通信,最终交由PackageManagerBinder处理。
  2. 方式二:使用PackageManagerBinder,并设定code=SHELL_COMMAND_TRANSACTION,然后将命令以数组的形式封装到data内即可。

对于方式一来说,依赖adb的实现,底层通信需要依赖socket + binder,而对于方式二来说,底层通信直接使用binder,相比来说更高效,所以最终选择第二种方式。

📌小结:

在得知限制无法被绕过后,就想到是否可以使得应用进程可以触发系统行为,然后由系统触发dex2oat,然后通过查阅官方文档找到对应的adb命令可以满足诉求,不过此时仅看到宿主Apk的相关实现,然后继续通过查看代码验证其流程,找到插件Apksecondary-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 问题分析

通过问题背景可以基本了解到,主要是Dex2OatDexOpt的编译影响到了用户的使用体验,如何能在没有编译产物的时候,跳过编译,让用户直接使用,有产物的时候,无需编译,也是让用户直接使用即可解决上述问题。

Android 4.x

在查阅相关文档的时候,发现抖音之前针对Multidex做过优化(BoostMultidex),其中有一项操作,是以读取Dexbyte数组进行加载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 的版本上来说,有三种方案可以选择:

  1. 通过Native Hook的方式,关闭dex2oat的开关,从而绕过产物生成;
  2. 通过类似OatMeal的方式,生成Fake的产物(基于原Dex生成的编译产物,符合编译产物的格式,不会进行真正的编译),从而绕过产物的生成;
  3. 通过提供不可用的编译产物路径,从而绕过产物生成;(当编译路径不可用时,编译会失败,且会走到fallback逻辑,从而加载原dex,保证功能可用)

下面分别分析下三种方案:

  1. 方案1:一方面,它关闭dex2oat的开关后,影响范围较大,不仅影响插件相关的产物编译,也会影响宿主的,相对来说更不可控(范围、影响等不可控);另一方面,Native Hook 对版本兼容要求较高,兼容成本和效果需要一定的时间来验证(时间、成本、效果等不可控)。
  2. 方案2:一方面OatMeal不支持64位产物的生成;另一方面OatMeal是按照OdexVdex 产物的格式进行生成的,如果某些Rom调整了相关的结构,就可能存在不兼容,兼容成本较高,范围可控(仅影响插件)。
  3. 方案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的流程存在优化的点:

  1. 官方流程比较冗余,Dex的数据辗转传递多次(Zip->ByteBuffer->MemMap);通过阅读源码发现,Native中存在对应的方法符号,可以将Zip中的Dex直接提取到MemMap,并通过MemMap创建对应的Native层中的DexFile(对应Java层中DexFile#mCookie,一个JavaDexFile 可以对应多个Native层中的DexFile,所以Java层中DexFile#mCookie是个数组。)
  2. 官方流程中JavaDexFile 同 Native 层DexFile 一一对应关系; 前面也提到了Java层中DexFile#mCookie是个数组,可以存多个NativeDexFile,所以可以将创建出的多个NativeDexFile ,进行合并,这样可以减少类查找时的JNI切换,提升类查找速度。

2.2.2.3 问题解决

通过问题分析,对解决方案也就基本很清晰了,下面说明下不同版本上的处理:

  1. Android 4.x :对dex的读取和加载,其原理就是前面提到的,读取dex的内容为byte数组,然后使用byte数组进行加载dex,从而跳过Android 4.x 上的DexOpt;
  2. Android 5-7 : 在创建ClassLoader的时候,传入不可用的编译产物路径,从而导致编译失败,走虚拟机的fallback 流程,从而跳过Android 5~7 上的Dex2Oat;
  3. 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(会遍历对应ClassLoaderDexFile,遍历调用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性能、稳定性、构建等方向。旨在通过对操作系统内核、虚拟机、工具链、编译器方向的深度优化和建设包括稳定性、流畅性、电量在内全链路的监控体系和调试工具,为各个业务线提供行业领先的终端体验优化能力,助力公司业务的高效、高质发展。

点赞收藏
分类:标签:
火山引擎开发者服务

火山引擎应用性能监控全链路版,经字节内部众多APP实践验证、提供APP、Web、小程序、服务端、PC、OS端在内的APM服务,通过先进的数据采集技术,为用户优化应用性能助力。

请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
2