Android进阶宝典 -- 插件化1(加载插件中类)

2023-11-17

什么是插件化?插件化对于Android应用能起到什么好处?可能对于插件化不熟悉的伙伴们都会有这个疑问,或许你在项目中已经遇到过这个问题,只不过是不知道需要采用什么样的方式去解决,我们看下面这个场景。

一个应用主模块20M,其他3个模块可以看做是3个App,分别占5M、15M、15M,如果打包,那么整个包体积为55M;如果我们需要做包体积压缩,那么这3个实打实的app无论怎么压缩都会占用app的体积。

那么如果使用插件化技术呢?最终打出的包体积只有20M,其他3个模块都是以插件的方式存在,而Main App则是能够支持插件的宿主App,所以插件化的特点就是不需要安装就能运行app

1 插件化解决的问题

(1)app功能模块越来越多,包体积增大

其实这是一个app成为大型app的必经之路,模块越加越多,所以就如前文讲解的一样,采用插件的方式,当需要启动一个app的时候,将插件下载下来,调用插件中的方法运行app。

(2)模块解耦

每个插件其实在app中都可以看做是一个单独的模块,如果采用插件化的方式,那么可以将每个功能抽离为单独的module,每个module可以独立运行,不会出现多个模块耦合在一块的问题

(3)多应用之间相互调用

这个其实我们在使用支付宝、淘宝的时候,经常会使用到,例如从闲鱼app中跳转到支付宝、或者跳转到淘宝,支持相互调用。

插播一个Android进阶开发资料~

插播一个资料

2 组件化和插件化的区别

在实际项目中,组件化是使用最频繁的,例如

将app分为多个模块,每个模块都是一个组件,在开发过程中,组件之间可以相互依赖,也可以单独作为app调试,最终打包的时候,是将这些组件合并到一起打包成一个apk。

而插件化和组件化类似的是,app同样被分为多个模块,但是每个模块都有一个宿主和多个插件,也就是说每个模块都是一个apk,最终打包的时候宿主apk和插件apk分开打包

3 插件化设计思路

在设计一个框架的时候,往往需要想明白目的是什么?插件是一个apk,如果我们想要启动这个插件,主要有以下几个关键点:
(1)如何启动插件
(2)如何加载插件中的类
(3)如何加载插件中的资源
(4)如何调用插件中的类和方法

3.1 Android的类加载机制

如果想要加载插件中的类,那么对于Android的类加载机制必须要了解,在之前Tinker热修复专题中,其实已经介绍了Android的类加载机制,那么这里再简单介绍一下。

Android类加载和Java不同的在于,Android拥有自己的类加载器,看下图

3.1.1 PathClassLoader和DexClassLoader有啥区别?

在Android中常用的两个类加载器,分别是PathClassLoader和DexClassLoader,两者的区别我们稍后分析,先看下具体的源码分析。

