Android简单插件化

10,880 阅读5分钟

1 插件化简介

插件化缘来

插件化技术最初源于免安装运行APK的想法,这个免安装的APK就可以理解为插件,而支持插件的APP我们一般叫宿主。

插件化解决的问题

  1. APP的功能模块越来越多,体积越来越大
  2. 模块之间的耦合度高,协同开发沟通成本越来越大
  3. 方法数目可能超过65535,APP占用的内存过大
  4. 应用之间的互相调用

插件化与组件化

组件化:组件化开发就是将一个APP分成多个模块,每个模块都是一个组件,开发的过程中我们可以让这些组件相互依赖或者单独调试部分组件等,但是最终发布的时候是将这些组件合并统一成一个APK,这就是组件化开发。

插件化:插件化开发和组件化略有不同,插件化开发是将整个APP拆分成多个模块,这些模块包括一个宿主和多个插件,每个模块都是一个APK,最终打包的时候宿主APK和插件APK分开打包。

常用插件化框架对比

特性 dynamic-load-apk DynamicAPK Small DroidPlugin VirtualAPK
作者 任玉刚 携程 wequick 360 滴滴
支持四大组件 只支持Activity 只支持Activity 只支持Activity 全支持 全支持
组件需要/不需要在宿主manifest中预注册 不需要 需要 不需要 不需要 不需要
插件可以/不可以依赖宿主 可以 可以 可以 不可以 可以
支持/不支持PendingIntent 不支持 不支持 不支持 支持 支持
Android特性支持 大部分 大部分 大部分 几乎全部 几乎全部
兼容性适配 一般 一般 中等
插件构建 部署aapt Gradle插件 Gradle插件

2 类加载机制

2.1 Android类加载机制

我们知道Java的类加载采用双亲委托的加载机制,有效防止类的重复加载和保护核心类的加载。Android的加载机制与Java的类加载机制类似,但也有区别。Android中的各个类加载器之间的关系如下图所示。

Android类加载机制

在Android中,在APP运行时,所有的应用自有的包含字节码文件(.class)的dex文件被包装成一个 Element对象,放在了一个Element[] elements数组中,每一个element元素对应一个dex文件,应用寻找特定某个类时,会从前往后依次遍历该数组,直到找到或遍历到尾。

本文讲的简单插件化实现就利用该Element数组实现:新建一个Element[] newElements数组,在新建的newElements数组中实现宿主的Element[] hostElements数组和插件Element[] pluginElements数组的拼接,并用新的Element数组替换宿主原有的数组。

QZone dex分包修复原理

2.2 寻找Element数组

当系统通过PathClassLoader去加载应用程序的dex文件中Java类时,PathClassLoader并没有重写loadClass()方法,所以接下来由PathClassLoader的父类BaseDexClassLoader尝试执行加载任务,然而BaseDexClassLoader也没有重写loadClass()方法,则依次向上调用父类加载器的loadClass()方法,父类加载器(由最顶级父类加载器开始尝试loadClass)在各自的加载范围内尝试加载需要的类,失败之后依次向下调用子类加载器的findClass()方法(也就是双亲委托机制那一套)。最终会调用到BaseDexClassLoaderfindClass()方法。

// 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);
    }
}
public class BaseDexClassLoader extends ClassLoader {
    // ...
	private final DexPathList pathList;
	// ...
	@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类中私有成员属性DexPathList pathListfindClass()方法。

final class DexPathList {
    // ...
    private Element[] dexElements;
    // ...
    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;
    }
    // ...
}

在这段代码中,用增强for循环从前往后去遍历dexElements数组,该数组即为上面图片中的提及的Element[]数组

3 项目实例

3.1 项目结构

项目结构

3.2 生成插件APK

3.2.1 插件MainActivity类

插件中的MainActivity.java未额外处理,如下:

package com.tongbo.plugin;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;

