Android热修复:底层替换类加载原理总结 及 DexClassLoader类加载机制源码探索

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android热修复:底层替换类加载原理总结 及 DexClassLoader类加载机制源码探索相关的知识,希望对你有一定的参考价值。

前言

新型产品的诞生,自然是源于人们的需求,而新型技术的出现,也是昭示着技术在日新月异的发展中出现了缺陷,需要提高其“健壮性”,解决问题。万物诞生皆有因,何物不尽然? —–From LemonGuo

在早期的android开发中,若客户端发布更新后遇到紧急bug需要及时修复,通常只能等待新版本解决再次发布,但是重新发布版本代价太大且用户下载安装成本高,若Bug未及时修复,无疑会严重影响用户体验。

因此衍生了一系列技术来解决以上问题,其中一种就是Hybrid方案,即将需要经常变更的业务逻辑以H5的形式独立出来,但是它要求传统的java开发者了解前端知识,无疑增加了学习成本,还需对原先应用中的逻辑进行合理的抽象和转化,最重要的是有些代码无法转为H5形式,则意味着代码将无法修复!还有一种是插件化方案,例如Atlas或DroidPlugin,但此方案要求掌握整套插件化工具,并且改造原先的代码,这样移植成本过高,对于中小型APP而言,明显过于笨重。

因此在Android开发近几年中,又衍生出了新的解决方案——热修复。开发者可以直接将更新补丁上传到云端,此时App从云端下拉补丁直接应用生效,即直接在用户已安装的程序中修复bug。这项技术被广泛应用于大多数App,比较有代表性的的App是阿里系的优酷和支付宝,腾讯系的微信,两者使用的都是自行研发的方案,可见热修复技术对移动端发展的重要性。

笔者早已久闻“热修复”大名,经过一段时间学习后,以此系列来记录专研其技术,计划讲解知识点如下:

  • 热修复及原理简易介绍
  • DexClassLoader类加载机制源码详解
  • 以类加载角度出发自行实现demo完成热修复技术
  • Tinker库原理及使用详解
  • 底层替换原理详解
  • 以底层替换角度出发自行实现demo完成热修复技术
  • AndFix库原理详解

由上可见,此系列对热修复进行大致介绍科普后,分别以热修复类加载、底层替换的原理深入探究,并衍生去自行实现demo切实了解其技术实现的核心关键,最后学习大厂研发的Tinker库等的原理及使用。




此篇为本系列第一篇博文,首先会简单介绍热修复相关概念,市面上各技术库优劣对比,并简易总结实现其技术的几大原理,在第二大点中详细介绍了Android中的类加载机制及源码探索。

一. 热修复介绍

1. 简介

定义

顾名思义,就是动态修复、更新App功能、行为等,也被称为动态更新。

可解决的典型问题

  • 刚发布的应用出现闪退、ANR等bug,及时修复 。
  • 及时推送一些小的功能给用户使用。

优势

  • 无需重新发布,实时高效修复bug
  • 用户无需操作,无需下载新的应用
  • 修复成功率高,降低损失

App并非拥有热修复功能就可一劳永逸了,准确而言它是一个亡羊补牢的措施。其实发布热修复版本和正式版本的节奏相同,都需要通过测试,并且市面上大部分热修复框架都有版本兼容问题,因此稳定App版本发布加上热修复技术才是完美配合。


2. 流行技术及版本比较

目前市场上比较流行的技术如下:

  • QQ空间的超级补丁方案
  • 微信的Tinker
  • 阿里的AndFix、dexposed(还有最新的Sophix)
  • 美团的Robust,ele的migo,百度的hotfix……

上图技术比较中,除了基本的类、so、资源替换等等,需要注意一下“gradle支持”条件,笔者认为此条件更为重要。以上技术对比中可见微信的Tinker功能较为齐全。

