美文网首页Android开发Android开发
Android热修复,插件化理论与实战

Android热修复,插件化理论与实战

作者: 丘卡皮 | 来源:发表于2022-06-08 13:50 被阅读0次

前言

本文现实讨论一下Java虚拟机的理解 以及 类Java加载过程,引申出热修复与插件化实现的核心点,类加载技术,分析思路之后使用ClassLoader+反射实现了基本效果

Java虚拟机是一层封装

在编程的上古年代,程序员直接面对硬件编程,机器计算机只认识0和1,不同硬件的指令都是不同的,所以代码完全不能复用。

在Java诞生之初 有一个非常著名的口号 “一次编写,到处运行” ,它是如何实现的呢?人类编写的高级语言Java如何转化为机器认识的01呢

我理解的答案是:分层

最初的层次:代码—机器

Java出现之后的层次:Java—Java虚拟机—机器

人类认识java, Java虚拟机认识Java语言,将Java语言对应翻译给机器,做了一层隔离。从此之后使用Java就再也不管用种类繁多的真实机器了,java只需要和Java虚拟机对接,有Java虚拟机和种类繁多的真实机器对接。

Java虚拟机相当于编程界的翻译。

但是Java虚拟机不仅仅能够翻译Java语言,还可以翻译其他语言,有时候我们会听到某某语言是面向JVM的语言,比如:kotlin,Groovy 等等有很多

这些高级语言 ,Java,Java虚拟机时什么关系呢,为什么Java虚拟机可以支持多种高级语言?

Java语言 和 Java虚拟机 虽然都有Java这个名词,但它们之间没有任何强关联,Java虚拟机中没有任何Java语言的东西。

用程序员的话说,Java语言和Java虚拟机之前没有耦合,随时可插拔。

Java虚拟机 并不能直接认识 Java,kotlin等高级语言。 与Java虚拟机交流的语言是class字节码。

Java,kotlin 需要经过编译器,翻译为class字节码才能被Java虚拟机识别,加载,运行。

现在回顾一遍程序被机器识别的过程

  1. 某程序员会Java编程开发了一套程序
  2. 经过Javac编译器使Java程序编译为 class字节码
  3. class字节码被Java虚拟机识别
  4. Java虚拟机根据字节码中的内容操纵计算机

如图所示

Java类加载过程

节选自 技术 / Android / 类加载机制 · Issue #30 · huanzhiyazi/articles (github.com)

通常,一个 Java 类被使用前,需要经过以下两个步骤:

  1. 由 javac 编译成字节码。
  2. 将代表该类的字节码文件加载到虚拟机。

Java 的一大魅力在于可以在运行时加载类。理论上,Java 虚拟机可以在应用程序运行过程中根据应用程序的执行要求加载任何一个合法的新类并执行。这体现了 Java 的动态性和灵活性。

我们可以试想一下 Java 类的加载过程:

首先,需要先将该类的字节码文件读到内存中;

其次,肯定需要对字节码进行一些验证,以确保它是合法并安全的,不会危害到系统,因为字节码并非只由 javac 编译 java 源程序得到,理论上只要遵循 java 字节码规范都可以得到一个虚拟机可执行的字节码文件;

然后,需要对这个类进行解析,即虚拟机需要知道这个类长什么样子,有些什么字段,需要分配多少内存,需要告诉应用程序如何才能调用到它;

最后,需要对该类进行一些必要的初始化工作,这种初始化的特点是只需要执行一次,因为类的加载一般也只需要执行一次,所以将这样的工作放到加载过程中是合理的。注意类的初始化不同于构造函数,确切地说,类的初始化是所有对象共有的部分,所以只会有一次,而构造函数应该是对象的初始化,不同的对象都要初始化一遍,所以并不属于类加载过程,从而也可以推断类的初始化应该是指的是静态语句块和静态字段的初始化。

以上是从正常的逻辑分析来看类的加载过程的,实际上,类的加载过程也大致如同上述所言,具体来看,如下图所示:

总体而言,类加载包括三个过程:加载、链接、初始化。其中,链接又包含验证、准备、解析三个阶段。一般而言,这几个过程的开始顺序是不变的,除了在 Java 动态绑定中解析在初始化之后以外。

