关于插件化这个技术如今也已经烂大街了,不过遗憾的是在自己的职业生涯中还木有真正遇到过插件化的公司项目,本来有一家公司正准备有机会跟着一位大神参与插件化的从0重构,但是好景不长最终夭折了~~于是私底下对于插件化技术的研究从未停止过,但是从未对它进行过笔录,对于这个话题其实去面试时也会被经常问到,毕境里面涵盖的技术含量还是有的,所以接下来准备花些篇幅系统全面的对这项技术进行一个全面的梳理。
插件化概念了解:
动态加载技术:
说到插件化,其实它是属于动态加载技术的一个特例,所以有必要了解一下动态加载。
原理:在应用程序运行时,动态加载一些程序中原本不存在的可执行文件并运行这些文件里的代码逻辑。可执行文件总的来说分为两个,一种是动态链接库so,另一种是dex相关文件(dex文件包含jar/apk文件)。
市面上基于这种技术的有两种:
- 热修复:它的出现主要是用来动态修复应用中的bug的,如hotfix、tinker,上家公司用的热修复框架就是tinker,关于这块的东东也是值得研究的,待之后再进行梳理。
- 插件化: 主要用于解决应用越来越庞大的以及功能模块的解耦,所以小项目中一般用的不多。这个也是就这次要来研究的。
插件化的出现原因:
- 业务复杂,模块解耦。
- 应用间的接入,举支付宝的场景就秒懂了,如下:
有这么多三方应用,而且又不是用的webview接入的,那很明显集成到一个apk中是不现实的。
- 65536限制,内存占用大。
实现插件化的方式:
- 插桩式,重点学习。
- Hook方式,这个到时也会学习一个Hook的效果。
- 反射,但是在Android9.0中有很多反射是用不了了,所以这种基本上不会用了。
插庄式插件初步实现---Activity跳转:
插庄式原理:
在正式撸码之前,先来对这个插庄式的搞法有一个简单的了解,下面用图来表述一下:
![](https://i-blog.csdnimg.cn/blog_migrate/c4267f2703169adfbe5d0310791eb162.png)
此时这个滴滴出行就是以一个apk单独的插件存在的,宿主app要来打开这个滴滴出行插件的apk中某个Activity,但是试想一下,此时滴滴出行这个插件很显然是没有上下文对象的【为啥没有?因为此插件木有安装到手机上~~】,要想启动Activity必须要解决上下文这个东东,所以此时就需要在宿主APP中插一个桩,声明一个代理的Activity,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/4b82d8455b8b7c9565b5f4a4a161b8f8.png)
此时ProxyActivity是一个空壳,那还是木有显示插件的东东呀,怎么办?其实是这样的:
![](https://i-blog.csdnimg.cn/blog_migrate/095c2f5d8150bc24261461fc3329db4f.png)
那怎么能将一个未安装的插件apk的Activity能显示在这个代理Activity之上呢?具体实现再来揭晓,这里要明白要想让Activity显示出来肯定得要调用它里面的生命周期方法,而对于插件而言就是将自己Activity中的各种生命周期方法通过接口对外暴露给宿主的ProxyActivity,然后插件Activity中需要的Context则是借用ProxyActivity,这样最终就能达到我们调用的目的,目的达成最终插件化也就这实现了,当然除了调用插件的Activity,还有Service、BroadcastReceiver也都可以进行插件化的调用,不过这个之后再来学习,先把Activity的显示搞定,下面开撸。。
具体实现:
框架搭建:
新建项目:
![](https://i-blog.csdnimg.cn/blog_migrate/287f8d05bf6415894846c1f01c11533c.png)
目前这个app就是一个宿主了,然后再新建一个module来制作将来我们的插件,就以didi来命名,注意是以Application来运行,而非Library来运行哈:
![](https://i-blog.csdnimg.cn/blog_migrate/5d3370ed6d4eda2431dbbe859d1260c4.png)
为了直观看到didi的页面,先来改个文案:
![](https://i-blog.csdnimg.cn/blog_migrate/89676cffef044a6b362600e3509e2cda.png)
由于需要用一个公共接口来暴露整个Activity的方法,所以还需要定义一个宿主app和插件app之间公共的library,里面会定义各种公共接口,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/d69d2d87bfc1764766dd0438d4c18e76.png)
然后再添加一下对它的依赖:
![](https://i-blog.csdnimg.cn/blog_migrate/4c3fa845608419ca9bcb8f80e8bba8a6.png)
![](https://i-blog.csdnimg.cn/blog_migrate/8ef4d021c05b237efbe5c454adf8d072.png)
里面先来定义一个Activity生命周期的公共接口,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/30a4634bc5d77f522497ac2a1e8cda6c.png)
然后插件Activity得要将其生命周期方法对外暴露,所以需要实现这个接口:
![](https://i-blog.csdnimg.cn/blog_migrate/19726278dfc6d61627a76fecc2b562da.png)
但是如图并未对接口中的方法进行重写,为啥?因为这样写是不合适的,插件中肯定会有N多个Activity的,所以需要抽取一个BaseActivity出来,然后再由它来实现抽象接口才靠谱,所以:
![](https://i-blog.csdnimg.cn/blog_migrate/ad9c69166e006ba68b11cd881b985812.png)
![](https://i-blog.csdnimg.cn/blog_migrate/a3d98948125b1ef6b37b6b92e23c2373.png)
![](https://i-blog.csdnimg.cn/blog_migrate/808645a70dd4b04164f1bc357497637c.png)
![](https://i-blog.csdnimg.cn/blog_migrate/f229955d4192a5da128327b5f6211d3e.png)
解决插件中木有上下文生命周期调用的问题:
在上面原理上也讲到过,插件是一个不会装在手机上的apk,那么Activity的显示依赖的上下文肯定是木有的,所以来看一下现在的问题:
![](https://i-blog.csdnimg.cn/blog_migrate/e4ec2d0a2776bacb50ec63aacd5cc3cd.png)
这个目前肯定是要依赖于上下文才能正常设置好布局,而对于插件而言木有上下文的话则需要在BaseActivity中来重写一个这个方法:
![](https://i-blog.csdnimg.cn/blog_migrate/4e181146484fb61619e1af513343b351.png)
而在上面的理论描述来说,对于插件的上下文对象会借用宿主的,那如何借用呢?咱们看一下这个attach()方法:
![](https://i-blog.csdnimg.cn/blog_migrate/b2804bbbb15d4816969acbbf589bb7cc.png)
如果这个attach()方法最终由宿主来调用,那此时参数Activity是不是就是宿主的Context了?是的,所以咱们可以将这个Activity给保存一下,然后如下:
![](https://i-blog.csdnimg.cn/blog_migrate/94d1bf2249cfe1e54d198e2bd244c2be.png)
好,关于插件的代码暂时写到这,接下来则要转到宿主的代理对象了。
ProxyActivity代理对象登场:
新建之:
![](https://i-blog.csdnimg.cn/blog_migrate/e080d4d790e80ac4d6ee89fa4b8944ef.png)
![](https://i-blog.csdnimg.cn/blog_migrate/e2702d20eb6bc516e98f5518bdb11370.png)
好,接下来则需要想办法来将它的Context传递到插件的Activity里去,也就是想办法去调用插件的BaseActivity.attach()方法,那怎么来调用呢?还是需要用到反射,这里需要知道要跳转插件Activity的全类名,所以这里通过Intent的参数传进来,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/c5294088ea494df42efeeb3d8990792c.png)
接下来则通过反射来获取到要跳转插件Activity的对象,由于插件的所有Activity都继承了BaseActivity了,而BaseActivity又实现了公共模块的InterfaceActivity接口,所以最终就可以调用attach方法啦,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/ca5a0fb178c473c652254c3c7c14dc2a.png)
这样对于插件化Activity生命周期的调用就都可以通过这个接口来进行调用了,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/db2ec81ef52992be7de3958fabfd2ac6.png)
在宿主中加载插件:
对于插件一般是从服务器进行远程下载到手机sdcard上的,所以要跳转到插件的界面,肯定得先要将插件的类加载进来才行,所以接下来处理这块的逻辑,先在布局中增加一个加载按钮:
![](https://i-blog.csdnimg.cn/blog_migrate/da925f3eaddf43301cd0d8e8cfbc2274.png)
![](https://i-blog.csdnimg.cn/blog_migrate/4e53db52b0262001b3894a4b7cdc148d.png)
咱们具体来实现一下,由于插件如果存在sdcard的话是不太安全的,用户可以随意删除,所以为了对我们的插件有一个保护作用,将其拷贝到/data目录下,代码比较简单:
private void loadPlugin() {
File filesDir = this.getDir("plugin", Context.MODE_PRIVATE);//这里就是获得app的data目录,没有root权限用户是看不到它的
String name = "plugin.apk";
String filePath = new File(filesDir, name).getAbsolutePath();
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
InputStream is = null;
FileOutputStream os = null;
try {
is = new FileInputStream(new File(Environment.getExternalStorageDirectory(), name));
os = new FileOutputStream(filePath);
int len = 0;
byte[] buffer = new byte[1024];
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
File f = new File(filePath);
if (f.exists()) {
Toast.makeText(this, "dex overwrite", Toast.LENGTH_SHORT).show();
}
//TODO,然后再进行插件的加载处理
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
os.close();
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
然后加载的逻辑用一个类封装一下:
![](https://i-blog.csdnimg.cn/blog_migrate/4818657bb08abfae4eeaa75ff4e9ba78.png)
里面的实现细节就不多说了,直接贴出来,比较好理解:
package com.android.pluginarchstudy;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.Resources;
import java.io.File;
import java.lang.reflect.Method;
import dalvik.system.DexClassLoader;
public class PluginManager {
private static final PluginManager ourInstance = new PluginManager();
private DexClassLoader dexClassLoader;
private Resources resources;
private PackageInfo packageInfo;
public static PluginManager getInstance() {
return ourInstance;
}
public PluginManager() {
}
public void loadPlugin(Context context) {
File filesDir = context.getDir("plugin", Context.MODE_PRIVATE);
String name = "plugin.apk";
String path = new File(filesDir, name).getAbsolutePath();
PackageManager packageManager = context.getPackageManager();
packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
//activity
File dex = context.getDir("dex", Context.MODE_PRIVATE);
dexClassLoader = new DexClassLoader(path, dex.getAbsolutePath(), null, context.getClassLoader());
//resource构建,这个跟当时换肤加载是一样的,不多说
try {
AssetManager manager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
addAssetPath.invoke(manager, path);
resources = new Resources(manager,
context.getResources().getDisplayMetrics(),
context.getResources().getConfiguration());
} catch (Exception e) {
e.printStackTrace();
}
}
public Resources getResources() {
return resources;
}
public DexClassLoader getDexClassLoader() {
return dexClassLoader;
}
public PackageInfo getPackageInfo() {
return packageInfo;
}
}
其中在构建DexClassLoader时涉及到了应用目录,下面简单对于APK安装之后的常见的目录说明一下:
![](https://i-blog.csdnimg.cn/blog_migrate/1fbc3304e2f09f519fac4262f3d33351.png)
好,加载完之后,接下来对于代理Activity中就需要重写一下ClassLoader和Resource对象为我们在PluginManager所获取的,这样才能够去加载插件的资源,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/16d3093538afbf585c8e3dc0899b48fd.png)
至此,加载这块的逻辑就写到这,接下来则来处理我们真正要跳转到插件界面的逻辑了。
正式跳转到插件:
这个就直接像正常调用Activity跳转一样,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/799d243445cc474a377b3f55c9033e37.png)
就这么简单,接下来则准备要应用看一下效果了,不过先来添加sdcard的权限:
![](https://i-blog.csdnimg.cn/blog_migrate/e5747a24e15ddbe5e060e1ce96c1cf80.png)
然后咱们编译一个插件出来:
![](https://i-blog.csdnimg.cn/blog_migrate/19ac43e0da959ceec92a16614cd88acc.png)
然后将其命名为plugin.apk并放到手机sdcard的根目录下:
![](https://i-blog.csdnimg.cn/blog_migrate/af595709ed2d3f14b2e54351bf15f920.png)
接下来运行app,由于运行在我的9.0手机上,所以对于sdcard的权限得要主动申请一下,这里就不写申请的代码了,主动到权限管理中先将其打开,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/18e25fd80ad6aef207d213bae5c9194c.png)
![](https://i-blog.csdnimg.cn/blog_migrate/c19fc6c879d5488504df9e84a24f2f3c.png)
此时就可以运行看一下了:
![](https://i-blog.csdnimg.cn/blog_migrate/637af7396e880eb54d546fbaad818ca8.gif)
嗯,整个插件的核心机制就顺利实现了,不是太难。目前跳转的界面木有任何点击事件,接下来咱们则来处理一下。
处理插件界面的点击事件:
首先给我们的插件主界面增加一个按钮:
![](https://i-blog.csdnimg.cn/blog_migrate/ae8b244fe00238cd7abf437ffa48beb1.png)
接下来给按钮增加一个点击事件,通常的作法可能是在布局中定义:
![](https://i-blog.csdnimg.cn/blog_migrate/6d79602a226b815dc54368849f44886d.png)
但是此时是没有上下文的,所以不能用这种方式了,只能用传统findViewById然后再添加事件的方式,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/3d466836405a8974a1ec8541848ecf49.png)
上面标红的写法是有问题的,一切原因还是由于它自己没有Context造成,所以对于findViewById得重写一下,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/6043372a7586651c63ed8660761677bd.png)
然后Toast的Context则也得用那个代理的,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/45a8cbd46f09e51ea20c82ac61edccd4.png)
![](https://i-blog.csdnimg.cn/blog_migrate/db99417c76e8426b359ec04a99634932.png)
好,咱们在插件中新建一个新的Activity待跳转,如下:
![](https://i-blog.csdnimg.cn/blog_migrate/234d0e8f62d87163b008240add51b686.png)
![](https://i-blog.csdnimg.cn/blog_migrate/acaecc12ce2c256c3c4d18474c0f4c09.png)
注意!!!此时并不需要往清单文件中进行注册,咱们不注册试验一下,看是否能正常的跳转,先写一下跳转的代码:
![](https://i-blog.csdnimg.cn/blog_migrate/51eb73fedb9282f20b2bea46995bf2ce.png)
其中startActivity我们又得重写一下,原因还是那个Context,所以。。
![](https://i-blog.csdnimg.cn/blog_migrate/60122cbabf1633e7a4e0a4b54337abcc.png)
接下来则需要回到咱们的宿主的代理对象中来处理这个startActivity了,需要重写一下startActivity,处理比较简单:
![](https://i-blog.csdnimg.cn/blog_migrate/15831e3beec7685dd7a142daaf4110e1.png)
为啥跳转这么麻烦呀,这里一定得要转过弯来,就是所有的跳转都得要委托给我们宿主的代理类来进行,插件没有安装到手机上没有上下文是不可能直接跳转的,这个说了N多遍了,也是插件化技术稍稍难以理解的地方,理解这一点了基本上插件化的核心就理解了,好,接下来重新编译插件替换到手机上,并且重新运行一下宿主app,运行看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/052992c5af61911ce0634d396d7b6007.gif)
完美实现,不过这里需要强调一下这个东东:
![](https://i-blog.csdnimg.cn/blog_migrate/2ad21b6aefdce41847d7b64ab311e0ea.png)
比如说上面运行之后返回界面则是按预期一个个返回,如果改了这个模式的话其显示就会异常了。
好,以上是关于Activity跳转的插桩式插件化实现,不过还有Service、BroadCastReceiver的跳转,这个就放到下次再学。