从这个章节开始,加载SO库的问题算是告一段落,现在开始谈及的动态加载,主要是指基于ClassLoader的加载方式,这也是这个动态加载系列文章的核心。
Java程序中,JVM虚拟机是通过类加载器ClassLoader加载 .jar 文件里面的类的。Android也类似,不过Android用的是Dalvik/ART虚拟机,不是JVM,也不能直接加载 .jar 文件,而是加载 .dex 文件。
通过Android SDK提供的 DX工具 把 .jar 文件优化成 .dex 文件,然后Android的虚拟机才能加载。注意,有的Android应用能直接加载 .jar 文件,那是因为这个 .jar 文件已经经过优化,只不过后缀名没改(其实已经是 .dex 文件)。
如果对ClassLoader的工作机制有兴趣,具体过程请参考 动态加载基础 ClassLoader的工作机制,这里不再赘述。
基本信息
- Author : Kaede
- Index : ANDROID动态加载系列
- GitHub : kaedea/android-dynamical-loading
如何获取能够加载的DEX文件
首先我们可以通过JDK的编译命令javac把Java代码编译成 .class 文件,再使用jar命令把 .class 文件封装成 .jar 文件,这与编译普通Java程序的时候完全一样。
之后再用Android SDK的DX工具把 .jar 文件优化成 .dex 文件(在“android-sdk\build-tools\具体版本\”路径下)
dx –dex –output=target.dex origin.jar // target.dex就是我们要的了
此外,我们可以先把代码编译成APK文件,再把APK里面的 .dex 文件解压出来,或者直接把APK文件当成 .dex 使用(只是APK里面的静态资源文件我们暂时还用不到)。至此我们发现,无论加载 .jar,还是 .apk,其实都和加载 .dex 是等价的,Android能加载 .jar 和 .apk,是因为它们都包含有 .dex,直接加载 .apk 文件时,ClassLoader也会自动把 .apk 里的 .dex 解压出来(具体实现代码,有兴趣的话请阅读DexClassLoader和DexFile的源码,兴许以后开个源码分析系列的文章再仔细探讨吧)。
加载并调用DEX文件里面的方法
与JVM不同,Android的虚拟机不能用ClassCload直接加载 .dex,而是要用DexClassLoader或者PathClassLoader,他们都是ClassLoader的子类,这两者的区别是
- DexClassLoader:可以加载 .jar/apk/dex 文件,可以从SD卡中加载未安装的APK;
- PathClassLoader:要传入系统中已经安装过的 .apk 文件的存放Path,所以只能加载已经安装的APK;
使用前,先看看DexClassLoader的构造方法
1 | public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) { |
注意,我们之前提到的,DexClassLoader并不能直接加载外部存储等noexec存储路径中的 .dex 文件,而是要先拷贝到内部存储里。这里的dexPath就是 .dex 的外部存储路径,而optimizedDirectory则是内部路径(exec存储),libraryPath是Native库(其实就是SO库)的所在路径,必须是内部路径,如果不需要用到SO库的话这里直接用null即可,parent则是要传入当前应用的ClassLoader,这与ClassLoader的“双亲代理模式”有关。实际上,DexClassLoader之所以能加载SD卡中的APK文件,就是因为它会先提取并优化dexPath路径上APK文件中的 .dex 文件,并保存到optimizedDirectory路径上,然后再加载优化好的 .dex 文件,所有的动态加载只能发生在exec存储路径上。
注意,如果 .dex 里面有用到SO库相关的代码,我们需要事先把SO库拷贝到内部存储路径,并把路径作为参数传给libraryPath,或者如果你不想在创建DexClassLoader的时候就加载SO库,可以把libraryPath置为null,并确保在调用相关的Native方法前,使用System#loadLibrary加载了相应的SO库。这里我们并不需要用到SO库,所以才使用null。
实例使用DexClassLoader的代码:
1 | File optimizedDexOutputPath = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "test_dexloader.jar");// 外部路径 |
到这里,我们已经成功把 .dex 文件给加载进来了,接下来就是如何调用 .dex 里面的代码,主要有两种方式。
使用反射的方式
使用DexClassLoader加载进来的类,我们本地并没有这些类的源码,所以无法直接调用新加载进来的类,不过可以通过反射的方法调用,简单粗暴。
1 | DexClassLoader dexClassLoader = new DexClassLoader(optimizedDexOutputPath.getAbsolutePath(), dexOutputDir.getAbsolutePath(), null, getClassLoader()); |
使用接口的方式
使用反射的方式不利于代码维护,如果动态加载的业务是多变的话,就不适合了。毕竟 .dex 里面的类也是我们自己维护的,所以可以设计一个宿主和插件共用的基础类,把方法抽象成公共接口,再把这些接口复制到公共库里面去,宿主项目和插件项目都依赖公共库,这样就可以通过这些接口调用动态加载插件得到的类的方法了。
1 | pulic interface IFunc { |
到这里,我们已经成功从外部路径动态加载一个 .dex 文件,并执行里面的代码逻辑了。通过从服务器下载最新的 .dex 文件并替换本地的旧文件,就能初步实现“APP的动态升级了”。但是这只是非常基础的功能,所以我称之为“简单加载”。从一般的Android开发需要来看,“简单动态加载”虽然能动态更换类了,但还是有不少问题需要解决。
如何动态更改XML布局
虽然已经能动态更改代码逻辑了,但是UI界面要怎么更改啊?Android开发中大部分的情况下,UI界面都是通过XML布局实现的,放在res目录下,可是 .dex 库里面并没有这些静态资源啊,所以无法改变XML布局。(这里即使直接动态加载APK文件,但是通过DexClassLoader只能加载新的APK其中的 .dex 文件,并无法加载其中的res资源文件,所以如果在动态加载的 .dex 的类中直接使用新的APK的res资源的话会抛出异常。)
大家都知道,所有的XML布局在运行的时候都要通过LayoutInflator渲染成View的实例,这个实例与我们使用纯Java代码创建的View实例几乎是等价的,而且后者可能效率还更高,所有的XML布局实现的UI界面都有等价的纯代码的创建方案。由此伸展开来,res目录下所有XML资源都有等价的纯代码的实现方式,比如XML动画、XML Drawable等。
所以,如果想要动态更改应用的UI界面的话,可以通过用纯代码创建布局的形式来解决。此外,还可以模仿LayoutInflator的工作方式,自己写一套布局渲染机制来代替系统的LayoutInflator方案(类似于许多跨平台游戏引擎的方案),这样就能在完全不依赖res资源的情况下创建UI界面了,当然这样的工作量不少,而且,完全避开res资源的话,所有的分辨率、国际化等自适应问题都要自己在应用层写代码维护了,显然脱离res资源框架不是一个很明智的做法,但是这种做法确实可行,在我们之前的实际生产中的项目中也稳定使用着,这里出于责任问题就不方便公开细节了。
(早期还没有解决res资源的方案,现在有了,宝宝心里苦 🌚,说实在,这种方案非常繁琐,不好维护,一方面,这是产品一句“技术可行就做呗”而产生的解决方案;另一方面,当时动态加载技术还很不成熟,也没有什么实际投入到生产的项目,所以采取了非常保守的开发方式)。
使用Fragment代替Activity
Activity需要在Manifest里注册,然后以标准的Intent启动才会具有生命周期(详情参考AMS的工作机制),很明显,如果想要动态加载的 .dex 里的Activity没有注册的话,是无法启动的。
有一种简单粗暴的做法就是可以把 .dex 里所有需要用到的Activity都事先注册到原项目里,不过这样只适用于Activity数量不经常改变的业务,如果 .dex 里的Activity有变化,原项目就必须跟着升级。另外一种方案是使用Fragment,Fragment只是普通的Java类,不是组件类,但是自带生命周期(同步FragmentActivity的),不需要在Manifest里注册,所以可以在 .dex 里使用Fragment来代替Activity,代价就是Fragment之间的切换会繁琐许多。
ART模式的兼容性问题
当初我们开始设计动态加载方案的时候,还没有ART模式。随着Kitkat的发布以及ART模式的出现,我们开始担心“用DexClassLoader加载 .dex 文件”的方案会不会在ART模式上面存在兼容性问题。
其实,ART模式相比原来的Dalvik,会在安装APK的时候,使用Android系统自带的 dex2oat 工具把APK里面的. .dex 文件转化成 OAT 文件, OAT 文件是一种Android私有ELF文件格式,它不仅包含有从DEX文件翻译而来的本地机器指令,还包含有原来的DEX文件内容。这使得我们无需重新编译原有的APK就可以让它正常地在ART里面运行,也就是我们不需要改变原来的APK编程接口。ART模式的系统里,同样存在DexClassLoader类,包名路径也没变,只不过它的具体实现与原来的有所不同,但是接口是一致的。(如果你熟悉设计模式的话,应该会知道有种原则叫做“针对接口编程,而不针对实现编程”,就是因为接口没有变化,才能保证ART模式的向下兼容。)
在Kitkat项目的源码中,我们依然能找到同样API的DexClassLoader:
1 | package dalvik.system; |
也就是说,ART模式在加载 .dex 文件的方法上,对Dalvik做了向下兼容,所以使用DexClassLoader加载进来的 .dex 文件同样也会被转化成OAT文件再被执行,“以DexClassLoader为核心的动态加载方案”在ART模式上可以稳定运行。
关于ART模式以及OAT文件的详细分析,请参考官方的ART and Dalvik,以及老罗的Android ART运行时无缝替换Dalvik虚拟机的过程分析。
存在的问题与改进方案
以上大致就是“Android动态性加载初级阶段”的解决方案,虽然现在已经能投入到具体的生产中去,但是还有一些问题无法忽略。
- 无法使用res目录下的资源,比如layout、values等;
- 无法动态加载新的Activity等组件,因为这些组件需要在Manifest中注册,动态加载无法更改当前APK的Manifest;
在这些问题没有解决的情况下,虽然可以以比较“绕”的方式开发插件项目,但是还是过于繁琐(方正我是受不了)。以上问题可以通过 使用反射调用Framework层的隐藏API接口加载res资源 以及 代理Activity 的方式解决,可以把这种的动态加载框架成为“代理模式”。在代理模式下,我们能以接近常规Android开发的方式开发插件项目。