手记

Android插件化总结-Android进阶

瓶颈

大多数朋友开始接触这个问题是因为 App 爆棚了,方法数超过了一个 Dex 最大方法数 65535 的上限,因而便有了插件化的概念,将一个 App 划分为多个插件(Apk 或相关格式)

插件化动态加载架构方案会为我们带来多么巨大的收益,除此之外还有诸多好处:

编译速度提升

工程被拆分为十来个子工程之后,Android Studio编译流程繁冗的缺点被迅速放大.

启动速度提升

Google提供的MultiDex方案,会在主线程中执行所有dex的解压、dexopt、加载操作,这是一个非常漫长的过程,用户会明显的看到长久的黑屏,更容易造成主线程的ANR,导致首次启动初始化失败。

A/B Testing

可以独立开发AB版本的模块,而不是将AB版本代码写在同一个模块中。

可选模块按需下载

​例如用于调试功能的模块可以在需要时进行下载后进行加载,减少App Size

介绍名词

插件化 – apk 分为宿主和插件部分,插件在需要的时候才加载进来

热修复 – 更新的类或者插件粒度较小的时候,我们会称之为热修复,一般用于修复bug

热更新 – 2016 Google 的 Android Studio 推出了Instant Run 功能 同时提出了3个名词

“热部署” – 方法内的简单修改,无需重启app和Activity。 “暖部署” – app无需重启,但是activity需要重启,比如资源的修改。 “冷部署” – app需要重启,比如继承关系的改变或方法的签名变化等。

MulitiDex开始

当 Android 系统安装一个应用的时候,有一步是对 Dex 进行优化,这个过程有一个专门的工具来处理,叫 DexOpt 。DexOpt 的执行过程是在第一次加载Dex文件的时候执行的。这个过程会生成一个 ODEX 文件,即 Optimised Dex。执行 ODex 的效率会比直接执行 Dex 文件的效率要高很多。

但是在早期的 Android 系统中,DexOpt 有一个问题,DexOpt 会把每一个类的方法 id 检索起来,存在一个链表结 构里面。但是这个链表的长度是用一个 short 类型来保存的,导致了方法 id 的数目不能够超过65536个。当一个项目足够大的时候,显然这个方法数的上限是不够的。尽管在新版本的 Android 系统中,DexOpt 修复了这个问题,但是我们仍然需要对低版本的 Android 系统做兼容。

为了解决方法数超限的问题,需要将该dex文件拆成两个或多个,为此谷歌官方推出了 multidex 兼容包,配合 AndroidStudio 实现了一个 APK 包含多个 dex 的功能。

MulitDex 引起的问题有:

在应用安装到手机上的时候dex文件的安装是复杂的(complex)有可能会因为第二个dex文件太大导致ANR。
使用了mulitDex的App有可能在4.0(api level 14)以前的机器上无法启动,因为Dalvik linearAlloc bug(Issue 22586) 。
使用了mulitDex的App在runtime期间有可能因为Dalvik linearAlloc limit (Issue 78035) Crash。该内存分配限制在 4.0版本被增大,但是5.0以下的机器上的Apps依然会存在这个限制。
主dex被dalvik虚拟机执行时候,哪些类必须在主dex文件里面这个问题比较复杂。build tools 可以搞定这。

实现插件化需要解决的技术点
资源如何加载(资源冲突问题如何解决)
代码如何加载访问访问?
插件的管理后台包括的内容?
问题1 资源如何加载
方案一:

将插件apk资源解压,通过操作文件的方式来使用,这个只是理论上可行,实际使用的时候还是有很多的问题。(主要是混淆后就懵逼了)

方案二:

重写 Context 的getResource() getAsset() 之类的方法。资源冲突需要扩展 aapt 实现。

运行时资源的加载

平常我们使用资源,都是通过AssetManager类和Resources类来访问的。获取它们的方法位于Context类中。

Context.java

/** Return an AssetManager instance for your application's package. */
public abstract AssetManager getAssets();

/** Return a Resources instance for your application's package. */
public abstract Resources getResources();

它们是两个抽象方法,具体的实现在ContextImpl类中。ContextImpl类中初始化Resources对象后,后续Context各子类包括Activity、Service等组件就都可以通过这两个方法读取资源了。

ContextImpl.java

private final Resources mResources;

@Override
public AssetManager getAssets() {
   return getResources().getAssets();
}