阿里官方已经说明Andfix具有局限性,其底层固定结构的替换方案稳定性不好,且使用范围也存在诸多限制,虽然可通过改造代码绕过限制来达到相同修复目的,但此种方法既不优雅也不方便,更大的问题是Andfix直提供了代码层面的修复,对于资源和so修复都为实现!

因此阿里后续又发布了Sophix,相较于之前的Andfix,在Android热修复三大领域:代码、资源、so修复方面具有很大提升,并且保障了方案的安全性和易用性。以下贴出阿里官方比较图供参考:

如上图,Sophix唯一明显不足的是不支持四大组件的修复,因为四大组件修复要求在AndroidManifest里预先插入代理组件,并且尽可能声明所有权限,这些操作会给原app添加很多臃肿代码,对app运行流程的侵入型很强!因此阿里本着对开发者透明与代码极简原则,未做多余处理。

技术选型

在了解以上技术对比后,需要注意并非综合最佳的技术就是最适合的,需要理智选择,例如Sophix功能强大,但它并无Amigo支持四大组件修复的功能。因此开发者在进行技术选型时提供以下建议:

  • 根据自身需求来衡量一切标准;
  • 在满足需求的条件下,选择学习成本低的,学习资料充分的;
  • 在满足以上条件下,优先选择大型公司研发出来的方案,在产品稳定性和后期维护上更有保障;

3. 典型的热修复原理

目前市场上热修复有两大主流方案,分别是阿里系的底层替换方案,腾讯系的类加载方案,优劣如下:

  • 底层替换方案:从底层C的二进制来解决问题,这样做限制颇多,但时效性最好,加载轻快,立即见效;
  • 类加载方案:从Java加载机制来解决问题,这样做时效性差,需要重新冷启动才能见效,但修复范围广,限制少;

(1)底层替换方案

底层替换方案的原理是直接在已加载类中替换掉原有方法,即在原来类基础上进行修改,因此无法实现增减原有类方法或字段,这样会破坏原有类的结构。

不仅如此,一旦补丁类中的方法数量有增减,会直接导致此类以及整个Dex的方法熟变化,从而访问方法时无法正常索引到正确方法。若字段发生了增减,和方法数变化情况相同,而且所有字段索引都会变化。更严重的后果是,若程序运行中间某个类突然增加字段,那么对于原先已经产生的类实例,它还是原来的结构,而新方法使用到这些“过期”实例对象时,访问新增字段就会产生不可预期的结构!

以上是底层替换方案的固有限制,既然决定从底层出发,那么必定就要承担它本身带来的问题。

不仅如此,其中最令人诟病的地方就是它的稳定性,传统的底层替换方式如Dexposed、Andfix及其他安全界的Hook方案都是直接依赖修改虚拟机方法实体的具体字段例如修改Dalvik方法的jni函数指针、修改类或方法的访问权限等。这里埋藏着一个隐患,由于Android是开源的,各个手机厂商都可以对代码进行改造,而Andfix里ArtMethod结构题进行了修改,就和原先开源代码结构不同,导致在修改过了的设备上,通用性的替换机制会出问题,这就是不稳定的根源。

而hotfix技术框架针对以上的问题做了完善,它实现的是一种无视底层具体结构的替换方式,不仅解决了兼容性问题,并且忽略了底层ArtMethod结构的差异,从而对所有Android版本都兼容,大量减少代码量。即使以后都Android版本不断修改ArtMethod的成员,只要保证ArtMethod数组仍是线性结构排列,就可适应以后Android新版本,也无须针对新的系统版本进行适配。

(2)类加载方案

类加载方案的原理是在app重新启动后让Classloader加载新的类。因为当app运行到一半时,所需发生变更的类已经被加载过,而在Android上无法对一个类进行卸载操作,若不重启,原来的类还存储于虚拟机中,新类无法被加载。因此只有在下次重启时,在业务逻辑运行之前抢先加载补丁中的新类,这样后续访问此类时,才会Resolve为新类,从而达到热修复的目的。