public class PathClassLoader extends BaseDexClassLoader {
    /**
     * Creates a {@code PathClassLoader} that operates on a given list of files
     * and directories. This method is equivalent to calling
     * {@link #PathClassLoader(String, String, ClassLoader)} with a
     * {@code null} value for the second argument (see description there).
     *
     * @param dexPath the list of jar/apk files containing classes and
     * resources, delimited by {@code File.pathSeparator}, which
     * defaults to {@code ":"} on Android
     * @param parent the parent class loader
     */
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    /**
     * Creates a {@code PathClassLoader} that operates on two given
     * lists of files and directories. The entries of the first list
     * should be one of the following:
     *
     * <ul>
     * <li>JAR/ZIP/APK files, possibly containing a "classes.dex" file as
     * well as arbitrary resources.
     * <li>Raw ".dex" files (not inside a zip file).
     * </ul>
     *
     * The entries of the second list should be directories containing
     * native library files.
     *
     * @param dexPath the list of jar/apk files containing classes and
     * resources, delimited by {@code File.pathSeparator}, which
     * defaults to {@code ":"} on Android
     * @param librarySearchPath the list of directories containing native
     * libraries, delimited by {@code File.pathSeparator}; may be
     * {@code null}
     * @param parent the parent class loader
     */
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

我们可以看到,PathClassLoader是继承自BaseDexClassLoader,其中只有两个构造方法,先不着急,再看下DexClassLoader的源码

public class DexClassLoader extends BaseDexClassLoader {
    /**
     * Creates a {@code DexClassLoader} that finds interpreted and native
     * code.  Interpreted classes are found in a set of DEX files contained
     * in Jar or APK files.
     *
     * <p>The path lists are separated using the character specified by the
     * {@code path.separator} system property, which defaults to {@code :}.
     *
     * @param dexPath the list of jar/apk files containing classes and
     *     resources, delimited by {@code File.pathSeparator}, which
     *     defaults to {@code ":"} on Android
     * @param optimizedDirectory this parameter is deprecated and has no effect since API level 26.
     * @param librarySearchPath the list of directories containing native
     *     libraries, delimited by {@code File.pathSeparator}; may be
     *     {@code null}
     * @param parent the parent class loader
     */
    public DexClassLoader(String dexPath, String optimizedDirectory,String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
    //Android 8.0 以前的源码
    public DexClassLoader(String dexPath, String optimizedDirectory,String librarySearchPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
    }

}

DexClassLoader同样是继承自BaseDexClassLoader,而且跟PathClassLoader中构造方法传入的值是一样的,当前版本是Android10版本,其实在Android 8.0之前的版本,第二个参数是必须要传入的,optimizedDirectory是dex优化之后生成odex文件存储地址,但是Android 8.0之后,就直接传null了

所以在Android 8.0之前,PathClassLoader和DexClassLoader还是有区别的,但是在Android 8.0之后,两者就是一样的了,所以网上之前的老博客还在区分两者的区别,其实是不对的了

3.1.2 PathClassLoader和BootClassLoader

BaseDexClassLoader是PathClassLoader继承上的父类,但是并不代表BaseDexClassLoader是PathClassLoader的父类加载器,我们通过代码可以看一下

Log.e("TAG", "classLoader $classLoader parent ${classLoader.parent}")

我们在MainActivity中打印下日志,我们可以发现就是MainActivity的类加载器是PathClassLoader,而PathClassLoader的父类加载器是BootClassLoader

 E/TAG: classLoader dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.lay.image_process-n8633iv_VMBnRO2AEqJ4rg==/base.apk"],nativeLibraryDirectories=[/data/app/com.lay.image_process-n8633iv_VMBnRO2AEqJ4rg==/lib/x86, /system/lib]]] parent java.lang.BootClassLoader@3ab8f00

那么PathClassLoader和BootClassLoader分别加在什么类呢?

Log.e("TAG", "activity ${Activity::class.java.classLoader}")

通过之前的代码,我们可以看到,应用内的类都是PathClassLoader来加载(包括三方库),而Activity的类加载器是BootClassLoader,也就是说Android SDK中的类是由BootClassLoader来加载的

3.1.3 双亲委派机制

和Java的类加载机制一样,Android类加载同样遵循双亲委派机制,我们看下ClassLoader的loadClass方法。

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;
}

(1)首先通过类的全类名,查找这个类是不是已经被加载过了,如果已经加载过了,那么直接返回;
(2)如果没有被加载过,那么首先会判断父类加载器是否为空,如果不为空那么就交给父类加载器去加载,依次递归,如果某个父类加载器加载过了,那么就返回,如果所有的父类加载器都遍历过了,而且不能去加载这个类,那么就自己去加载;
(3)自己怎么加载呢?就是从DexPathList中取出dex文件加载其中类,跟我们今天讲的插件化就联系起来了,其实就是通过PathClassLoader或者DexClassLoader去加载

那么为什么要使用双亲委派机制呢?其实更多的是为了安全性考虑,假如我们自己写了一个String类,想要代替系统的String,这个其实是不可能的,因为系统SDK中的类已经被BootClassLoader加载过了,我们应用内的String类就不会再次被加载。

3.2 加载插件中的类

通过前面对于类加载机制的简单了解,我们知道,插件中类其实就可以通过ClassLoader来加载,所以我们先尝试加载插件中某个类,调用它的方法。

插件也是一个apk,其中有一个TestPlugin类

class TestPlugin {

