Android 占位式插件化原理实现(一) 如何实现拉起插件apk

一、说在前面

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,这是两个步骤,需要不同的实现方法。

        宿主内如何启动插件内页面 ?
appplugin_package都依赖于startder,startder包含一个接口IActivity,内置一些生命周期方法和可注入context的方法,让插件APP实现这一接口,随后在宿主APP修改classLoaderresource,再通过反射创建插件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后,此后所有的跳转都基于全类名的隐式启动。那么下面就开始实现接收和启动。

  1. 重写getClassLoadergetResources方法,拿到插件APP的DexClassLoader和Resources
// 我们已经在MainActivity里预加载了这两项
@Override
    public Resources getResources() {
        return PluginManager.getInstance(this).getResources();
    }

    @Override
    public ClassLoader getClassLoader() {
        return PluginManager.getInstance(this).getClassLoader();
    }
  1. onCreate里接收要启动的Activity
	String className = getIntent().getStringExtra(Constant.CLASS_NAME);
    if(className == null){
        System.out.println( "ProxyActivity: 接收来自MainActivity的className为null");
        return;
     }
  1. 启动并注入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());
        }
  1. 接收和启动插件内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的方式
         */

最后希望大家一切顺利,技术牛逼!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柯基爱蹦跶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值