public class MainActivity extends AppCompatActivity {

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

3.2.2 插件Test类

Test类用于之后插件引入之后的测试,如下:

package com.tongbo.plugin;

import android.util.Log;

public class Test {
    public static void print() {
        Log.e("TB", "Test class from com.tongbo.plugin");
    }
}

3.2.3 ★生成插件APK并上传★

  • 运行plugin模块;
  • plugin\build\outputs\apk\debug中找到plugin-debug.apk,并上传到模拟器的/sdcard/路径。

上传插件APK

3.3 宿主载入插件

3.3.1 宿主MainActivity类

  • onCreate()回调中,调用LoadUtil.loadClass(this)实现插件的加载(详见3.3.2);

  • 在宿主的主页面,添加一个按钮,并在onClick属性绑定toStartPlugin(View view)方法,并在方法中反射调用插件的类的方法Test.print()

package com.tongbo.pluginbasic;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import java.lang.reflect.Method;

public class MainActivity extends AppCompatActivity {

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

        LoadUtil.loadClass(this);
    }

    public void toStartPlugin(View view) {
        Log.e("TB", "btn is clicked.");
        try {
            Class<?> clazz = Class.forName("com.tongbo.plugin.Test");
            Method method = clazz.getMethod("print");
            method.invoke(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3.3.2 宿主LoadUtil类

LoadUtil类中的loadClass(Context context)方法是核心的插件载入方法,主要分为以下几个步骤:

  • 第一步,反射得到dalvik.system.BaseDexClassLoaderpathList字段以及dalvik.system.DexPathListdexElements字段并设置访问权限;
  • 第二步,反射获得宿主的dexElements字段的值;
  • 第三步,反射获得插件的dexElements字段的值;
  • 第四步,利用Array.newInstance()方法创建新的Element[],在新创建的Element[]中利用System.arraycopy()方法完成数组的拼接;
  • 第五步,设置宿主的dexElements字段的值为新建的Element[],完成插件加载。
package com.tongbo.pluginbasic;

import android.content.Context;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import dalvik.system.DexClassLoader;
import dalvik.system.PathClassLoader;

public class LoadUtil {

    private final static String pluginApkPath = "/sdcard/plugin-debug.apk";

    public static void loadClass(Context context) {

        try {
            //TODO:to get 'pathList' field and 'dexElements' field by reflection.
            //private final DexPathList pathList;
            Class<?> baseDexClassLoaderClass = Class.forName("dalvik.system.BaseDexClassLoader");
            Field pathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
            pathListField.setAccessible(true);
            //private Element[] dexElements;
            Class<?> dexPathListClass = Class.forName("dalvik.system.DexPathList");
            Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
            dexElementsField.setAccessible(true);

            //TODO:to get the value of host's dexElements field.
            PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
            Object hostPathList = pathListField.get(pathClassLoader);
            Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList);

            //TODO:to get the value of plugin's dexElements field.
            DexClassLoader dexClassLoader = new DexClassLoader(
                    pluginApkPath,
                    context.getCacheDir().getAbsolutePath(),
                    null,
                    context.getClassLoader()
            );
            Object pluginPathList = pathListField.get(dexClassLoader);
            Object[] pluginDexElements = (Object[]) dexElementsField.get(pluginPathList);

            //TODO:to add host's dexElements and plugin's dexElements together in a newly created Element array.
            Object[] dexElements = (Object[]) Array.newInstance(
                    pluginDexElements.getClass().getComponentType(),
                    hostDexElements.length + pluginDexElements.length
            );
            System.arraycopy(
                    hostDexElements,
                    0, dexElements,
                    0,
                    hostDexElements.length
            );
            System.arraycopy(
                    pluginDexElements,
                    0,
                    dexElements,
                    hostDexElements.length,
                    pluginDexElements.length
            );

            //TODO:to set the newly created Element array to the host's dexElements field and done.
            dexElementsField.set(hostPathList, dexElements);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3.4 测试

将宿主APP运行起来,点击按钮,控制台将打印如下(成功):

测试