    fun getPluginInfo():String{
        return "this is my first plugin"
    }
}

TestPlugin通过编译成class文件后,转为dex文件打包进入apk,我们可以模拟这个场景

将class转换为dex文件,采用下面的命令行

dx --dex --output=/Users/xxx/Desktop/dx/plugin.dex com/lay/plugin/TestPlugin.class

这里需要注意一点就是,/Users/xxx/Desktop/dx是class文件所在包名的前缀,/Users/xxx/Desktop/dx/com/lay/plugin/TestPlugin.class是class文件所在的全路径,只有这样才能生成dex文件,不然可能会报错

java.lang.RuntimeException: com/lay/plugin/TestPlugin.class: file not found
at com.android.dex.util.FileUtils.readFile(FileUtils.java:51)
at com.android.dx.cf.direct.ClassPathOpener.processOne(ClassPathOpener.java:168)
at com.android.dx.cf.direct.ClassPathOpener.process(ClassPathOpener.java:143)
at com.android.dx.command.dexer.Main.processOne(Main.java:678)
at com.android.dx.command.dexer.Main.processAllFiles(Main.java:575)
at com.android.dx.command.dexer.Main.runMonoDex(Main.java:310)
at com.android.dx.command.dexer.Main.runDx(Main.java:288)
at com.android.dx.command.dexer.Main.main(Main.java:244)
at com.android.dx.command.Main.main(Main.java:95)

这样,我们得到dex文件之后,可以将其放在sd卡下面,通过ClassLoader去加载某个类。

通过创建PathClassLoader或者DexClassLoader,去加载插件(dex)中类,获取Class对象,通过反射可以生成一个类对象,获取到getPluginInfo方法后,调用这个方法

val loader =
    PathClassLoader("/sdcard/plugin.dex", null, MainActivity::class.java.classLoader)
val clazz = loader.loadClass("com.lay.plugin.TestPlugin")
try {
    val testPluginObj = clazz.newInstance()
    val getPluginInfoMethod = clazz.getMethod("getPluginInfo")
    val result = getPluginInfoMethod.invoke(testPluginObj)
    Log.e("TAG", "result $result")
} catch (e: Exception) {

}
2022-09-12 16:45:10.761 8306-8306/com.lay.image_process E/TAG: result this is my first plugin

其实从这里就能验证,无论是PathClassLoader还是DexClassLoader,都可以加载未安装apk中的类。

3.2 宿主和插件dex合并

在上一小节中,我们采用了反射的方式,加载dex文件中的类,但是实际的项目开发中,伙伴们认为这种方式可取吗?显然不可取,一个插件可能有上千个方法,都采用反射的方式去调用,那岂不是太荒唐了,所以我们想,既然宿主apk能够加载apk中所有的类和资源,那么能不能把插件中的类和资源也全部捎带上呢?

首先我们先看一下宿主apk加载的流程,之前上一小节中,我们看到了类加载的双亲委派机制,其实应用中的类都是由PathClassLoader加载的,所以我们看下PathClassLoader是如何加载类的。

因为PathClassLoader只有两个构造方法,所以直接去它父类BaseDexClassLoader中查看源码;在ClassLoader的loadClass方法中,我们看到如果没有其他父类加载器能够加载这个类,就会由当前类加载器调用findClass方法区加载,所以我们看下BaseDexClassLoader中的findClass方法。

3.2.1 DexPathList和dexElements

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    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;
}

BaseDexClassLoader中的findClass中,调用了pathList的findClass方法,如果没有找到,那么就会抛出ClassNotFoundException的异常,那么pathList是什么呢?

pathList是BaseDexClassLoader中的一个变量DexPathList,是在BaseDexClassLoader的构造方法中完成初始化,会将dexPath作为参数传递进来,其实在上一小节中,我们在创建PathClassLoader的时候,其实已经初始化了这个DexPathList

/**
 * @hide
 */
public BaseDexClassLoader(String dexPath, File optimizedDirectory,String librarySearchPath, ClassLoader parent, boolean isTrusted) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);

    if (reporter != null) {
        reportClassLoaderChain();
    }
}

既然后缀带有一个List,我们猜到这个数据结构应该是个数组,那么我们看下DexPathList到底是个什么

DexPathList(ClassLoader definingContext, String dexPath,String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
    if (definingContext == null) {
        throw new NullPointerException("definingContext == null");
    }

    if (dexPath == null) {
        throw new NullPointerException("dexPath == null");
    }

    if (optimizedDirectory != null) {
        if (!optimizedDirectory.exists())  {
            throw new IllegalArgumentException(
                    "optimizedDirectory doesn't exist: "
                            + optimizedDirectory);
        }

        if (!(optimizedDirectory.canRead()
                    && optimizedDirectory.canWrite())) {
            throw new IllegalArgumentException(
                    "optimizedDirectory not readable/writable: "
                            + optimizedDirectory);
        }
    }

    this.definingContext = definingContext;

    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
    // save dexPath for BaseDexClassLoader
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
        suppressedExceptions, definingContext, isTrusted);
}

在DexPathList中,有一个非常重要的成员变量dexElements,我们看过apk包的话,应该会看到有很多dex文件。所以我们传入的dexPath下,可能存在多个dex文件,那么dexElements其实就是存储这些dex文件的,我们可以看到,在DexPathList的构造方法中,调用了makeDexElements方法,其实就是将dex文件存储在dexElements数组中。