说到腾讯系三大类加载方案的实现原理,QQ空间方案会侵入打包流程,可能为了hack而添加一些无用信息;而QFix的方案需要获取底层虚拟机的函数,不够稳定,最大的问题时无法增加public函数。微信的Tinker方案是完整的全量dex加载,将补丁做到了极致,其合成方案是从dex方法和指令维度进行全量合成,整个过程是自己研发的。

在上一部分技术比较中也体现出了微信的Tinker方案的综合优势,但是结合上一段所说它采用的dex全量合成,可以很大地节省空间,但由于对dex内容的比较粒度过细,实现较复杂,对于性能会有所损耗。实际上dex占据APK的比例是很小的,资源文件才是占据APK的主要部分,因此Tinker用空间换取性能的转换并非理想。

此种方案虽然尤其限制,但也有提升空间:dex比较多最佳粒度,在于类的维度,它既不像方法和指令维度那样细微,也不像bsbiff那样粗糙,因此在类的维度上是可以达到时间和空间平衡的最佳效果。

(3)两者结合方案

上述分析可见底层替换方案和类加载方案都有各自的优缺点,阿里的Sophix技术结合了两张方案,可灵活地根据实际情况切换。在补丁生成阶段,补丁工具会根据实际代码变动情况进行自动选择,针对一些在底层替换方案限制范围内的小修改,就直接采用底层替换方案,便于修复即时生效;而对于代码修复超出底层替换限制的,采用类加载方案,虽然及时性不太好,但可达到热修复的目的。

不仅如此,Sophix在运行时阶段,还会判断所运行机型是否支持热修复,防止部分机型底层虚拟机构造不支持情况,可以执行类加载方案,从而达到最好的兼容性。



4. 资源修复和so库修复

Google官方Instant Run方案资源修复原理

说起Android热修复浪潮的主因,不得不提Instant Run的实现,市面上大多数资源热修复方案基本参考了Instant Run的实现。简要而言,Instant Run中的资源热修复分为两步:

  • 首先构造一个新的AssetManager,并通过反射调用addAssetPath 方法,把这个完整的新资源包加入到AssetManager中,这样就获得了一个含有所有新资源的AssetManager
  • 找到所有之前引用到原有AssetManager的地方,通过反射将引用处替换成AssetManager。

阿里实现资源修复原理

阿里对于“资源修复”这一块没有直接采用Instant Run技术,而是构造一个package id为0x66的资源包,该包只包含修改了的资源项,然后直接在原有AssetManager中调用addAssetPath 方法添加此包即可。由于补丁包的package id 为0x66,不与目前已经加载的0x7f冲突,因此直接加入到已有的AssetManager中就可以使用了。补丁包中的资源只包含原有包里没有的新增资源,以及原有内容发生改变的资源,并且采用的替换方式是直接在原有的AssetManager对象上进行析构和重构,这样所有原先对AssetManager对象的引用是没有改变的,因此无需像Instant Run那样繁琐修改引用了。

两者比较

总之阿里的资源修复方案相较于Google官方研制的Instant Run方案,优势如下:

  • 不需要修改AssetManager的引用处,替换更快更完全。(对比Instant Run以及所有copycat的实现)
  • 不必下发完整包,补丁包中只包含有变动的资源。(对比Instant Run以及所有Amigo等方式的实现)
  • 不需要在运行时合成完整包,不占用运行时计算和内存资源。(对比Tinker的实现)

so库修复

so库的修复本质上是对native方法对修复和替换。

阿里采用的是类似类修复反射注入方式,即把补丁so库的路径插入到nativeLibraryDirectories数组的最前面,就能够达到加载so库时的是补丁库,并非原来so库的目录,从而达到修复的目的。Sophix是在启动期间反射注入patch中的so库,对开发者依然透明,而其他方案则需要手动替换系统的System.load来实现替换目的。


