个人认为:
2017年手淘Atlas插件化项目的开源标志着插件化的落幕,
2018年Android 9.0上私有API的限制几乎称得上是要退出历史主流。
如今的插件化技术朝两个方向发展:
其一,插件化的工程特性:模块化/解耦被抽离,逐渐演进为稳定、务实的的组件化方案;
其二,插件化的黑科技特性被进一步发掘,inline hook/method hook大行其道,走向双开,虚拟环境等等。
虽然插件化终将落幕,但是它背后的技术原理包罗万象,值得每一个希望深入Android的小伙伴们学习。
《Android插件化技术——原理篇》《Android插件化资源的使用及动态加载 附demo》
什么是组件化和插件化?
组件化开发:将一个app分成多个模块,每个模块都是一个组件(Module),开发的过程中我们可以让这些组件相互依赖或者单独调试部分组件等,但是最终发布的时候是将这些组件合并统一成一个apk。
插件化开发:和组件化开发略有不用,插件化开发时将整个app拆分成很多模块,这些模块包括一个宿主和多个插件,每个模块都是一个apk(组件化的每个模块是个lib),最终打包的时候将宿主apk和插件apk分开或者联合打包。
为什么要插件化开发和动态加载呢?我认为原因有三点:
- 可以实现解耦
- 可以解除单个dex方法数量不能超过65535的限制
- 可以给apk瘦身,比如说360安全卫士,整个安装包才13.7M,对于一个用户量上亿的app这个大小已经很小了,它里面很多功能都是以插件的形式存在的
插件化开源框架有哪些
目前插件化开发已不是什么高深的技术了,各大互联网公司基本都有自己插件化开发框架,而且大部分都已经开源出来,听起来都是很高大上的东西,但是他们的原理有没有真正了解过呢?这两天通过查找的一些资料,想跟大家分享一下。
主要解决三个问题
- 如何加载插件apk的资源文件?
- 如何调用插件apk的方法?
- 如何加载插件中的activity,并且有生命周期?
第一个问题:如何加载插件apk的资源文件?
对于第一个问题我们假设有这么一个需求:我们有个app想做类似qq换肤的功能,但是这个皮肤文件很大,如果跟宿主app一起打包的话可能会导致apk包很大,希望通过插件的方式,在用户需要换肤的时候去下载各种皮肤插件,来完成换肤的需求。
首先要了解一个类:
- DexClassLoader
DexClassLoader是一个类加载器,可以用来从.jar和.apk文件中加载class。可以用来加载执行没用和应用程序一起安装的那部分代码。
构造函数:
DexClassLoader(
String dexPath, //被解压的apk路径,不能为空。
String optimizedDirectory, //解压后的.dex文件的存储路径,不能为空。这个路径强烈建议使用应用程序的私有路径,不要放到sdcard上,否则代码容易被注入攻击。
String libraryPath, //os库的存放路径,可以为空,若有os库,必须填写。
ClassLoader parent//父亲加载器,一般为ClassLoader.getSystemClassLoader()。
)
- AssetManager
中的内部的方法addAssetPath,
将插件apk路径传入,从而添加进assetManager中,
然后通过new Resource把assetManager传入构造方法中,
可以得到未安装apk对应的Resource对象。
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
int res = addAssetPathNative(path);
return res;
}
接下来解决这个问题的思路是,先把插件apk下载到本地sd卡上,然后获取这个apk的信息,最后用DexClassLoader动态加载
第一步,下载插件apk:
/**
* 下载插件apk
* */
private void downLoadPlugApk() {
DownloadUtils.get().downloadFile(APK_URL, new File(PLUG_APP_PATH, APK_NAME), new DownLoadListener() {
@Override
public void onFail(File file) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(UnInstallActivity.this,"下载失败",Toast.LENGTH_LONG).show();
}
});
}
@Override
public void onSucess(File file) {
runOnUiThread(new Runnable() {
@Override
public void run() {
btn_download_plug_apk.setText("下载插件apk");
Toast.makeText(UnInstallActivity.this,"下载成功",Toast.LENGTH_LONG).show();
}
});
}
@Override
public void onProgress(long bytesRead, long contentLength, boolean done) {
LogUtils.d("contentLength:"+contentLength+" | bytesRead:"+bytesRead+" | done:"+done);
final float persent = (float) bytesRead / contentLength*100;
runOnUiThread(new Runnable() {
@Override
public void run() {
btn_download_plug_apk.setText((int)persent+"%");
}
});
}
});
}
这个插件apk里面有一张图片test.png放在mipmap-xxhdpi目录下,我是先把plugapp.apk文件放在一个服务器上,通过代码下载到sd卡的根目录下面
第二步,获取plugapk的信息 通过PackageManager的getPackageArchiveInfo方法获得
/**
* 获取未安装apk的信息
* @param context
* @param apkPath apk文件的path
* @return
*/
private String[] getUninstallApkInfo(Context context, String apkPath) {
String[] info = new String[2];
PackageManager pm = context.getPackageManager();
PackageInfo pkgInfo = pm.getPackageArchiveInfo(apkPath, PackageManager.GET_ACTIVITIES);
if (pkgInfo != null) {
ApplicationInfo appInfo = pkgInfo.applicationInfo;
String versionName = pkgInfo.versionName;//版本号
Drawable icon = pm.getApplicationIcon(appInfo);//图标
String appName = pm.getApplicationLabel(appInfo).toString();//app名称
String pkgName = appInfo.packageName;//包名
info[0] = appName;
info[1] = pkgName;
}
return info;
}
第三步,获取Resource对象
/**
* @param apkPath
* @return 得到对应插件的Resource对象
* 通过得到AssetManager中的内部的方法addAssetPath,
* 将未安装的apk路径传入从而添加进assetManager中,
* 然后通过new Resource把assetManager传入构造方法中,进而得到未安装apk对应的Resource对象。
*/
private Resources getPluginResources(String apkPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//反射调用方法addAssetPath(String path)
//第二个参数是apk的路径:Environment.getExternalStorageDirectory().getPath()+File.separator+"plugin"+File.separator+"apkplugin.apk"
//将未安装的Apk文件的添加进AssetManager中,第二个参数为apk文件的路径带apk名
addAssetPath.invoke(assetManager, apkPath);
Resources superRes = this.getResources();
Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return mResources;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
第四步,通过DexClassLoader获得resid
/**
* 加载apk获得内部资源
* @param apkPath apk路径
* @throws Exception
*/
private int getRecourceIdFromPlugApk(String apkPath,String apkPackageName) throws Exception {
File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
Log.v("zxy", optimizedDirectoryFile.getPath().toString());// /data/data/com.example.dynamicloadapk/app_dex
//参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(apkPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
Field field = clazz.getDeclaredField("test");//得到名为test的这张图片字段
int resId = field.getInt(R.id.class);//得到图片id
return resId;
}
第五步,实现换肤效果
/**
* 加载资源
* */
private void loadPlugResource() {
String[] apkInfo = getUninstallApkInfo(this, PLUG_APP_PATH + "/" + APK_NAME);
String appName = apkInfo[0];
String pkgName = apkInfo[1];
Resources resource = getPluginResources(APK_PATH);
try {
int resid = getRecourceIdFromPlugApk(APK_PATH, pkgName);
activity_un_install.setBackgroundDrawable(resource.getDrawable(resid));
} catch (Exception e) {
e.printStackTrace();
}
}
第二个问题:如何调用插件apk的方法?
根据第一个问题就可以得到答案, 通过DexClassLoader加载类,然后通过反射机制执行类里面的方法
/**
* @param apkPath apk路径
* @throws Exception
*/
private String runPlugApkMethod(String apkPath,String apkPackageName) throws Exception {
File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
Log.v("zxy", optimizedDirectoryFile.getPath().toString());// /data/data/com.example.dynamicloadapk/app_dex
//参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(apkPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
// //通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
// Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");
// Field field = clazz.getDeclaredField("test");//得到名为test的这张图片字段
// int resId = field.getInt(R.id.class);//得到图片id
// 使用DexClassLoader加载类
Class libProvierClazz = dexClassLoader.loadClass(apkPackageName+".TestDynamic");
//通过反射运行sayHello方法
Object obj=libProvierClazz.newInstance();
Method method=libProvierClazz.getMethod("sayHello");
return (String)method.invoke(obj);
}
第三个问题:如何加载插件中的activity,并且有生命周期?
这个问题是最关键的问题,我们知道通过DexClassLoader可以加载插件app里的任何类包括Activity,也可以执行其中的方法,但是Android中的四大组件都有一个特点就是他们有自己的启动流程和生命周期,我们使用DexClassLoader加载进来的Activity是不会涉及到任何启动流程和生命周期的概念,说白了,他就是一个普普通通的类。所以启动肯定会出错。
这里就要看一下activity的启动流程了,步骤太多就不写了,可以网上搜一下资料或者看《Android源码情景分析》这本书介绍的很详细,一个简单的启动要涉及到30多个步骤。
加载Activity的时候,有一个很重要的类:LoadedApk.Java
他内部有一个mClassLoader变量是负责加载一个Apk程序d的,所以可以从这里入手,我们首先要获取这个对象,这个对象在ActivityThread中有实例,
ActivityThread类中有一个自己的static对象,然后还有一个ArrayMap存放Apk包名和LoadedApk映射关系的数据结构,那么我们分析清楚了,下面就来通过反射来获取mClassLoader对象。
private void loadApkClassLoader(DexClassLoader dLoader){
try{
String filesDir = this.getCacheDir().getAbsolutePath();
String libPath = filesDir+File.separator+APK_NAME;
// 配置动态加载环境
Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[] {}, new Object[] {});//获取主线程对象
//当前apk的包名
String packageName = this.getPackageName();
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect( "android.app.ActivityThread", currentActivityThread, "mPackages");
WeakReference wr = (WeakReference) mPackages.get(packageName);
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), dLoader);
}catch(Exception e){
e.printStackTrace();
}
}
所以我们是通过将LoadedApk中的mClassLoader替换成我们的DexClassLoader来实现加载plugappActivity的
/**
* 运行插件apk
* */
private void runPlug() {
String filesDir = this.getCacheDir().getAbsolutePath();
String libPath = filesDir+File.separator+APK_NAME;
loadResources(libPath);
DexClassLoader loader = new DexClassLoader(libPath, filesDir, filesDir, ClassLoader.getSystemClassLoader());
// DexClassLoader loader = new DexClassLoader(libPath, filesDir,null, getClassLoader());
Class<?> clazz = null;
try {
clazz = loader.loadClass("com.demo.plug.MainActivity");
Class rClazz = loader.loadClass("com.demo.plug.R$layout");
Field field = rClazz.getField("activity_main");
Integer ojb = (Integer)field.get(null);
View view = LayoutInflater.from(this).inflate(ojb, null);
Method method = clazz.getMethod("setLayoutView", View.class);
method.invoke(null, view);
Log.i("demo", "field:"+ojb);
loadApkClassLoader(loader);
Intent intent = new Intent(RunPlugActivity.this, clazz);
startActivity(intent);
} catch (Throwable e) {
Log.i("inject","error:"+Log.getStackTraceString(e));
e.printStackTrace();
}
}
说白了就是偷梁换柱,欺骗系统来达到启动插件的目的。360的插件框架就是使用这种技术称之为hook技术,然后通过预先占坑的方式来预注册Activity。携程的这套插件化开发框架则是使用代理的模式来实现启动插件Activity的,所有activity都需要继承自proxy avtivity(proxy avtivity负责管理所有activity的生命周期),它的优点是不需要预先占坑了(不需要预先在宿主的清单文件里注册actvity)缺点是不支持Service和BroadCastReceiver,因为activity的生命周期启动还是比较复杂的,所以个人觉得携程的这套插件化框架实现起来是比较有难度的。
最后,除了上面这种方式还有两种
- 通过合并PathClassLoader和DexClassLoader中的dexElements数组,
- 动态代理加载Activity
这里只是做了一个最简单的探讨,如果想要做一套插件化开发框架可能要对android的framework层有一个更深入的理解,但是大概原理和思路我觉得是差不多的。
###一. 背景 Android插件化作为每个合格的Android程序员都必须会的技术,被各大厂广泛使用。随着各大厂对移动互联网的垄断,我们渐渐发现app集成的功能越来越多。比如如下几个app(携程、淘宝、支付宝):
可以看到每一个app都被集成了无数的功能入口,就拿淘宝来说,“天猫”、“外卖”、“飞猪”、“拍卖”,这任何一个入口都其实是一个app,只不过被集成到“淘宝”这个入口里了。如果没有插件化技术,很难想象淘宝app的size会有多大。很可能有几个GB!!
再来看看支付宝,可以发现支付宝中提供了很多第三方app的入口,而点击这些入口跳转的也都是native页面。应用市场上的支付宝app一共只有二三十MB,而如果这些app都集成到支付宝中,那支付宝的size就不是二三十MB了,那就是二三十GB了!!
本篇blog的主题是介绍Android插件化技术,并且会提供一个仿支付宝插件化技术的demo,告诉你支付宝是如何把一个第三方app作为插件集成到自己的app里的。
###二. 插件化好处
- 宿主和插件分开编译
编译时只需要编译宿主app,插件app是在编译好后下发到宿主app里的。
- 并发开发
宿主app什么时候发布版本跟插件app什么时候开发完没有关系,宿主app只要开发完并且为插件app提供一个入口就可以了。
- 动态更新插件
插件app在开发完后下发到宿主app里,点击相应的入口就可以跳转到最新版的插件app了。
- 按需下载模块
解决方法数或变量数爆棚(65536)
###三. 随便一个app都能集成到支付宝吗?
答案是:不能!
我们来思考,支付宝要跳转到一个插件的Activity,而插件是没有被安装的,它没有上下文,也就没有生命周期,那么插件Activity的生命周期就要由宿主app来控制。为此,我们需要建立一套标准。
###四. 插件化程序结构
我们先来看下插件化程序结构,了解下其大致框架,对程序有宏观感受。下面我们直接开始撸代码。
###五. 动态加载apk
#####1. 插件app的activity没有在宿主app中注册,该怎么办?
插桩,一个空的Activity,专门用来加载插件app中的activity,这个Activity叫ProxyActivity,后面我会具体去讲这个空Activity该如何实现。我们只需要在宿主app里注册这个Activity就可以了。
#####2. 加载插件app中的Activity
实际场景中插件apk肯定是由服务端下发后,保存到SD卡的某个文件夹下。这里将编译好的插件apk放到手机外置SD卡的根目录中,我们来演示宿主app如何去加载插件app中的Activity。
###六. 资源加载
接下来我们来看下FluginManager的loadPath方法如何实现。如果要实现这个功能,首先想到的肯定是用反射。
可是你别忘记了,插件app根本就没有安装,这里是无法找到这个Class的。我们需要DexClassLoader来完成Activity类的加载。
PluginManager的getDexClassLoader的实现如下:
讲完了如何加载Activity,我们来讲下如何加载Activity中用到的资源文件。我们在日常开发中需要资源文件时,我们是通过getResources()来获取。例如加载一个图片:
getResources().getDrawable()
可现在我们需要获取的是另外一个app的资源,所以这里就需要自己实现一个getResources()方法。
PluginManager的getResources方法实现如下:
至此,一个插件app的activity加载功能就实现完成了,下面我们来看如何跳转。
###七. 跳转到插件app中的Activity
由于我们需要读取SD卡中的插件apk,这里别忘记加上SD卡的读写权限
这就可以实现跳转了。下面我们来看下效果,奇怪,为什么我们跳转到插件app的activity是空白的?我们来看下插件app的activity应该长什么样子。
当然这里我只在插件app的主activity里放了一张图片,并没有写复杂的布局。可是为什么我们跳过来的是空白页呢?我们再看下ProxyActivity的代码:
package com.ctrip.pluginapplication
import android.content.res.Resources
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
/**
* 壳!专门用来加载插件Activity
* @author Zhenhua on 2018/3/3.
* @email zhshan@ctrip.com ^.^
*/
class ProxyActivity : AppCompatActivity() {
/**
* 要跳转的activity的name
*/
private var className = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/**
* step1:得到插件app的activity的className
*/
className = intent.getStringExtra("className")
/**
* step2:通过反射拿到class,
* 但不能用以下方式,因为插件app没有被安装!
*/
// classLoader.loadClass(className)
// Class.forName(className)
}
override fun getClassLoader(): ClassLoader {
//不用系统的ClassLoader,用dexClassLoader加载
return PluginManager.getInstance().getDexClassLoader() as? ClassLoader
?: super.getClassLoader()
}
override fun getResources(): Resources {
//不用系统的resources,自己实现一个resources
return PluginManager.getInstance().getResources() ?: super.getResources()
}
}
我们发现,我们这里还没有在ProxyActivity里写逻辑啊,我们只是得到了插件app的主activity的name,这时activity还没有生命周期。?我们接着来实现。我们需要让ProxyActivity控制插件app的activity的生命周期,所以我们需要得到插件app的activity的实例,然后去控制其生命周期:
package com.ctrip.pluginapplication
import android.content.res.Resources
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import com.ctrip.standard.AppInterface
/**
* 壳!专门用来加载插件Activity
*/
class ProxyActivity : AppCompatActivity() {
/**
* 要跳转的activity的name
*/
private var className = ""
private var appInterface: AppInterface? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/**
* step1:得到插件app的activity的className
*/
className = intent.getStringExtra("className")
/**
* step2:通过反射拿到class,
* 但不能用以下方式
* classLoader.loadClass(className)
* Class.forName(className)
* 因为插件app没有被安装!
* 这里我们调用我们重写过多classLoader
*/
var activityClass = classLoader.loadClass(className)
var constructor = activityClass.getConstructor()
var instance = constructor.newInstance()
appInterface = instance as?AppInterface
appInterface?.attach(this)
var bundle = Bundle()
appInterface?.onCreate(bundle)
}
override fun onStart() {
super.onStart()
appInterface?.onStart()
}
override fun onResume() {
super.onResume()
appInterface?.onResume()
}
override fun onDestroy() {
super.onDestroy()
appInterface?.onDestroy()
}
override fun getClassLoader(): ClassLoader {
//不用系统的ClassLoader,用dexClassLoader加载
return PluginManager.getInstance().getDexClassLoader() as? ClassLoader
?: super.getClassLoader()
}
override fun getResources(): Resources {
//不用系统的resources,自己实现一个resources
return PluginManager.getInstance().getResources() ?: super.getResources()
}
}
这时我们就可以成功跳转了。ok,插件化实现完成。我们来看下效果。
~~华丽丽的分割线:在插件app中实现更多功能~
之前我们的插件app的activity其实就只是加载了一个imageView,我们现在来实现这样一个功能:“点击ImageView,弹出一个toast”。
代码如下:
我们来看下效果。。
然而,点击竟然crash了。我们来贴下错误日志:
原来我们在插件Activity中不能用自己的上下文,我们应该用that!!