private static List<File> splitPaths(String searchPath, boolean directoriesOnly) {
    List<File> result = new ArrayList<>();

    if (searchPath != null) {
        for (String path : searchPath.split(File.pathSeparator)) {
            if (directoriesOnly) {
                try {
                    StructStat sb = Libcore.os.stat(path);
                    if (!S_ISDIR(sb.st_mode)) {
                        continue;
                    }
                } catch (ErrnoException ignored) {
                    continue;
                }
            }
            result.add(new File(path));
        }
    }

    return result;
}

首先在makeDexElements方法中,首先调用了splitPaths方法,这个方法就是将传入的dexPath路径下全部的dex文件存储在一个List集合中,作为第一个参数,传入到makeDexElements方法中

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
    Element[] elements = new Element[files.size()];
    int elementsPos = 0;
    /*
     * Open all files and load the (direct or contained) dex files up front.
     */
    for (File file : files) {
        if (file.isDirectory()) {
            // We support directories for looking up resources. Looking up resources in
            // directories is useful for running libcore tests.
            elements[elementsPos++] = new Element(file);
        } else if (file.isFile()) {
            String name = file.getName();

            DexFile dex = null;
            if (name.endsWith(DEX_SUFFIX)) {
                // Raw dex file (not inside a zip/jar).
                try {
                    dex = loadDexFile(file, optimizedDirectory, loader, elements);
                    if (dex != null) {
                        elements[elementsPos++] = new Element(dex, null);
                    }
                } catch (IOException suppressed) {
                    System.logE("Unable to load dex file: " + file, suppressed);
                    suppressedExceptions.add(suppressed);
                }
            } else {
                try {
                    dex = loadDexFile(file, optimizedDirectory, loader, elements);
                } catch (IOException suppressed) {
                    /*
                     * IOException might get thrown "legitimately" by the DexFile constructor if
                     * the zip file turns out to be resource-only (that is, no classes.dex file
                     * in it).
                     * Let dex == null and hang on to the exception to add to the tea-leaves for
                     * when findClass returns null.
                     */
                    suppressedExceptions.add(suppressed);
                }

                if (dex == null) {
                    elements[elementsPos++] = new Element(file);
                } else {
                    elements[elementsPos++] = new Element(dex, file);
                }
            }
            if (dex != null && isTrusted) {
                dex.setTrusted();
            }
        } else {
            System.logW("ClassLoader referenced unknown path: " + file);
        }
    }
    if (elementsPos != elements.length) {
        elements = Arrays.copyOf(elements, elementsPos);
    }
    return elements;
}

然后,makeDexElements方法中,创建了一个Element数组,将之前传入的List集合中文件分组,将带有.dex后缀的文件和其他文件(夹)区分放置

看了这么多,核心在于宿主类加载器如何加载apk中的类呢?看下findClass方法

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;
}

我们可以看到,findClass是遍历dexElements数组,调用Element的findClass方法,如果找到了这个类,那么就直接return,所以如果我们想在宿主app中,加载插件中的类,是不是就可以将插件中的dexElements合并到宿主的dexElements,就可以直接调用了

3.2.2 实现dex合并工具

通过上面的源码,我们可以得到dex合并的思路

(1)获取宿主的dexElements
(2)获取插件的dexElements
(3)将宿主的dexElements和插件的dexElements合并成新的dexElements
(4)将合并之后的dexElements赋值给宿主的dexElements

//获取宿主的dexElement
private fun findBaseDexElement(context: Context): Array<*>? {

    val baseClassLoader = context.classLoader
    val clazz = Class.forName("dalvik.system.BaseDexClassLoader")
    //获取DexPathList
    val pathListFiled = clazz.getDeclaredField("pathList")
    pathListFiled.isAccessible = true
    val pathList = pathListFiled.get(baseClassLoader)

    //获取宿主的dexElement
    val dexClazz = Class.forName("dalvik.system.DexPathList")
    val dexElementsFiled = dexClazz.getDeclaredField("dexElements")
    dexElementsFiled.isAccessible = true

    return dexElementsFiled.get(pathList) as Array<*>?
}

通过反射获取BaseDexClassLoader中的pathList成员变量,然后通过DexPathList来获取对应宿主的dexElements