(以上阿里叙述部分及相关图片来自于《深入探索Android热修复技术原理》一书,书中可能在评价其他产品稍稍带有主观意识,但在技术原理比较部分很客观了,特别是在对比官方、其他第三方库实现功能原理,阿里在书中给出了自己的实现思路,从不同的角度剖析问题、讲解透彻,给了笔者醍醐灌顶的感觉。)

ps. 尽量在上述内容去掉了“优雅”二字,书中特别喜欢形容自我“优雅” :)




二. Android类加载机制源码探究

注意:此大点只是重点研究Android类加载机制源码,涉及到的热修复的原理后篇文章讲解!

1. JVM类加载之双亲委派模式

(此小节只做简单介绍,详细分析请阅读笔者的另一篇文章:JVM高级特性与实践(九):类加载器 与 双亲委派模式(自定义类加载器源码探究ClassLoader)

(1)介绍

Java开发者对于“双亲委派模式”必然不陌生,这是JVM中的一个重要知识点,它是类加载器的重要特征,类加载器分类如下:

  • 启动类加载器:负责将指定类库加载到虚拟机内存中。无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用null代替即可。
  • 拓展类加载器:负责将指定类库加载到内存中。开发者可以直接使用标准扩展类加载器
  • 自定义类加载器:负责用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

上图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。

(2)工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器,每一个层次的加载器都是如此,依次递归,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它搜索范围中没有找到所需类)时,子加载器才会尝试自己加载。

(3)模式优点

使用双亲委派模型来组织类加载器之间的关系,好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。

相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,用户编写了一个java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但是永远无法被加载运行。



2. Android类加载介绍

Android中的ClassLoader类加载机制主要用来加载dex文件,系统提供了PathClassLoader、DexClassLoader两个API可供选择。ClassLoader种类如下:

  • BootClassLoader
  • BaseDexClassLoader:父类
  • PathClassLoader:只能加载已安装到Android系统的APK文件;
  • DexClassLoader:支持加载外部的APK、Jar或dex文件;(所有的插件化方案都是使用它来加载插件APK中的.class文件,也是动态加载的核心依据!)

如上,在简单理解之后发现Android的ClassLoade和Java的大体上是一一对应的,只不过内部实现有些变化。思考一个问题,一个App正常运行最少需要哪些ClassLoade?

答案揭晓:最少需要BootClassLoader和PathClassLoader。首先BootClassLoader是无可或缺的,因为它需要加载framework层的一些class文件,而PathClassLoader用来加载已安装到系统上的文件。

因此一个应用运行至少需要以上两个ClassLoade,下面通过一个简单demo来证实以上猜想。

public class MainActivity extends AppCompatActivity 

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ClassLoader classLoader = getClassLoader();
        if(classLoader != null)

            Log.e("lemonnnnnn", "classLoader: " + classLoader.toString());

            while (classLoader.getParent() != null)
                classLoader = classLoader.getParent();
                Log.e("lemonnnnnn", "classLoader: " + classLoader.toString());
            
        
    

上述测试代码逻辑也很简单,获取并输出加载当前应用的类加载器,然后再判断其父加载器并输出(双亲委派模式)。查看控制台显示可知输出了PathClassLoader、BootClassLoader,因此证实了以上猜想。

双亲委派模式 特点及作用

  • 类加载的共享功能,一些framework层的类被顶层classLoader加载过后会缓存在内存中,避免重复加载。
  • 类加载的隔离功能,不同继承实现的classLoader加载的类肯定不会是同一个类,一些系统层级类java.lang.String 会在初始化时被加载,可避免用户写代码访问核心类库可见的成员变量。 例如java.lang.String就是在系统启动之前就已经加载好,用户可自定义一个String类提前加载与之替换,这会带来严重的安全问题。

上述就引发出一个问题:如何的两个类才算是相同的类呢?两个类的包名、类名相同即可?并非如此!还需加上一个条件:同一个ClassLoader加载,以上三个条件成立,这两个类才能被称为相同类。



3. Android类加载源码过程解析

