一、说在前面
1. 我所理解的插件化实现原理
初步认识插件化实现Activity跳转,其实就是在一个宿主APP中,通过DexClassLoader加载一个未安装的apk资源(其中包含class和resource),然后利用包名全类名做隐式跳转拉起这个未安装apk内的Activity,未安装的apk所有页面都将基于宿主APP的Context环境和任务栈运行。
宿主APP和插件APP的环境交互,主要通过实现同一套接口标准,利用多态实现反射创建实例;而两者的跳转交互,主要通过宿主APP和插件APP共同提供的跳转通道,使宿插件内部所有启动的Activity都基于宿主APP在启动类里创建的任务栈运行,也就是一直循环在两个应用之间。
2. 画了个简单的关系图
放个效果:
GitHub仓库地址: PluginApp
================================================
二、项目搭建
结构目录如下, app model和plugin_package model都依赖startder library
其中Gradle公共配置的抽取, 详情见 Gradle公共配置抽取
================================================
三、大致思路和问题
项目分为三个模块,一个宿主APP(app model),一个插件APP(plugin_package model),一个接口标准(startder library)。
我们要做的是从宿主MainActivity跳转到插件的PluginActivity,再从插件的PluginActivity跳转到插件的PluginMainActivity,这是两个步骤,需要不同的实现方法。
宿主内如何启动插件内页面 ?
app和plugin_package都依赖于startder,startder包含一个接口IActivity,内置一些生命周期方法和可注入context的方法,让插件APP实现这一接口,随后在宿主APP修改classLoader 和 resource,再通过反射创建插件APP实例,将实现标准的接口类转换成IActivity反射调用,达到启动。
插件内页面如何启动插件内页面?
插件内的页面启动应基于宿主的上下文环境,否则一定失败,如何利用宿主环境启动呢?我们可以利用宿主的环境将插件内的startActivity方法调用到宿主类去,就像这样:appActivity.startActivity(newIntent); 随即将要跳转的Activity class通过Intent传递过去,在宿主类拦截,利用自身的任务栈启动。
说到这你可能有点懵,没事,我们开始撸码。
================================================
四、撸码
一、搭建两者交互的接口标准
public interface IActivity {
/**
* 宿主APP环境
*/
void insertAppContext(Activity appActivity);
/**
* 声明周期
*/
void onCreate(Bundle savedInstanceState);
void onResume();
void onDestroy();
}
二、搭建插件APP的环境
IActivity接口里,我提供了这样几个方法,在插件APP下新建三个Activity,其中BaseActivity实现了IActivity并实现了其方法,另外两个普通类继承自BaseActivity,顺便提一下这些Activity不需要在清单文件注册,因为运行的环境是在宿主中。
// 实现了IActivity接口和其方法,保存宿主的Context环境
public class BaseActivity extends Activity implements IActivity {
// 保存来自宿主的Context
public Activity appActivity;
@SuppressLint("MissingSuperCall")
@Override
public void insertAppContext(Activity appActivity) {
this.appActivity = appActivity;
}
@SuppressLint("MissingSuperCall")
@Override
public void onCreate(Bundle savedInstanceState) {}
@SuppressLint("MissingSuperCall")
@Override
public void onResume() {}
@SuppressLint("MissingSuperCall")
@Override
public void onDestroy() {}
}
// 继承自BaseActivity,页面提供了标识和一个跳转插件APP其他Activity的按钮
public class PluginActivity extends BaseActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_plugin);// 父类重写了此方法
// 不能在插件Activity的xml里使用onClick直接设置点击事件,需要用id的方式
final Button startButton = (Button) findViewById(R.id.btn_plugin_start);
startButton.setOnClickListener( v -> startPluginMain());
}
public void startPluginMain() {}
}
// 继承自BaseActivity,页面提供了一个标识
public class PluginMainActivity extends BaseActivity {
@Override
public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) {
super.onCreate(savedInstanceState, persistentState);
setContentView(R.layout.activity_plugin_main);
}
}
到这里插件APP的环境就算是搭建好了,接下来处理宿主APP。
三、搭建宿主APP环境
1. 宿主APP提供了这样几个类
其中MainActivity提供了一个拉起插件APP页面的按钮,并预加载插件apk资源,加载过程由PluginManager类实现;ProxyActivity代理了MainActivity的页面承载,负责接收任务栈创建和管理,接收和启动插件APP内的所有页面。
此Demo就这么几个类,主要理清其原理。
<!--注册Activity-->
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ProxyActivity"/>
<!--注册权限,另外Activity也需要动态申请权限,我就踩了这个坑-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
2. 处理插件apk资源加载
先来到PluginManager,我们需要做的就是加载插件apk中的资源,其中包含Class资源 和 resource资源。
// 先提供了带Context的单例和构造方法
public static PluginManager getInstance(Context context) {
if (sPluginManager == null) {
synchronized (PluginManager.class) {
if ((sPluginManager == null)) {
sPluginManager = new PluginManager(context);
}
}
}
return sPluginManager;
}
public PluginManager(Context context) {
mContext = context;
}
接着我们来实现加载资源的方法
// 加载apk资源
public void loadPlugin(){}
1. 提供一个插件apk的存放路径,比如:/storage/emulated/0/a.apk
File pluginPak = new File(Environment.getExternalStorageDirectory() + File.separator + Constant.APK_NAME);
if (!pluginPak.exists()) {
System.out.println( "loadPlugin: 插件apk不存在!");
return;
}
2. 拿到插件apk的DexClassLoader
// DexClassLoader需要一个加载时存放apk的缓存路径
File cacheDir = mContext.getDir(Constant.APK_CACHE_DIR, Context.MODE_PRIVATE);// apk缓存目录
// 传入插件apk的本地存放路径和缓存路径
mDexClassLoader = new DexClassLoader(pluginPak.getAbsolutePath(),cacheDir.getAbsolutePath(),null, mContext.getClassLoader());
System.out.println( "loadPlugin: 插件apk class加载成功===" + mDexClassLoader.toString());
3. 加载插件apk内资源
AssetManager assetManager = AssetManager.class.newInstance();
// AssetManager类里有一个方法用于加载资源: addAssetPath(String path),path是需要加载的apk路径
Method addAssetPathMethod = assetManager.getClass().getMethod(Constant.ADD_ASSET_PATH, String.class);
System.out.println( "loadPlugin: 插件apk的路径===" + pluginPak.getAbsolutePath() + " 插件apk的缓存路径===" + cacheDir.getAbsolutePath());
// 传入插件apk的缓存路径,反射调用
addAssetPathMethod.invoke(assetManager, pluginPak.getAbsolutePath());
mResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics(),mContext.getResources().getConfiguration());
System.out.println( "loadPlugin: 插件apk resource加载成功===" + mResources);
4. 提供获取插件apk的DexClassLoader和Resources的方法
public ClassLoader getClassLoader() {
return mDexClassLoader;
}
public Resources getResources(){
return mResources;
}
到这里加载插件apk资源已经完成了,咱们到MainActivity初始化一下
3. MainActivity初始化插件apk资源和启动插件apk
/**
* 加载插件apk资源
*/
public void loadPlugin() {
PluginManager.getInstance(this).loadPlugin();
}
/**
* 启动插件内页面
* @param view v
*/
public void startPluginActivity(View view) {
// 为了不干扰宿主APP的扩展, 通过宿主代理的Activity去处理插件内所有相关的Activity的事务
// 跟之前的路径一样,插件apk的存放路径
File pluginApk = new File(Environment.getExternalStorageDirectory() + File.separator + Constant.APK_NAME);
System.out.println( "MainActivity: pluginApkPath===" + pluginApk.getAbsolutePath());
// 通过getPackageArchiveInfo()方法,用apk path拿到插件APP的packageInfo
PackageInfo packageArchiveInfo = getPackageManager().getPackageArchiveInfo(pluginApk.getAbsolutePath(), PackageManager.GET_ACTIVITIES);
if (packageArchiveInfo == null) {
System.out.println( "MainActivity: packageInfo 为null");
return;
}
if (packageArchiveInfo.activities.length == 0) {
System.out.println( "MainActivity: 插件APP内没有Activity");
return;
}
ActivityInfo activityInfo = packageArchiveInfo.activities[0];
System.out.println( "MainActivity: activityInfo===" + activityInfo.toString());
// 交给代理Activity处理, 把要启动的Activity全类名(带包名)传递过去
Intent intent = new Intent(this,ProxyActivity.class);
intent.putExtra(Constant.CLASS_NAME, activityInfo.name);
startActivity(intent);
}
这里提醒一下,千万不要忘记动态申请权限,否则会在获取packageInfo时报错:
android.content.pm.PackageParser$PackageParserException: Failed to parse /storage/emulated/0/a.apk
我当时报出这个错,也没注意到权限相关的提示,于是好久没找到原因
4. ProxyActivity的事务
1. 在onCreate方法里注入宿主的Context环境
首先,这个Activity作为承载表示是不需要页面展示的,所以直接干掉setContentView。
思考一下上文提到的负责接收和启动插件APP的事务,那么这个Activity需要干什么?
1. 接收MainActivity传递来的插件内的Activity
2. 接收插件内Activity传递来的插件内Activity
我们始终要记住一个问题:从MainActivity拉起插件内Activity后,此后所有的跳转都基于全类名的隐式启动。那么下面就开始实现接收和启动。
- 重写getClassLoader 和 getResources方法,拿到插件APP的DexClassLoader和Resources
// 我们已经在MainActivity里预加载了这两项
@Override
public Resources getResources() {
return PluginManager.getInstance(this).getResources();
}
@Override
public ClassLoader getClassLoader() {
return PluginManager.getInstance(this).getClassLoader();
}
- 在onCreate里接收要启动的Activity
String className = getIntent().getStringExtra(Constant.CLASS_NAME);
if(className == null){
System.out.println( "ProxyActivity: 接收来自MainActivity的className为null");
return;
}
- 启动并注入Context环境
try {
// 传入要启动的class
Class<?> pluginActivityClass = getClassLoader().loadClass(className);
// 实例化插件apk里的入口Activity
Constructor<?> constructor = pluginActivityClass .getConstructor(new Class[]{});
Object instance = constructor.newInstance(new Object[] {});
// 将插件APP的入口Activity强转为IActivity(也就是实现了IActivity接口标准的BaseActivity)
IActivity iActivity = (IActivity) instance;
// 注入宿主环境
iActivity.insertAppContext(this);
// 执行插件apk里的onCreate
iActivity.onCreate(null);
System.out.println( "ProxyActivity: 插件apk内onCreate已执行");
...可以执行更多自己想要的操作
} catch (Exception e) {
e.printStackTrace();
System.out.println( "ProxyActivity: 插件apk注入内容出错===" + e.getMessage());
}
- 接收和启动插件内Activity
这个操作其实很简单,重写startActivity方法,相当于拦截了插件APP里用宿主上下文启动的方法。
至此,宿主内环境也已经搭建完了。@Override public void startActivity(Intent intent) { // 重写方法,拦截来自插件APP调用传递的intent // 启动自身Activity,再次走onCreate方法循环(模拟任务栈) Intent newIntent = new Intent(this, ProxyActivity.class); newIntent.putExtra(Constant.CLASS_NAME, intent.getStringExtra(Constant.CLASS_NAME));// 携带参数为插件APP传递过来要跳转的Activity.class super.startActivity(newIntent);// 一定要调用super的启动方法,不然就是递归调用本方法 }
四、测试跳转
其实,这时候宿主APP已经能拉起插件APP了,但插件APP内启动插件Activity我们还差最后一步没完成。
1. 重写需要用到Context的方法
1. setContentView
2. findViewById
3. startActivity
涉及到公用,这几个方法就放在BaseActivity了
2. BaseActivity重写方法
public void setContentView(int resId) {
appActivity.setContentView(resId);
}
public View findViewById(int viewId){
return appActivity.findViewById(viewId);
}
public void startActivity(Intent intent) {
ComponentName component = intent.getComponent();
if(component == null){
System.out.println("BaseActivity: component为null");
return;
}
Intent newIntent = new Intent();
newIntent.putExtra(Constant.CLASS_NAME, component.getClassName());// className 是带包名的全类名
// 通过宿主的Activity环境启动,方法调用到宿主的startActivity方法里(到宿主Activity重写此方法)
appActivity.startActivity(newIntent);
}
3. 插件内Activity启动插件内Activity
public class PluginActivity extends BaseActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_plugin);// 父类重写了此方法
findViewById(R.id.btn_plugin_start).setOnClickListener( v -> startPluginMain());
}
// 不能在插件Activity的xml里使用onClick直接设置点击事件,需要用id的方式
public void startPluginMain() {
// 插件内跳转Activity必须回到宿主APP,也就是在ProxyActivity内启动了pluginActivity,再启动ProxyActivity再启动pluginMainActivity
Intent intent = new Intent(appActivity, PluginMainActivity.class);
startActivity(intent);
}
}
五、结束
到这里,整个过程已经全部完成,总结一下这个过程的感受和这种方式的缺点:
1. 调试麻烦,自己比较粗心,调试的过程中细节不注意就要排查问题
2. 插件APP一旦修改一丁点东西都需要重新下载新的apk
如果你在实现过程中出现问题,请参考下面几个问题
/**
* 1. 宿主APP一定要动态申请权限
* 2. 插件APP内的Activity除入口Activity以外其他的可以不用注册, 也不需要注册权限或动态申请权限
* 3. 插件内使用到context的地方一律使用用宿主的环境
* 4. 重写startActivity方法时调用super的方法,不然会递归调用自己
* 5. 不能在插件Activity的xml里使用onClick直接设置点击事件,需要用id的方式
*/
最后希望大家一切顺利,技术牛逼!