简单介绍一下这几个过程的作用:

  1. 加载:将类的字节码二进制流读进内存;将该字节流代表的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
  2. 验证:如前所述,验证阶段确实就是为了验证字节码的合法性和安全性。理论上用 javac 编译过后的字节码都应该符合虚拟机规范了,所以验证过程实际上可能重复做了很多在编译阶段已经做过的事情,但是由于字节码来源的多样性,这个验证阶段还是很重要的。验证过程包括验证字节码的格式、验证类的语义合法性、方法的语义合法性、代码的语义合法性等。
  3. 准备:准备阶段比较简单,就是给类的静态变量分配内存和赋初值。需要注意的是这里不同于类的初始化阶段给静态变量赋初值,准备阶段给静态变量赋的初值一般是零值,除非该静态变量用 final 进行修饰。
  4. 解析:解析阶段将常量池内的符号引用替换为直接引用。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;直接引用则是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。符号引用与直接引用的关系就好比姓名和身份证之间的关系一样。
  5. 初始化:如前所述,初始化阶段就是根据类的逻辑语义去初始化类中的所有静态语句块和静态变量。需要注意的是,初始化的顺序将按照各个静态语句在代码中的顺序进行,特别地,静态语句块可以对在其后的静态变量重新赋值,但是不能引用它;虚拟机会保证父类的类初始化优先于子类的类初始化;类初始化是线程安全的,所以当多个线程同时初始化一个类时,若类的初始化过程太长,另一个线程将长久处在等待锁的状态。

上述类加载过程都是在程序运行期间完成的,最值得关注的,也是开发者可以控制的就是 “加载”

可以操作的空间很大

Java虚拟机并没有限定字节码的来源,从哪里获取,使用什么方式获取,所以class文件不仅仅 编译后在磁盘获取,还可以通过其他方式获取,比如:

  1. 从ZIP压缩包中获取,后来发展成为常见的 jar包,war包
  2. 从网络获取
  3. 运行时计算生成,典型例子: Java动态代理
  4. 由其他文件生成,典型例子:JSP
  5. 从数据库获取
  6. …等等

总的来说,虚拟机只管加载Class文件,却不管它的来源只要后续验证步骤通过就可以使用。

开发人员可以定义自己的类加载器,从不同的来源加载class文件,实现程序的动态性。Java虚拟机的这个特性是热修复,插件化的基础。

Android中的ClassLoader

Android中ClassLoader继承关系如下图

我们只需要关心这三个类,Android自定义的类加载器,用于加载dex文件

  1. BaseDexClassLoader

    1. 抽象类,另外两个是实现类,承担了大部分的工作,子类中只重写了构造方法
  2. DexClassLoader

    1. 大概翻译:用于加载包含classes.dex文件的jar 和 apk文件,可以挂载一部分不属于当前应用程序的clas
    /**
      * A class loader that loads classes from {@code .jar} and {@code .apk} files
      * containing a {@code classes.dex} entry. This can be used to execute code not
      * installed as part of an application.
      *
      */
    public DexClassLoader(String dexPath, String optimizedDirectory,
                  String librarySearchPath, ClassLoader parent) {
       super(dexPath, null, librarySearchPath, parent);
    }
  1. PathClassLoader

    1. 大概翻译:用加载系统class和应用程序class,不要尝试用此类从网络加载class
     /**
      * Provides a simple {@link ClassLoader} implementation that operates on a list
      * of files and directories in the local file system, but does not attempt to
      * load classes from the network. Android uses this class for its system class
      * loader and for its application class loader(s).
      */

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

大家可以用在线工具看一下DexClassLoader,PathClassLoader 的源码,只重写了构造方法,大部分逻辑都是由BaseDexClassLoader 和 最顶级的Java ClassLoader实现的。所以只好根据源码注释理解这两个类的作用

观察这两个类允许开发者调用的构造函数可以看出,两者唯一的区别是,DexClassLoader 允许用户设置 优化目录optimizedDirectoryPathClassLoader不允许用户设置optimizedDirectory

应该也可以说明,官方希望开发者动态加载类的时候使用DexClassLoader

日志输出看一下常见类的类加载器,根据结果可以看出

Activity,View,String 这种存在于Android framework中的类 是由 java.lang.BootClassLoader 类加载器加载的

BootClassLoader 存在于Java包下, 专门用于加载 Android framework中的类,Android系统启动时,第一次启动Zygote进程,其中有一步资源预加载就是BootClassLoader 在提前加载class。

