Android 宿主启动插件中的Activity和Service
在宿主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"><applicationandroid: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"><activityandroid: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 中的字段 pathListObject pathListObj = RefInvoke.getFieldObject(DexClassLoader.class.getSuperclass(),classLoader,"pathList");// 获取PathList中的字段 Element[] dexElementsObject[] dexElements = (Object[]) RefInvoke.getFieldObject(pathListObj,"dexElements");// Element类型Class<?> elementClass = dexElements.getClass().getComponentType();// 创建一个数组, 用来替换原始的数组Object[] newElements = (Object[]) Array.newInstance(elementClass, dexElements.length + 1);// 构造插件ElementClass[] 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);}@Overrideprotected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {Class<?> clazz = null;try {//先查找parent classLoader,这里实际就是系统帮我们创建的classLoader,目标对应为宿主apkclazz = 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() {// 传入宿主的ClassLoaderZeusClassLoader classLoader = new ZeusClassLoader(mBaseContext.getPackageCodePath(), mBaseContext.getClassLoader());// 添加插件的ClassLoaderFile 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和当前线程的mClassLoaderRefInvoke.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对象也设置为nullRefInvoke.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的生成过程建议查看这个
合并dex版本源码
ZeusClassLoader版本源码