此处的ClassLoader是java.lang包下的,因此与那篇讲解Java类加载机制中讲解的逻辑大同小异,最多只是版本上的差别,无须赘述,最大的区别则在于继承此类并实现的一些类,也就是Android的dalvik.system包下的BaseDexClassLoader、PathClassLoader、DexClassLoader,见下图:

如上图,在AS编辑器中点进详情无法阅读dalvik.system包下类源码,接下来在网页中提供源码作以分析。

(1)DexClassLoader源码分析

package dalvik.system;
import java.io.File;

public class DexClassLoader extends BaseDexClassLoader 

    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) 
        super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
    

以上源码可以看到DexClassLoader类中只有一个构造方法,4个参数含义分别是:

  • dexPath:指定要加载dex文件的路径;
  • optimizedDirectory:指定dex文件需要被写入的目录,一般是应用程序内部路径(不可以为null);
  • librarySearchPath:包含native库的目录列表(可能为null);
  • parent:父类加载器;

DexClassLoader类注释:用来加载包含dex的jar包或apk中的类,也可以执行于尚未安装到应用中的代码,因此它才是动态加载的核心!


(2)PathClassLoader源码分析

package dalvik.system;

public class PathClassLoader extends BaseDexClassLoader 
    public PathClassLoader(String dexPath, ClassLoader parent) 
        super(dexPath, null, null, parent);
    

public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) 
        super(dexPath, null, librarySearchPath, parent);
    

有别于DexClassLoaderPathClassLoader只是一个简单的类加载器实现,运行于本地文件系统中的文件和目录,但不尝试从网络加载类。Android用此类加载器PathClassLoader来加载一些系统级别类和已存在于应用中的类。

查看源码可知PathClassLoader有两个构造方法,其参数相较于DexClassLoader少了一个指定dex文件需要被写入的内部目录optimizedDirectory,因此PathClassLoader只能加载已安装到应用的dex文件。


(3)BaseDexClassLoader源码分析

以上DexClassLoaderPathClassLoader两个类源码没有具体实现,最大的区别在于后者只能加载已安装于应用的dex文件,而详情部分还是要参数它们的父类——BaseDexClassLoader

上图是BaseDexClassLoader类重点源码部分,类中只有一个成员变量DexPathList,暂不理会其含义,继续查看其构造方法,其中创建了DexPathList对象,传入了四个参数,分别是:

  • DexClassLoader:父类加载器本身;
  • dexPath:需要加载的dex文件路径;
  • librarySearchPath:包含native库的目录列表(可能为null);
  • optimizedDirectory: dex文件需要被写入的内部目录(可能为null);

BaseDexClassLoader 构造方法中的这些参数是其子类传过来的,之前介绍过,并不陌生,只是对于在其构造方法中只做了一件事——创建DexPathList对象,有些不解。继续查看重点方法findClass(String name),重点部分笔者用红框圈出来了,通过成员变量dexList的findClass 加载获取的类返回,若类为null则报错,此处意味着真正执行加载类的重点部分并非是BaseDexClassLoader,它也只是一个中介,真相在于DexPathList类,继续延伸查看此类。


(4)DexPathList源码分析——背后的Boss

首先查看它的一些重要成员变量:

  • DEX_SUFFIX:字符串类型,值是”.dex”;
  • definingContext: ClassLoader类型,加载器,也就是BaseDexClassLoader 构造方法中创建DexPathList时传入的加载器;
  • dexElements: Element[]类型,Element是一个内部类。此类作用就是指定dex/resource/native 库路径,其内部重要成员DexFile的dexFile,这是dex文件在Dalvik安卓虚拟机中的具体实现,稍后讲解;
  • 后续成员变量类型类似,只是代表不同数据,不再赘述……

接下来查看其构造方法:

查看其构造方法,就是用来接收参数并对成员变量赋值。由此可知参数definingContext(即ClassLoader)、dexPath一定不可为null,否则直接报异常,optimizedDirectory被写入内部的目录可能为null(即使用默认系统目录),然而重点在于笔者圈起来的第二个红框,调用内部makeElements方法获取Element数组赋值给成员变量dexElements。深入查看,如何通过上述几个参数获得Element数组,此方法有几个重载,最终调用的方法如下:

    private static Element[] makeElements(List<File> files, File optimizedDirectory, List<IOException> suppressedExceptions,boolean ignoreDexFiles,ClassLoader loader) 
        Element[] elements = new Element[files.size()];
        int elementsPos = 0;

        //循环遍历所有File并加载dex
        for (File file : files) 
            File zip = null;
            File dir = new File("");
            DexFile dex = null;
            String path = file.getPath();
            String name = file.getName();

            if (path.contains(zipSeparator)) 
                String split[] = path.split(zipSeparator, 2);
                zip = new File(split[0]);
                dir = new File(split[1]);
             else if (file.isDirectory()) 
                //若果该file是文件夹格式,则继续递归
                 elements[elementsPos++] = new Element(file, true, null, null);
             else if (file.isFile()) 
                if (!ignoreDexFiles && name.endsWith(DEX_SUFFIX))                     
                // ⭐️⭐️⭐️⭐️⭐️若该file是文件且是以dex后缀结尾,说明正是需要加载的文件,调用loadDexFile去创建一个dex(DexFile类型)
                    try 
                        dex = loadDexFile(file, optimizedDirectory, loader, elements);
                     catch (IOException suppressed) 
                        System.logE("Unable to load dex file: " + file, suppressed);
                        suppressedExceptions.add(suppressed);
                    
                 else 
                    zip = file;
                    // ⭐️⭐️⭐️⭐️⭐️若该file是压缩文件,调用loadDexFile去创建一个dex(DexFile类型)
                    if (!ignoreDexFiles) 
                        try 
                            dex = loadDexFile(file, optimizedDirectory, loader, elements);
                         catch (IOException suppressed)                            suppressedExceptions.add(suppressed);
                        
                    
                
             else 
                System.logW("ClassLoader referenced unknown path: " + file);
            

            if ((zip != null) || (dex != null)) 
                elements[elementsPos++] = new Element(dir, false, zip, dex);
            
        
        if (elementsPos != elements.length) 
            elements = Arrays.copyOf(elements, elementsPos);
        
        return elements;
    

如上DexPathList类的重点方法makeElements源码,方法中的参数经过上述源码讲解后也能够知名见意了,只有一个需要特别说明:files其实是对dexPath的一个转化,获得了该路径内的所有文件。笔者已在源码中加以注释,主要逻辑就是循环遍历files(由dexPath转化的),文件中可能包含文件夹或压缩文件,分别判断,找到后缀为dex文件,调用loadDexFile加载生成DexFile文件(⭐️注释处),最后将生成的dex文件和路径等信息传入Element构造方法来创建对象,返回Element数组

此方法makeElements逻辑并不复杂,需要格外注意一下内部临时变量dex,它是DexFile类型,代表着dex文件。在makeElements方法中判断file是文件格式或者zip压缩格式时,都会调用此方法来创建DexFile对象,具体有何不同呢?进一步查看loadDexFile方法源码,查看其内部细节:

    /*
     *实例化DexFile类
     */
    private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
                                       Element[] elements)
            throws IOException 
        if (optimizedDirectory == null) 
            return new DexFile(file, loader, elements);
         else 
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
            return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
        
    

查看loadDexFile源码,其主要作用就是创建DexFile对象返回,
首先判断写入目录optimizedDirectory是否为null,如果为null表明file确实是一个dex文件,直接创建DexFile,否则会先将其解压获取真正DexFile文件。

DexPathList类的makeElements方法核心作用就是:将指定加载路径dexPath的所有文件遍历获取dex文件,并转换成DexFile类型存储到Element数组中。(Element数组的作用是为了后续DexPathList类的findClass方法铺垫)

