在宿主App中加载插件App中的四大组件,需要以下几个步骤:
1. 预先在宿主的AndroidManifest文件中声明插件中的四大组件
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.chinatsp.zeusstudy1">
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ZeusStudy1"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.chinatsp.zeusstudy1.ActivityA"/>
<!-- 插件中的类 -->
<service android:name="com.chinatsp.plugin1.TestService1"/>
<activity android:name="com.chinatsp.plugin1.TestActivity1"/>
</application>
</manifest>
2. 宿主加载插件的类
2.1 合并所有插件的dex 来解决插件的类的加载问题
把插件dex都合并到宿主的dex中,那么宿主App对应的ClassLoader就可以而加载插件中的任意类了
static void mergeDexs(String apkName, String dexName) {
File dexFile = mBaseContext.getFileStreamPath(apkName);
File optDexFile = mBaseContext.getFileStreamPath(dexName);
try {
BaseDexClassLoaderHookHelper.pathClassLoader(mBaseContext.getClassLoader(), dexFile, optDexFile);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 由于应用程序使用的ClassLoader为PathClassLoader
* 最终继承自 BaseDexClassLoader
* 查看源码得知,这个BaseDexClassLoader加载代码根据一个叫做
* dexElements的数组进行, 因此我们把包含代码的dex文件插入这个数组
* 系统的classLoader就能帮助我们找到这个类
*
* 这个类用来进行对于BaseDexClassLoader的Hook
* @author weishu
* @date 16/3/28
*/
public final class BaseDexClassLoaderHookHelper {
public static void pathClassLoader(ClassLoader classLoader, File apkFile,File dexFile) throws IllegalAccessException,NoSuchMethodException, IOException, InvocationTargetException,
InstantiationException,NoSuchFieldException {
// 获取BaseDexClassLoader 中的字段 pathList
Object pathListObj = RefInvoke.getFieldObject(DexClassLoader.class.getSuperclass(),classLoader,"pathList");
// 获取PathList中的字段 Element[] dexElements
Object[] dexElements = (Object[]) RefInvoke.getFieldObject(pathListObj,"dexElements");
// Element类型
Class<?> elementClass = dexElements.getClass().getComponentType();
// 创建一个数组, 用来替换原始的数组
Object[] newElements = (Object[]) Array.newInstance(elementClass, dexElements.length + 1);
// 构造插件Element
Class[] params = {DexFile.class,File.class,};
DexFile dexFile1 = DexFile.loadDex(apkFile.getCanonicalPath(),dexFile.getAbsolutePath(),0);
Object[] values = {dexFile1,apkFile};
Object dexObj = RefInvoke.createObject(elementClass,params,values);
Object[] toAddNewElementArray = new Object[]{dexObj};
// 把原始的elements复制进去
System.arraycopy(dexElements,0,newElements,0,dexElements.length);
// 插件的那个element复制进去
System.arraycopy(toAddNewElementArray,0,newElements,dexElements.length,toAddNewElementArray.length);
// 替换
RefInvoke.setFieldObject(pathListObj,"dexElements",newElements);
}
}
在Applciation的attachBaseContext 方法中调用该方法将插件的dex合并进宿主的dexElements中就可以了。
通过以上两步,我们就可以正常的打开插件App中的Service和Activity类了
public void startService1InPlugin1(View view) {
try {
Intent intent = new Intent();
String serviceName = PluginManager.plugins.get(0).packageInfo.packageName + ".TestService1";
intent.setClass(this, Class.forName(serviceName));
startService(intent);
} catch (Exception e) {
e.printStackTrace();
}
}
public void startActivityInPlugin1(View view){
try {
Intent intent = new Intent();
String activityName = PluginManager.plugins.get(0).packageInfo.packageName + ".TestActivity1";
intent.setClass(this, Class.forName(activityName));
startActivity(intent);
}catch (Exception e){
e.printStackTrace();
}
}
2.2 修改app原生的ClassLoader
直接把系统的
ClassLoader
替换为我们自己的ZeusClassLoader
。ZeusClassLoader
的构造函数中将会传递进宿主的ClassLoader
,除此之外,其内部有一个mClassLoaderList
变量,保存着所有插件ClassLoader
的集合。于是ZeusClassLoader
的loadClass
方法,会先尝试使用宿主ClassLoader
加载类,如果不能加载,就遍历mClassLoaderList
,直到找到一个能加载类的ClassLoader
。
/***
* 这是一个空ClassLoader,主要是个容器
* <p>
* Created by huangjian on 2016/6/21.
*/
class ZeusClassLoader extends PathClassLoader {
private List<DexClassLoader> mClassLoaderList = null;
public ZeusClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, parent);
mClassLoaderList = new ArrayList<DexClassLoader>();
}
/**
* 添加一个插件到当前的classLoader中
*/
protected void addPluginClassLoader(DexClassLoader dexClassLoader) {
mClassLoaderList.add(dexClassLoader);
}
@Override
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
Class<?> clazz = null;
try {
//先查找parent classLoader,这里实际就是系统帮我们创建的classLoader,目标对应为宿主apk
clazz = getParent().loadClass(className);
} catch (ClassNotFoundException ignored) {
}
if (clazz != null) {
return clazz;
}
//挨个的到插件里进行查找
if (mClassLoaderList != null) {
for (DexClassLoader classLoader : mClassLoaderList) {
if (classLoader == null) continue;
try {
//这里只查找插件它自己的apk,不需要查parent,避免多次无用查询,提高性能
clazz = classLoader.loadClass(className);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException ignored) {
}
}
}
throw new ClassNotFoundException(className + " in loader " + this);
}
}
private static void composeClassLoader() {
// 传入宿主的ClassLoader
ZeusClassLoader classLoader = new ZeusClassLoader(mBaseContext.getPackageCodePath(), mBaseContext.getClassLoader());
// 添加插件的ClassLoader
File dexOutputDir = mBaseContext.getDir("dex", Context.MODE_PRIVATE);
final String dexOutputPath = dexOutputDir.getAbsolutePath();
for(PluginItem plugin: plugins) {
DexClassLoader dexClassLoader = new DexClassLoader(plugin.pluginPath,
dexOutputPath, null, mBaseClassLoader);
classLoader.addPluginClassLoader(dexClassLoader);
}
// 替换PackgeInfo和当前线程的mClassLoader
RefInvoke.setFieldObject(mPackageInfo, "mClassLoader", classLoader);
Thread.currentThread().setContextClassLoader(classLoader);
mNowClassLoader = classLoader;
}
经过这样Hook,所有插件的ClassLoader都在一起了。但是原先启动Activity/Service的方式需要改变:
public void startService1InPlugin1(View view) {
try {
Intent intent = new Intent();
String serviceName = PluginManager.plugins.get(0).packageInfo.packageName + ".TestService1";
// intent.setClass(this, Class.forName(serviceName));
intent.setClass(this, getClassLoader().loadClass(serviceName));
startService(intent);
} catch (Exception e) {
e.printStackTrace();
}
}
我们之前是使用
Class.forName
方法来启动Servcie,会抛出找不到宿主Apk或找不到插件Service类的异常,这是因为Class.forName
方法会使用BootClassLoader
来加载类,这个类并没有被Hook,所以自然也就加载不到插件中的类了。
而getClassLoader
方法获取到的是我们Hook过的新ClassLoader
,就可以加载到插件中的类了。
3. 加载插件中的资源
四大组件除了Activity,其他都是没有界面的,因此不涉及到资源。Activity则严重依赖资源文件,所以要想正确的显示插件中的Activity,必须解决加载插件中资源的问题。
宿主想要加载插件中的资源,我们是怎么做的呢?
生成一个新的
AssetManager
对象newAssetManager
,发射调用这个newAssetManager
的addAssetPath
方法把插件Apk的路径加载进来,然后根据这个newAssetManager
生成一个新的Resource
对象newResource
,然后在宿主Activity中重写getResources
和getAssets
返回newAssetManager
和newResource
,这样宿主Activity就可以查找到插件Apk中的资源了。
这是一种分离宿主和插件资源的方式。还有另一种合并宿主和插件资源的方式。
创建一个新的 AssetManager 对象,并将宿主和插件的资源都通过addAssetPath方法塞入;通过新的AssetManager对象来创建出一个新的Resources对象;将新的Resources对象替换ContextImpl中的mResources变量、LoadedApk变量里的mResources变量 以及 置空mThem变量
所以按照上述步骤通过代码实现如下:
public static void reloadInstalledPluginResources(ArrayList<String> pluginPaths) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
// 塞入宿主的资源
addAssetPath.invoke(assetManager, mBaseContext.getPackageResourcePath());
for(String pluginPath: pluginPaths) {
// 塞入插件的资源
addAssetPath.invoke(assetManager, pluginPath);
}
mAssetManager = assetManager;
Resources newResources = new Resources(assetManager,
mBaseContext.getResources().getDisplayMetrics(),
mBaseContext.getResources().getConfiguration());
// 获取 ContextImpl 中的 Resources 类型的 mResources 变量,并替换它的值为新的 Resources 对象
RefInvoke.setFieldObject(mBaseContext, "mResources", newResources);
//这是最主要的需要替换的,如果不支持插件运行时更新,只留这一个就可以了
RefInvoke.setFieldObject(mPackageInfo, "mResources", newResources);
mNowResources = newResources;
//需要清理mTheme对象,否则通过inflate方式加载资源会报错
//如果是activity动态加载插件,则需要把activity的mTheme对象也设置为null
RefInvoke.setFieldObject(mBaseContext, "mTheme", null);
} catch (Throwable e) {
e.printStackTrace();
}
}
上述方法的调用时机就是在执行完插件dex合并后调用。如果此时要在宿主中调用插件中的Activity,还需要做一件事情,就是将上面方法中的
mNowResources
对象传递到插件中的Activity中去,并重写插件Activity的getResources
方法,使getResources
方法返回mNowResources
对象即可。
但是这样完成之后会发现,怎么明明代码中启动的是插件中的Activity,但显示的确是宿主中的Activity呢?
如果你把宿主的Apk和插件的Apk打开( 使用AS中的Build ==》Analazy Apk ),查看其中的resources.arsc文件,对比后会发现两个APK中的资源文件有相同的id,这就是资源id冲突引起的问题,可以通过 配置aapt2 的参数来修改插件Apk生成的资源id:
# build.gradle
android {
...
aaptOptions {
additionalParameters '--allow-reserved-package-id','--package-id','0x70'
}
}
这样,插件Apk中生成的资源id就会以0x70开头,而我们的宿主Apk生成的资源id默认是0x7f开头。
资源id的生成过程建议查看这个