所有应用都是根据Zygote进程fork而来,zygote进程在启动时已经加载过Android framework的类了,应用进程就不需要加载,节省资源。

MainActivity 是开发者编写的 PathClassLoader 加载

AppCompatActivity 不是Google开发的么, 怎么PathClassLoader 加载? 因为他虽然是Google开发的,但仍然以三方库的形式加载不属于framework的代码,如果不在gradle文件中引入程序中是不会有AppCompatActivity 的,所以也是PathClassLoader 加载。

Log.d("aaa","android.app.Activity 的类加载器 ${Activity::class.java.classLoader}")
Log.d("aaa","android.View 的类加载器 ${View::class.java.classLoader}")
Log.d("aaa","String 的类加载器 ${String::class.java.classLoader}")
Log.d("aaa","MainActivity 的类加载器 ${MainActivity::class.java.classLoader}")
Log.d("aaa","AppCompatActivity 的类加载器 ${AppCompatActivity::class.java.classLoader}")

D/aaa: android.app.Activity 的类加载器 java.lang.BootClassLoader@83cef24
D/aaa: android.View 的类加载器 java.lang.BootClassLoader@83cef24
D/aaa: String 的类加载器 java.lang.BootClassLoader@83cef24
D/aaa: MainActivity 的类加载器 dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.whl215.architecture-c7KjHXoJqIlvUGiWi4GS8Q==/base.apk"],nativeLibraryDirectories=[/data/app/com.whl215.architecture-c7KjHXoJqIlvUGiWi4GS8Q==/lib/arm64, /system/lib64, /system/vendor/lib64]]]
D/aaa: AppCompatActivity 的类加载器 dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.whl215.architecture-c7KjHXoJqIlvUGiWi4GS8Q==/base.apk"],nativeLibraryDirectories=[/data/app/com.whl215.architecture-c7KjHXoJqIlvUGiWi4GS8Q==/lib/arm64, /system/lib64, /system/vendor/lib64]]]

插件化实践

什么叫做插件化

一个应用有10个功能模块,应用在打包时并10个功能模块并没有全部参与打包,apk中只有5个模块,另外5个模块放到网络上适时下载,在需要的时候动态加载,使apk获取打包时不存在的另外5个模块。

实践

根据上两节的理论分析,我们可以使用DexClassLoader 加载外部dex文件,包含dex文件的jar包以及包含dex文件的apk文件。

dex文件内部保存的也是class文件,是针对Android平台的一层优化,使Android虚拟机以更好性能读取从class文件。

开始操作,流程如下:

  1. 创建新项目 plugin,其中包含 Test类
  2. 为了方便 打包 apk 作为插件,放到手机sd卡中
  3. 在应用中新建 DexClassLoader
  4. 使用 DexClassLoader 寻找 plugin.apk 的 Test类
  5. 找到后 使用反射 加载Test类 调用其中的方法
class Test {
    fun test(name:String):String{
        return "$name : 运行了插件"
    }
}