下面就来解析最万众瞩目的重点,DexPathList类的findClass方法:

public Class findClass(String name, List<Throwable> suppressed) 
        //遍历
        for (Element element : dexElements) 
            DexFile dex = element.dexFile;

            if (dex != null) 
            //通过dex来加载类返回字节码⭐️⭐️⭐️⭐️⭐️
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) 
                    return clazz;
                
            
        
        if (dexElementsSuppressedExceptions != null) 
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        
        return null;
    

此方法的逻辑也不难,就是遍历之前makeElements 方法中存储好的Element数组,将Element类型转换为DexFile类型,调用DexFile的内部方法loadClassBinaryName在dex文件中查找获取拼接成class字节码文件返回。

小结

Android中类加载器的PathClassLoaderDexClassloader所调用的findClass方法其实并非是自己实现的,它们内部只实现了本身的构造方法,因此调用的是其父类BaseDexClassLoader中实现的方法,可是最后追述到的真正实现者是DexPathList类!由它来具体实现了findClass方法,而此方法内部具体是调用了DexFile的核心内部方法loadClassBinaryName实现重要功能:在dex文件中查找获取拼接成class字节码文件返回。

DexPathList源码


(5)DexFile源码分析——Boss的心腹

下面具体查看DexFile的核心内部方法loadClassBinaryName实现:

    public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) 
        return defineClass(name, loader, mCookie, this, suppressed);
    
    private static Class defineClass(String name, ClassLoader loader, Object cookie,
                                     DexFile dexFile, List<Throwable> suppressed) 
        Class result = null;
        try 
            //⭐️⭐️⭐️⭐️⭐️
            result = defineClassNative(name, loader, cookie, dexFile);
         catch (NoClassDefFoundError e) 
            if (suppressed != null) 
                suppressed.add(e);
            
         catch (ClassNotFoundException e) 
            if (suppressed != null) 
                suppressed.add(e);
            
        
        return result;
    

以上源码可以发现代码的一个设计准则:loadClassBinaryName方法类型是public的,可供外部调用,但其内部只有调用defineClass方法这一行代码,而defineClass方法类型是private的,仅供内部调用,因此它只是借助loadClassBinaryName 方法做了一层封装,保持了私有性!

继续查看defineClass方法源码,逻辑也十分简单,除了异常捕获之外,核心代码只有一行:defineClassNative(name, loader, cookie, dexFile); ,通过它完成类的查找,查看详情:

最后的结果很明显,这是一个native方法,我们无法再向下剖析。若是对dex文件格式颇有了解或者阅读过笔者写过的分析dex格式文章,可知一个dex文件中存储了整个工程中所有的class文件,其文件数据存储在dex文件中的“数据区”。因此也可以推理出defineClassNativenative方法是通过C/C++在dex文件中查找获取拼接成class字节码文件返回。

DexFile源码地址



4 重点总结

以上就是对Android的ClassLoader加载机制源码部分的剖析,其实整个过程并不复杂,只是有些逻辑上的嵌套,涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList DexFile多个类之间方法互相调用,真正有难度的是最后native方法中的C层处理(此处不深究,有兴趣可自行研究C层)。

(笔者强烈建议认真阅读下面时序图,也许上述一系列的源码分析让你有些云里雾里,但笔者在画完时序图后,骤然理解,颇有“拨开云雾见天日 守得云开见月明”之感!画图实在有助于理解)

结合以上Android类加载时序图,再次回顾一下ClassLoader源码的解读研究过程:

  • 首先类的加载是在ClassLoader类的loadClass 方法中进行,此方法会判断此类是否被自己或双亲加载过(这也是著名的“双亲委派模式”);
    • 若加载过则无需重复load,直接返回类实例;
    • 否则调用findClass方法寻找获取这个类,可是findClass方法在ClassLoader类中是一个空实现,真正实现是在BaseDexClassLoader类中;
      • BaseDexClassLoader类也未具体实现,调用的实则是DexPathList类中的findClass方法;
        • DexPathList类中findClass方法最终又调用DexFile中的defineClassNativeDexFile的一个native方法来完成主要类加载逻辑。