private fun findPluginDexElement(context: Context, pluginDexPath: String): Array<*>? {
    //加载插件的类加载器

    val classLoader = PathClassLoader(pluginDexPath, null, context.classLoader)
    val clazz = Class.forName("dalvik.system.BaseDexClassLoader")
    val pathListFiled = clazz.getDeclaredField("pathList")
    pathListFiled.isAccessible = true
    //这样获取到的就是插件中的DexPathList
    val pathList = pathListFiled.get(classLoader)
    //获取插件的dexElement
    val dexClazz = Class.forName("dalvik.system.DexPathList")
    val dexElementsFiled = dexClazz.getDeclaredField("dexElements")
    dexElementsFiled.isAccessible = true

    return dexElementsFiled.get(pathList) as Array<*>?
}

对于插件类,宿主启动的时候并没有加载进来,所以不能使用宿主的类加载器,需要新建一个PathClassLoader来加载对应路径下的apk,这样就能生成对应的dexElements,才可以通过反射去获取。

接下来就是需要合并两个dexElement,因为通过反射是没法获取返回值类型,所以返回的类型是Object类型,那么我们可以创建一个Object数组,然后重新赋值给宿主的dexElements吗?显然不行,我们通过源码可以看到,宿主的dexElements需要的是Element类型的数组,所以需要通过反射来创建数组

private fun makeNewDexElements(
    baseDexElement: Array<*>?,
    pluginDexElement: Array<*>?
): Any? {
    if (baseDexElement != null && pluginDexElement != null) {
        val newDexElements = java.lang.reflect.Array.newInstance(
            baseDexElement.javaClass.componentType,
            baseDexElement.size + pluginDexElement.size
        )
        System.arraycopy(baseDexElement, 0, newDexElements, 0, baseDexElement.size)
        System.arraycopy(
            pluginDexElement,
            0,
            newDexElements,
            baseDexElement.size,
            pluginDexElement.size
        )
        return newDexElements
    }
    return null
}

创建了新的newDexElements数组之后,通过系统的arraycopy方法,将两个数组拷贝到新的数组中。

dexElementsFiled.set(pathList, newDexElements)

最终,将组合之后的Element数组重新赋值给宿主app的dexElements。

PluginDexMergeManager.loadPluginDex(this,"/sdcard/plugin-debug.apk")

其实apk插件的存储一般是存储在服务端,然后从服务端拉取下来,下载然后注入到宿主app中,这里只是模拟放在了sdcard下面,但是这里可能存在一个问题,就是第一次启动速度比较慢,但是也只是第一次,后续下载完成之后,就直接取本地缓存即可。

其实在DexPathList中,提供了一个方法addDexPath,可以将dex文件存储的路径传进去,然后内部自动将dex文件跟与宿主dexElements组合在一起

public void addDexPath(String dexPath, File optimizedDirectory, boolean isTrusted) {
    final List<IOException> suppressedExceptionList = new ArrayList<IOException>();
    final Element[] newElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
    suppressedExceptionList, definingContext, isTrusted);

    if (newElements != null && newElements.length > 0) {
        final Element[] oldElements = dexElements;
        dexElements = new Element[oldElements.length + newElements.length];
        System.arraycopy(
            oldElements, 0, dexElements, 0, oldElements.length);
        System.arraycopy(
            newElements, 0, dexElements, oldElements.length, newElements.length);
    }

    if (suppressedExceptionList.size() > 0) {
        final IOException[] newSuppressedExceptions = suppressedExceptionList.toArray(
                new IOException[suppressedExceptionList.size()]);
        if (dexElementsSuppressedExceptions != null) {
            final IOException[] oldSuppressedExceptions = dexElementsSuppressedExceptions;
            final int suppressedExceptionsLength = oldSuppressedExceptions.length +
                    newSuppressedExceptions.length;
            dexElementsSuppressedExceptions = new IOException[suppressedExceptionsLength];
            System.arraycopy(oldSuppressedExceptions, 0, dexElementsSuppressedExceptions,
                0, oldSuppressedExceptions.length);
            System.arraycopy(newSuppressedExceptions, 0, dexElementsSuppressedExceptions,
                oldSuppressedExceptions.length, newSuppressedExceptions.length);
        } else {
            dexElementsSuppressedExceptions = newSuppressedExceptions;
        }
    }
}

这种方式同样可以采用反射调用,具体的实现大家可以动手写一写!本节主要介绍了如何加载插件中的类,调用插件中类的方法,后续会继续介绍加载资源文件的实现。

作者:Ghelper
链接:https://juejin.cn/post/7142475355293499422
来源:稀土掘金

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Android进阶宝典 -- 插件化1(加载插件中类) 的相关文章

随机推荐