@Override
public Resources getResources() {
   return mResources;
}

既然我们已经知道一个资源ID应该从哪个apk去读取(前面在编译期我们已经在资源ID第一个字节标记了资源所属的package),那么只要我们重写这两个抽象方法,即可指导应用程序去正确的地方读取资源。

至于读取资源,AssetManager有一个隐藏方法addAssetPath,可以为AssetManager添加资源路径。

/**
* Add an additional set of assets to the asset manager.  This can be
* either a directory or ZIP file.  Not for use by applications.  Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
   synchronized (this) {
       int res = addAssetPathNative(path);
       makeStringBlocks(mStringBlocks);
       return res;
   }
}

我们只需反射调用这个方法,然后把插件apk的位置告诉AssetManager类,它就会根据apk内的resources.arsc和已编译资源完成资源加载的任务了。

以上我们已经可以做到加载插件资源了,但使用了一大堆定制类实现。要做到“无缝”体验,还需要一步:使用Instrumentation来接管所有Activity、Service等组件的创建(当然也就包含了它们使用到的Resources类)。

话说Activity、Service等系统组件,都会经由android.app.ActivityThread类在主线程中执行。ActivityThread类有一个成员叫mInstrumentation,它会负责创建Activity等操作,这正是注入我们的修改资源类的最佳时机。通过篡改mInstrumentation为我们自己的InstrumentationHook,每次创建Activity的时候顺手把它的mResources类偷天换日为我们的DelegateResources,以后创建的每个Activity都拥有一个懂得插件、懂得委托的资源加载类啦!

当然,上述替换都会针对Application的Context来操作。

方案三:

打包后执行一个脚本修改资源ID。

原理:

资源id是在编译时生成的,其生成的规则是0xPPTTNNNN,PP段,是用来标记apk的,默认情况下系统资源PP是01,应用程序的PP是07。TT段,是用来标记资源类型的,比如图标、布局等,相同的类型TT值相同,但是同一个TT值不代表同一种资源,例如这次编译的时候可能使用03作为layout的TT,那下次编译的时候可能会使用06作为TT的值,具体使用那个值,实际上和当前APP使用的资源类型的个数是相关联的。NNNN则是某种资源类型的资源id,默认从1开始,依次累加。

那么我们要解决资源id问题,就可从TT的值开始入手,只要将每次编译时的TT值固定,即可是资源id达到分组的效果,从而避免重复。例如将宿主程序的layout资源的TT固定为33,将插件程序资源的layout的TT值固定为03(也可不对插件程序的资源id做任何处理,使其使用编译出来的原生的值), 即可解决资源id重复的问题了。

固定资源id的TT值的办法也非常简单,提供一份public.xml,在public.xml中指定什么资源类型以什么TT值开头即可

还有一个方法是通过定制过的aapt在编译时指定插件的PP段的值来实现分组,重写过的aapt指定PP段来实现id分组。

问题2 代码如何加载访问

类的加载相对比较简单。与Java程序的运行时classpath概念类似,Android的系统默认类加载器PathClassLoader也有一个成员pathList,顾名思义它从本质来说是一个List,运行时会从其间的每一个dex路径中查找需要加载的类。既然是个List,一定就会想到,给它追加一堆dex路径不就得了?实际上,Google官方推出的MultiDex库就是用以上原理实现的。下面代码片段展示了修改pathList路径的细节:

MultiDex.java

private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
     File optimizedDirectory)
             throws IllegalArgumentException, IllegalAccessException,
             NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
    /* The patched class loader is expected to be a descendant of
    * dalvik.system.BaseDexClassLoader. We modify its
    * dalvik.system.DexPathList pathList field to append additional DEX
    * file entries.
    */
    Field pathListField = findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);
    expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
         new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}
Java ClassLoader基础
1. ClassLoader 的基础知识

无论是 JVM 还是 Dalvik 都是通过 ClassLoader 去加载所需要的类,而 ClassLoader 加载类的方式常称为双亲委托,ClassLoader.java 具体代码如下:
Java

protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class<?> clazz = findLoadedClass(className);

    if (clazz == null) {
        try {
            clazz = parent.loadClass(className, false);
        } catch (ClassNotFoundException e) {
            // Don't want to see this.
        }

        if (clazz == null) {
            clazz = findClass(className);
        }
    }

    return clazz;
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class<?> clazz = findLoadedClass(className);

    if (clazz == null) {
        try {
            clazz = parent.loadClass(className, false);
        } catch (ClassNotFoundException e) {
            // Don't want to see this.
        }

        if (clazz == null) {
            clazz = findClass(className);
        }
    }

    return clazz;
}