以上是类加载过程涉及到的几个类中方法互相调用最终实现“类加载”的过程,以下是重点方法中实现的逻辑总结:

首先在DexPathList类的构造方法中:将所有的dex文件(File类型)转换成DexFile类型,并且将其转化为Element数组便于findClass方法逻辑处理,然后在findClass 方法中遍历Element数组(Element类型中存储着DexFile类型),获取Element中的DexFile,调用DexFile的内部方法loadClassBinaryName在dex文件中查找获取拼接成class字节码文件返回(loadClassBinaryName是一个 native方法)。

而这整个过程,一系列方法、类之间调用的核心逻辑是:通过指定加载dex路径中遍历文件找到dex文件,然后在存储了整个工程class文件数据中的dex文件中,查找搜索并拼接 class字节码文件返回。



5. 动态加载难点

以上就是ClassLoader中的一个loadClassfindClass的过程,了解之后接下来介绍Android动态加载的难点:

在了解以上源码解析后,发现Android的动态加载不过是使用DexClassLoader指定需要加载的APK路径,思路很简单呀?其实在实际使用中并不尽然,由于Android系统的特点和第三方原因带来了以下限制:

  • 许多组件类需要注册才能使用。例如Android系统中的Activity、Service等需要在Manifest中注册才可以工作,这意味着即使开发者在工程中加载了一个新的组件,若没有注册也将无法工作。
  • 资源的动态加载复杂度高。 Android开发的一大特点就是使用资源,将资源通过ID注册好再来使用,因此资源的注册这一步不可或缺,之后才可以通过ID向Resource实例中获取对应资源。这意味着动态加载时运行的新类中若涉及到资源的加载,由于新类资源没有注册的原因,程序会抛出异常。
  • Android各版本的差异可能存在加载资源、注册的方式不同的隐患问题,对适配造成影响。

以上总结的问题存在一个共性:即Android程序运行需要一个上下文环境,它可以为Android中的组件提供使用的功能,例如主题、资源、组件查询等等。

因此现在面临的问题就是如何为动态加载到程序中的类或者资源提供这样一个Android上下文环境呢?这也是许多第三发动态加载库Tinker、AndFix核心解决问题关键,学习它们的实现原理着实必要,后续涉及。




建议阅读完此篇文章后,阅读笔者上一篇特地为了热修复系列去学习的Android dex格式解析:Android Dex VS Class:实例图解剖析两种格式文件结构、优劣,这样会对Android类加载机制的了解更加深入。

此系列对笔者而言又是一个“大头”,刚开始实在理解无能,研究原理、探索源码是一个痛苦而又艰难的过程,通过相关书籍、视频、博客、官方源码等渠道慢慢悟道。想要把一个知识点分析透彻实属不易,个中牵涉的部分太多,只能尽自己目前的知识存储量去理解并研究,共勉。(例如此篇的Android类加载机制,首先毫无疑问你需要了解JVM中的类加载机制及双亲委托模式,之后你会发现Android的ClassLoader与Java中的不同之处,因着前者加载的是dex文件,并非是class字节码文件,再去学习dex相关概念知识,再从源码角度慢慢深入探索总结其原理 )

下一篇博文的内容是解析如何使用类加载实现热修复,以及用类加载方案自行实现demo完成热修复技术。


若有错误,虚心指教~

以上是关于Android热修复:底层替换类加载原理总结 及 DexClassLoader类加载机制源码探索的主要内容,如果未能解决你的问题,请参考以下文章

Android热修复:以DexClassLoader类加载原理编写demo实现类替换修复

Android热修复:以DexClassLoader类加载原理编写demo实现类替换修复

Sophix及热修复原理介绍

热修复之类加载机制总结

Android热修复原理

Android类加载器和热修复原理