插件开发介绍
动态加载背景
当工程越来越大的时候,痛点来了:
1、 文件太多,编译太慢,apk包太大
2、 方法数超过65536,需要分包,启动时进行,启动界面根据分包情况会有不同耗时
3、 线上功能出现bug,不能及时修复,需要重新打包修复
4、 功能模块开发需要依赖整体项目进度,无法单独进行开发调试
有没有相关的技术方案解决上面的这些痛点了?
动态加载的形式
- 应用在运行的时候通过加载一些本地不存在的可执行文件实现一些特定的功能;
- 这些可执行文件是可以替换的;
插件化实现过程
基本实现流
Android项目中,插件化按照可执行文件的不同大致可以分为两种:
- 动态加载so库;
- 动态加载dex/jar/apk文件(现在动态加载普遍说的是这种);
实现的基本步骤为:
- 把可执行文件(.so/dex/jar/apk)拷贝到应用APP内部存储;
- 加载可执行文件;
调用具体的方法执行业务逻辑;
DexClassLoader dexClassLoader = new DexClassLoader(optimizedDexOutputPath.getAbsolutePath(), dexOutputDir.getAbsolutePath(), null, getClassLoader()); Class libProviderClazz = null; try { libProviderClazz = dexClassLoader.loadClass("me.kaede.dexclassloader.MyLoader"); // 遍历类里所有方法 Method[] methods = libProviderClazz.getDeclaredMethods(); for (int i = 0; i < methods.length; i++) { Log.e(TAG, methods[i].toString()); } Method start = libProviderClazz.getDeclaredMethod("func");// 获取方法 start.setAccessible(true);// 把方法设为public,让外部可以调用 String string = (String) start.invoke(libProviderClazz.newInstance());// 调用方法并获取返回值 Toast.makeText(this, string, Toast.LENGTH_LONG).show(); } catch (Exception exception) { // Handle exception gracefully here. exception.printStackTrace(); }复制代码
有几个疑问了:
1、 既然是apk,那么资源文件怎么加载进来
2、 四大组件怎么启动,并没有注册在宿主apk里
………
具体实现
上面两个问题也是插件化具体实现的关键问题。对于上述两个问题,我们最直观的解决办法:
1、 在宿主程序中添加重新拷贝一份插件apk中的资源文件,或者采用纯代码布局。
2、 将插件中的mnifest文件也拷贝到宿主应用中
3、 替换activity为fragment
…....
但是适用范围:界面更新比较少,基本是单一界面,不会有太大改动的。
上述的解决办法能够解决插件化的基本痛点,但是我们仍然痛。比如插件替换成fragment后,灵活性降低了,需要不断的更新插件apk中的资源文件与manifest到宿主程序中。。。
先看Activity:采用代理的方式解决manifest中插件组件未注册不能使用的问题。
具体的实现流程如上图所示:
1、 在宿主中注册一个代理ProxyActivity
2、 插件中的PluginActivity实现一个类似于Acitivty生命周期的协议接口
3、 通过中转类PluginManager来执行跳转,这里跳转到代理类ProxyActivity
宿主程序要启动插件组件,通过调用PluginManager的startActivity方法启动,在这个方法里将插件类替换为代理类,同时代理类会持有插件类的引用。
插件与代理类的绑定发生在PluginManager启动的时候发生,通过反射的机制在代理类执行生命周期的时候构造出插件类的引用,为了让插件类也具有自己的生命周期,代理类与插件类都实现了一个生命周期的接口,保证插件在不注册的情况下具有基本的生命周期
接下来看一下资源文件的问题:
先贴一段源码 Context
public Resources getResources() {
if (mResources != null) {
return mResources;
}
if (mOverrideConfiguration == null) {
mResources = super.getResources();
return mResources;
} else {
Context resc = createConfigurationContext(mOverrideConfiguration);
mResources = resc.getResources();
return mResources;
}
}复制代码
跟踪到ResourceManager关键代码
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
Resources r;
AssetManager assets = new AssetManager();
if (libDirs != null) {
for (String libDir : libDirs) {
if (libDir.endsWith(".apk")) {
if (assets.addAssetPath(libDir) == 0) {
Log.w(TAG, "Asset path '" + libDir +
"' does not exist or contains no resources.");
}
}
}
}
DisplayMetrics dm = getDisplayMetricsLocked(displayId);
Configuration config ……;
r = new Resources(assets, dm, config, compatInfo);
return r;
}复制代码
通过这些代码从一个APK文件加载res资源并创建Resources实例,经过这些逻辑后就可以使用R文件访问资源了。具体过程是,获取一个AssetManager实例,使用其“addAssetPath”方法加载APK(里的资源),再使用DisplayMetrics、Configuration、CompatibilityInfo实例一起创建我们想要的Resources实例。
通过上面的源码,可以通过反射的机制来实现同样的效果,关键代码贴图:
private AssetManager createAssetManager(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
return assetManager;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}private Resources createResources(AssetManager assetManager) {
Resources superRes = mContext.getResources();
Resources resources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return resources;
}复制代码
这样就创建了插件独立的resource了,资源文件也能够独立的加载了。
上面两个最重要的问题都有了比较好的解决办法,但是你以为到这里就结束了吗。。。。然而我只能告诉你
这里面还有很多问题要处理:
实际运行的Activity实例其实都是ProxyActivity,并不是真正想要启动的Activity;
往往不是所有的apk都可作为插件被加载,插件项目需要依赖特定的框架,还有需要遵循一定的"开发规范";
因为采用了反射,程序的不稳定性增加了。
。。。。
插件实现的一些不思议实现
有一些奇妙的东西
对于上述采用代理的实现方式已经基本能够满足部门内部的插件化的大部分需求,可是对于上面的限制,还有更好的解决办法。
解决对策就是,在需要启动插件的某一个Activity(比如PlugActivity)的时候,动态创建一个TargetActivity,新创建的TargetActivity会继承PlugActivity的所有共有行为,而这个TargetActivity的包名与类名刚好与我们事先注册的TargetActivity一致,我们就能以标准的方式启动这个Activity。运行时动态创建并编译一个Activity类,这种想法不是天方夜谭,动态创建类的工具有dexmaker和asmdex,二者均能实现动态字节码操作,最大的区别是前者是创建dex文件,而后者是创建class文件。
这是他的一个加载流程,在启动插件acitivty 的时候,先替换成宿主程序中已经注册过的targetactivity,这个targetactivity是不存在的一开始,这样我们会首先进入到frameworkclassloader,发现targetactivity后,调用插件的classloder进行targetacitivity的创建,并对新生成的dex创建新的dexclassloder,用该classloder加载targetactivity,这样就完成了一个真正的替换。对于一般的类跳转,我们直接通过pluginclassloader加载就好了。
这里还有一个问题,就是插件内部的activity的跳转,也没有注册,这种情况下,我们是在生成的targetactivity里面在调用startactivityforresult的时候,这里也做一层替换,就是调用pluginmanager的startactivity就好了。
RePlugin
这是最近360开源的一款插件化开发框架。大家对360其实很早之前就知道他的另外一款插件了----- DroidPlugin,这个在业界也是挺有名的一款开源插件化开发框架。只是这个工具hook的点太多,导致不稳定性太高
最近开源的RePlugin,听说只hook了一个点,到底有多牛逼,也顺应潮流看了一下这个框架。首先看一下他的使用方式:
1、 添加 RePlugin Host Gradle 依赖,在项目根目录的 build.gradle(注意:不是 app/build.gradle) 中添加 replugin-host-gradle 依赖:
buildscript {
dependencies {
classpath 'com.qihoo360.replugin:replugin-host-gradle:2.1.5'
...
}
}复制代码
2、 添加 RePlugin Host Library 依赖。
android {
// ATTENTION!!! Must CONFIG this to accord with Gradle's standard, and avoid some error
defaultConfig {
applicationId "com.qihoo360.replugin.sample.host"
}
}
// ATTENTION!!! Must be PLACED AFTER "android{}" to read the applicationId
apply plugin: 'replugin-host-gradle'
// If use AppCompat, open the useAppCompat
repluginHostConfig {
useAppCompat = true
}
dependencies {
compile 'com.qihoo360.replugin:replugin-host-lib:2.1.5'
}复制代码
3、 配置application。让工程的 Application 直接继承自 RePluginApplication。
这个是接入方式,接下来,看一下调用插件的方式:
1、 宿主调用插件的组件:
RePlugin.startActivity(MainActivity.this, RePlugin.createIntent("demo1",
"com.qihoo360.replugin.sample.demo1.MainActivity"));复制代码
2、 插件调用宿主主键:需要使用包名来调用
// 方法1(最“单品”)
Intent intent = new Intent();
intent.setComponent(new ComponentName("demo2",
"com.qihoo360.replugin.sample.demo2.databinding.DataBindingActivity"));
context.startActivity(intent);
// 方法2(快速创建Intent)
Intent intent = RePlugin.createIntent("demo2",
"com.qihoo360.replugin.sample.demo2.databinding.DataBindingActivity");
context.startActivity(intent);
// 方法3(一行搞定)
RePlugin.startActivity(v.getContext(), new Intent(), "demo2",
"com.qihoo360.replugin.sample.demo2.databinding.DataBindingActivity");复制代码
首先宿主的application要继承RePluginApplication,启动插件中的某个activity; RePlugin.startActivity(MainActivity.this, RePlugin.createIntent("demo1", "com.qihoo360.replugin.sample.demo1.MainActivity"));
RePlugin首先会通过gradle插件在宿主程序中生成一个新的manifest文件,上面是新生成文件里面的部分截图,这里展示的就是插件需要的坑位。是的你猜到了,这个文件首先会把所有可能情况的组件的坑位都生成到这里,然后让插件找到合适的坑位去匹配。这里的坑位命名规则符合以下规则:
/**
* 目前的策略是,针对每一种 launchMode 分配两种坑位(透明主题(TS)和不透明主题(NTS))
* <p>
* 例:透明主题
* <N1NRTS0, ActivityState>
* NR + TS - > <N1NRTS1, ActivityState>
* <N1NRTS2, ActivityState>
* <p>
* 例:不透明主题
* <N1NRNTS0, ActivityState>
* NR + NTS - > <N1NRNTS1, ActivityState>
* <N1NRNTS2, ActivityState>
* <p>
* 其中:N1 表示当前为 UI 进程,NR 表示 launchMode 为 Standard,NTS 表示坑的 theme 为 Not Translucent。
*/复制代码
每个activity坑位的命名方式都是按照进程名+启动模式+透明方式的方式命名。这里组合起来有230个左右的坑位,足够一个apk中各种activity的填坑了。
对于service与provider的坑位的设置也就稍微简单点了。
再看一下在插件中声明原始activity,有一种快捷的进程匹配方式,宿主就能很快的匹配上坑位:
这样声明的规则如下:
from:原来声明的进程名是什么。例如有个Activity,其进程名声明为“com.qihoo360.launcher:wff”
to:要映射到的进程名,必须以“$”开头,表示“特殊进程”
$ui:映射到UI进程
$p0:映射到进程坑位0进程
$p1:映射到进程坑位1进程
以此类推。复制代码
接下来我们看一看他的部分实现原理。其实Replugin的实现方式我们可以分解成三个部分:
1、 创建坑位。足够多的坑位,能够覆盖足够多的组件跟场景。(主要是通过自定义gradle插件来实现)
2、 加载插件时,匹配坑位进行替换
3、 Classloader进行点hook,将坑位重新替换成目标组件。
看一下hook实现。先看一下app启动进程与AMS涉及到的相关
App进程与ASM进程进行通信的流程就是进程间通信的原理,然后通过各自的binder代理进行通信。AMS进程完成Activity生命周
期的管理以及任务栈的管理,由主线程的handler来处理收到的消息,也就是我们这里的ActivityThread里面的handler来处理。
如果我们没有坑匹配与替换的过程,我们可以现在ASM这一层hook一次,把targetActivity替换成我们已经注册好的PluginActivity,接下来我们的app进程接管了Activity的管理,这里我们再替换为targetactivity就可以了。既然这里已经填坑完毕,我们只需要在app进程将PluginActivity替换回去就好了。
相关文章连接:
dynamic-load-apk: github.com/singwhatiwa…
RePlugin: github.com/Qihoo360/Re… ,