private fun loadPlugin() {
    try {
        val filePlugin = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "plugin-debug.apk")
        if (filePlugin.exists()) {
            //DexClassLoader 参数解析
            val dexPath = filePlugin.absolutePath//加载dex文件地址
            //优化后的dex文件目录 相当于一个缓存目录 可以为空,建议使用私有目录 applicationContext.cacheDir
            val optimizedDirectory: String? = null
            val librarySearchPath: String? = null //需要加载的native 库目录
            val parent: ClassLoader? = null //父ClassLoader
            //创建 DexClassLoader
            val dexClassLoader =
                DexClassLoader(dexPath, optimizedDirectory, librarySearchPath, parent)
            //反射调用
            val clazz: Class<*> = dexClassLoader.loadClass("com.example.plugin.Test")
            val test = clazz.newInstance()
            val method = clazz.getDeclaredMethod("test",String::class.java)
            val text: String = method.invoke(test, "app模块") as String
            Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

Plugin项目 并不属于App应用,完全独立的代码,在运行应用中使用类加载技术,加载到Plugin项目中的Class文件,使App应用获得了不属于它的新功能。

代码还是非常简单的,加载插件的全部代码就这么多了,需要注意的是因为apk放到sd卡中 所以需要申请权限。

代码写起来没一会就搞定了,但是理解代码的前置知识还是非常多的,初步使用ClassLoader并不难,知道ClassLoader可以动态加载class挺难。

小结

似乎很简单就加载了外部class,那岂不是也可以用同样的方式获取Activity,新增功能不就是分分钟的事?

很遗憾并不能,纯Java,kotlin的代码确实可以这样 动态随时新增功能。但是新增Android四大组件不可以,因为四大组件需要在AndroidManifest中声明,在应用安装时被系统读取。动态可以加载Class,但是无法修改Android系统的系统限制。

如果决解了四大组件的注册问题,还面临无法去读到插件apk的res资源,读取后资源冲突问题,加上不同版本的适配,可以说是一步一个坑。

所以还是用现成开源方案吧,但是所有方案的核心原理都是ClassLoader 和 反射,了解一下是没有坏处的。

热修复

什么叫做插件化

专门用来修复bug的,假设同样的一个应用有10个功能模块,上线之后发现其中某个类出问题了。修复好bug之后,把修复的类打成补丁包,下发到应用,用修复类替换bug类,解决问题。

热修复的方案有很多,这里讨论ClassLoader方案

插件化与热修复的区别在于,插件化是新增功能,class的从无到有。热修复是原本有一个Class,但是这个Class出了问题,开发者需要更改class。

所以热修复不能使用ClassLoader 新增类的方式解决问题,新增类之后,原有的bug类仍然存在,热修复不需要新增Class而是要对原有的类加载过程进行干预

PathClassLoader工作流程

在 “Android中的ClassLoader”一节中 知道了应用类是由PathClassLoader加载的,所有有必要研究PathClassLoader的工作原理,看如何实现热修复

  1. class通过 loadClass()方法加载的,所以先看PathClassLoader 是如何处理的

  2. 查看源码发现PathClassLoader 并没有重写loadClass() 则向上 在父类 BaseDexClassLoader 中寻找

  3. BaseDexClassLoader 也没有重写loadClass() 继续找它的父类 java.lang.ClassLoader

  4. ClassLoader 中找到了loadClass() 方法实现

  5. 代码非常简单,只有短短几行

    1. 首先从缓存中查找class ,如果有直接返回
    2. 缓存中没有class,判断parent是否为null,不为null,交给parent查找类
    3. parent 并不是 继承关系,而是另一个ClassLoader的实例
    4. parent为null 调用findBootstrapClassOrNull() 此方法是个空实现,固定返回null
    5. 如果上述步骤返回的class 都为null,那么自己查找,进入findClass()
    6. findClass()BaseDexClassLoader 类中实现
    7. 实现也非常简单,调用 DexPathListfindClass() 方法,如果找不到类抛出异常ClassNotFoundException
    8. DexPathList 的内容另起一行分析
    //ClassLoader 
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

    //BaseDexClassLoader 

    @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
                    ...省略代码
            Class c = pathList.findClass(name, suppressedExceptions);
            if (c == null) {
                ClassNotFoundException cnfe = new ClassNotFoundException(
                        "Didn't find class \"" + name + "\" on path: " + pathList);
                for (Throwable t : suppressedExceptions) {
                    cnfe.addSuppressed(t);
                }
                throw cnfe;
            }
            return c;
        }
  1. DexPathList 分析

    1. findClass() 遍历 Element数组,调用 element.findClass()
    2. Element 是DexPathList 的内部类,内部持有DexFile对象 ,和DexFile相同的还有 JarFile属于文件操作的范畴 ,利用DexFile找到dex文件中包含的class
    3. 我们可以说Element 就代码 apk包中的dex文件,apk中可能会存在多个dex,所以由数组存储
    public final class DexPathList {

        private Element[] dexElements;

        DexPathList(ClassLoader definingContext, String dexPath,
                String librarySearchPath, File optimizedDirectory, boolean isTrusted) {

                    //省略代码  
            this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                               suppressedExceptions, definingContext, isTrusted);
                    //省略代码  
        }

        public Class<?> findClass(String name, List<Throwable> suppressed) {
            for (Element element : dexElements) {
                Class<?> clazz = element.findClass(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }

            if (dexElementsSuppressedExceptions != null) {
                suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
            }
            return null;
        }

        static class Element {
                private final DexFile dexFile;
        }

    }

小总结

apk包中的classes.dex 经过解析 保存在PathClassLoader 成员变量 DexPathList对象中的 dexElements数组中,每一个元素代表一个dex文件,在需要的时候经过层层调用从Element中查找clas

因为PathClassLoader 是应用程序的类加载器,所以当前应用所有的class都保存在PathClassLoader 内部。

实践

实践思路

PathClassLoader.DexPathList 内部保存 dex文件数组,类加载过程按照顺序遍历dex文件数组,找到了就缓存,下次使用时从缓存中取。

利用上述特性,通过反射把补丁包放到dex文件数组的首位,类查找的时候先从补丁包查找

第一次加载bug类时候,没有缓存,从文件中查找,补丁包在最前面所以找到了补丁包中没有bug的类。

下一次进入从缓存中取补丁包中的新类,也不会找到bug类,问题就决解了。

目标

MainActivity 中 设置按钮点击事件,弹出toast “bug 提示” 通过热修复 该为 “修复成功”

实践步骤

  1. 修改MainActivity 代码,改为正确的内容
  2. 编译成 class文件,可以用javac编译 ,也可以通过AndroidStudio编译后 在build-temp-kotlin-classes 目录下找class文件
  3. class 打包为 dex 使用d8工具 d8 | Android 开发者 | Android Developers (google.cn)
  4. 遇到了问题,不能只把MainActivity.class 打包为 dex, 因为MainActivity 继承了AppCompatActivity ,使用了Button,Toast ,需要相关类的class 一起打包才能成功。为了方便改一下实现,toast的内容由 Test类 暴露方法返回字符串实现。
  5. 打包 Test.class 为dex文件
  6. 打包成功后,放入手机sd卡中 准备使用
  7. PathClassLoader.DexPathList 中存储的 Element数组,并不是File对象,所以需要把 dex文件地址转为Element,有两种方式
    1. 看系统是如何把地址,转为Element的,通过源码可知
    2. 调用 DexPathList对象的 makeDexElements() splitDexPath() 配合生成Element数字
    3. 我们可以反射调用方法,把补丁转换为Element数组
    4. 反射 合并 补丁Element数组 和 DexPathList内部的程序Element数组
    5. 另一种方式,自定义DexClassLoader,加载补丁到内存中,通过反射获取DexClassLoader的补丁Element数组
    6. 通过反射获取 PathClassLoader内的程序Element数组
    7. 合并两个数组
  8. 选用后一种 自定义DexClassLoader实现

因为测试放到了sd卡中 需要申请权限,正常把补丁包放到应用私有目录比较合适,都是反射代码,如果没有反射基础应该是看不太懂,思路和上面是一致的。

强调!!除非您是大佬 实际项目中还是 使用现成的开源

class App : Application() {

    override fun attachBaseContext(base: Context?) {
        super.attachBaseContext(base)
        try {
            val patchFile = File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                "classes.dex"
            ).absolutePath
//加载补丁
            val patchLoader = DexClassLoader(patchFile, null, null, null)

            //获取 DexPathList 属性
            val baseDexClazz = BaseDexClassLoader::class.java
val pathListField: Field = baseDexClazz.getDeclaredField("pathList")
            pathListField.isAccessible= true

            //获取补丁的 DexPathList
            val patchPathListObject = pathListField.get(patchLoader)
            //获取补丁的 dexElements
            val pathListClazz = patchPathListObject::class.java
val dexElementsField = pathListClazz.getDeclaredField("dexElements")
            dexElementsField.isAccessible= true
            //获取补丁的 dexElements
            val patchElementsArray = dexElementsField.get(patchPathListObject)!!

            //获取应用
            val appLoader =classLoader
val appPathListObject = pathListField.get(appLoader)
            //获取应用 dexElements
            val appElementsArray = dexElementsField.get(appPathListObject)!!

            //数组合并
            val patchLength = Array.getLength(patchElementsArray)
            val appLength = Array.getLength(appElementsArray)
            val resultArray = Array.newInstance(
                appElementsArray::class.java.componentType!!,
                patchLength + appLength
            )
            for (i in 0untilpatchLength) {
                Array.set(resultArray, i, Array.get(patchElementsArray, i))
            }
            for (i in 0untilappLength) {
                Array.set(resultArray, i + patchLength, Array.get(appElementsArray, i))
            }
            //设置结果
            dexElementsField.set(appPathListObject, resultArray)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

相关文章

网友评论

    本文标题:Android热修复,插件化理论与实战

    本文链接:https://www.haomeiwen.com/subject/qqsumrtx.html