从上面加载类的顺序中我们可以知道,loadClass 会先看这个类是不是已经被 loaded 过,没有的话则去他的 parent 去找,如此递归,称之为双亲委托。

2. 动态加载 Jar

Java 中动态加载 Jar 比较简单,如下:

URL[] urls = new URL[] {new URL("file:libs/jar1.jar")};
URLClassLoader loader = new URLClassLoader(urls, parentLoader);

表示加载 libs 下面的 jar1.jar,其中 parentLoader 就是上面1中的 parent,可以为当前的 ClassLoader。

3. ClassLoader 隔离问题

大家觉得一个运行程序中有没有可能同时存在两个包名和类名完全一致的类?
JVM 及 Dalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由一个 ClassLoader 加载,是无法将一个类的示例强转为另外一个类的,这就是 ClassLoader 隔离。 如 Android 中碰到如下异常

java.lang.ClassCastException: android.support.v4.view.ViewPager can not be cast to android.support.v4.view.ViewPager

java.lang.ClassCastException: android.support.v4.view.ViewPager can not be cast to android.support.v4.view.ViewPager

当碰到这种问题时可以通过 instance.getClass().getClassLoader(); 得到 ClassLoader,看 ClassLoader 是否一样。

4. 加载不同 Jar 包中公共类

现在 Host 工程包含了 common.jar, jar1.jar, jar2.jar,并且 jar1.jar 和 jar2.jar 都包含了 common.jar,我们通过 ClassLoader 将 jar1, jar2 动态加载进来,这样在 Host 中实际是存在三份 common.jar,如下图: Class Diagram
我们怎么保证 common.jar 只有一份而不会造成上面3中提到的 ClassLoader 隔离的问题呢,其实很简单,有三种方式:
第一种:我们只要让加载 jar1 和 jar2 的 ClassLoader 的 parent 为同一个 ClassLoader,并且该 ClassLoader 加载过 common.jar,通过上面 1 中我们知道根据双亲委托,最后都会首先被 parentClassLoader加载。
第二种:我们重写 jar1 和 jar2 的 ClassLoader,在 loadClass 函数中我们先去某个含有 common.jar 的 ClassLoader 中 load 即可,其实就是把上面的 parentClassLoader 换掉了而已。
第三种:在生成 jar1 和 jar2 时把 common.jar 去掉,只保留 host 中一份,以 host ClassLoader 为 parentClassLoader 即可。

ClassLoader机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校检、转换解析和初始化的,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时进行链连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以同代拓展的语言特性就是依赖运行期动态加载和动态链接这个特点实现的。例如,如果编写一个面相接口的应用程序,可以等到运行时在制定实际的实现类;用户可以通过Java与定义的和自定义的类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为代码的一部分,这种组装应用程序的方式目前已经广泛应用于Java程序之中。从最基础的Applet,JSP到复杂的OSGi技术,都使用了Java语言运行期类加载的特性。

Java的类加载是一个相对复杂的过程;它包括加载、验证、准备、解析和初始化五个阶段;对于开发者来说,可控性最强的是加载阶段;加载阶段主要完成三件事:

根据一个类的全限定名来获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为JVM方法区中的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
『通过一个类的全限定名获取描述此类的二进制字节流』这个过程被抽象出来,就是Java的类加载器模块,也即JDK中ClassLoader API。

Android Framework提供了DexClassLoader这个类,简化了『通过一个类的全限定名获取描述次类的二进制字节流』这个过程;我们只需要告诉DexClassLoader一个dex文件或者apk文件的路径就能完成类的加载。因此本文的内容用一句话就可以概括:

将插件的dex或者apk文件告诉『合适的』DexClassLoader,借助它完成插件类的加载

参考

https://gold.xitu.io/entry/578d184b79bc44005ff029b3
http://www.trinea.cn/android/java-loader-common-class/
http://www.trinea.cn/android/android-plugin/
http://weishu.me/2016/04/05/understand-plugin-framework-classloader/
http://www.infoq.com/cn/articles/ctrip-android-dynamic-loading

1人推荐
随时随地看视频
慕课网APP