开发中经常能听到动态加载,插件化,热部署等词,动态加载到底是何方神物,它能实现什么功能,实现原理又如何?动态加载和插件化、热部署又有着什么样的联系呢?下面我们一起来学习吧。
1. 基本知识
1.1 动态加载
动态加载,是指在应用运行时,动态加载某个模块,达到新增或是改变某一部分功能/行为。
1.1.1 Java的动态加载
通过类加载器ClassLoader(用来加载类的)实现动态加载Jar文件,当一个Class被加载时,这个Class所引用的所有Class也会被加载。ClassLoader是双亲委派,即总是先请求父ClassLoader查找自身,依次类推,不存在则用本类的类加载器加载。
1.1.2 Android中动态加载
由于Android的虚拟机(Dalvik VM)是不能识别Java打出jar的byte code,需要通过dx工具来优化转换成Dalvik byte code才行。所以Android动态加载是指应用在运行的时候,使用类加载器加载相应的apk\dex\jar(必须含有dex文件),再过通反射获取apk\dex\jar内部资源(class、图片、color等),提供给宿主app使用。
Android动态加载的类型:so库, dex/jar/apk文件。
1.2 Android插件化
动态加载是插件化实现的基础,Android中可以将一个应用分成多个不同的部分,每一部分可看成一个插件,再利用动态加载技术实现插件化动态加载。
Android类加载器相关知识可以参考我前面写的一篇博文:android类加载器ClassLoader
简单的Android动态加载实践可参考这篇文章,讲解了Android动态加载dex的实现:Android动态加载jar/dex
1.3 热部署
是一个独立的apk, 也是程序运行动态加载,但加载完了后与宿主apk没有很大联系。
1.4 插件化和热部署的区别
独立运行的插件APK叫热部署:如在用户使用的时候才加载插件,插件一旦运行后,与主项目没有任何逻辑,只有在主项目启动插件时才触发一次调用插件的行为。
需要依赖主项目的环境运行的播件apk叫插件化:一启动项目就加载插件,主项目提供一个启动入口及从服务器下载最新插件更新逻辑。或是插件需要使用主项目中的功能时,如插件apk和主项目中都有ImageLoader,如果两个项目都引入,无疑造成代码冗余,所以我们可以把ImageLoader相关代码抽离成一个AndroidLibrary项目,主项目以Compile模式引用这个Library,而插件以Provider模式引入这个Library(编译出来的jar),这样丙者之间就能交互了。
1.5 热更新、热修复
上线的应用,如果发现紧急bug,又不想重机关报发版,可以通过服务器向用户发送修复补丁包,使用户不需要重新下载,安装,而修复取决于。
2. 动态加载的作用
利用动态加载技术,实现插件化动态加载,可以解决以下问题:
2.1 方法数不得超过65535
问题:一个dex文件的方法数最大不得超过65535个,且android在加载dex时会对其进行优化成optDex文件,早期的optDex文件要求不能大于5M,后期提升到8M,早期的手机可能方法数没有超过65535限制,但超过了LinearAllocHdr的分配空间,也会导致安装失败问题。
解决办法:可利用插件化拆分多个dex并动态加载,从而解决android端代码方法数不得超过65535;
2.2 减小初始安装包的体积
问题:像淘宝一个apk可能包含多个第三方应用,如聚划算,天猫商城等,如果把所有第三方apk也打包进同一个apk包,会导致初始apk体积过大。用户可能在下载时会有顾虑。
解决办法:利用动态加载来实现模块加载,应用在运行时按需动态加载,减少apk包的体积;
2.3 动态更新
问题:应用上线后如果发些bug了,传统方法是必须重新打个修复包,用户需要再次升级更新,用户体验上很不好;
解决办法:将需要修复的代码打成一个插件,通过服务器下发,应用运行时,动态加载更新;
2.4 动态换肤
实现原理同2.3,这样用户可以实时在线更新皮肤。
2.5 加快应用启动速度
问题:应用比较大时,被拆成多个dex打进初始apk包中,首次启动时速度很慢;
解决办法:利用动态加载技术,使用懒加载机制,在需要时才初始化,提高应用的启动速度;
2.6 降低耦合,提高开发速度
问题:大型项目,代码量一般比较大,所有代码全放在一个module里,编译速度慢,且复用低,耦合度高;
解决办法:分割插件模块,做到项目级别的代码分离,大大降低模块之间的耦合度,同一个项目能分割出不同模块在多个开发团队之间并行开发,若出现bug也容易定位问题;
2.6 过hook系统做一些想改变系统操作
3. 动态加载过程
1) 获取到要加载的插件(.so/apk/dex/jar),可直接copy或是从网络下载)并放在手机本地;
2) 加载可执行文件;
3) 调用具体的方法执行业务逻辑。
4. 动态加载插件apk里的类和资源问题
使用ClassLoader动态加载外部的dex文件非常简单,但它只能用来加载类,而插件中的apk里的资源、XML文件等却无法加载,同时由于无法更改本地的AndroidMainfest清单文件,所以无法启动新的Activity等组件。
4.1 一种简单的解决办法
先把要用到的全部res资源都放到主APK里面,同时把所有需要的Activity先全部写进AndroidManifest.xml文件里,只通过动态加载更新代码,不更新res资源,如果需要改动UI界面,可以通过使用纯Java代码创建布局的方式绕开XML布局。同时也可以使用Fragment代替Activity,这样可以最大限度得避开“无法注册新组件的限制”。
4.2 插件Activity生命周期管理
Android中动态加载技术虽然能把类加载进来,可是Activity\ Service等组件是有生命周期的,合用ClassLoader可以从插件中创建Activity对象,但是无法负责其生命周期。所以我们需要把加载进来的Activity等组件交给系统管理,让AMS赋予组件生命周期。同时组件必须在AndroidMainfest.xml中显示注册,否则会报错,而插件的组件并没有在宿主apk中注册。
网上有三种方法:
4.2.1 反射方法
通过Java的反射去获取Activity的各种生命周期方法,再在代理Activity中去调用插件对应的生命周期方法。这种方法比较复杂,且反射有一定的性能开销。
4.2.2 接口方式
将Activity的生命周期方法提取出来作为一个接口,再通过代理Activity去调用插件Activity的生命周期方法,这样就完成了插件Activity的生命周期管理。
具体要参考: Android apk动态加载机制的研究(二):资源加载和activity生命周期管理
4.2.3 使用傀儡类
使用傀儡类Activity用于代理执行插件APK的Activity的生命周期。
(1) 实现方法:在宿主apk的AndroidMainfest.xml注册一个代理ProxyActivity,ProxyActivity是一个傀儡类,自身没有什么业务逻辑。让ProxyActivity进入AMS进程接受检验,再在适当时候替换成真正要启动的Activity。
(2) 实现思想: Activity的启动过程,通过IPC调用进入系统进程system_server,完成Activity管理及一些校验工作,最后回到App进程中完成真正的Activity对象的创建。
当app进程调用startActivity启动插件类PluginActivity时,在进入AMS进程的入口使用hook代理提前在启动插件apk时使用ProxyActivity去系统层校验,等校验完毕,会调用Handler的dispatchMessage方法,在此时换回真正的PluginActivity,从而达到使用傀儡类欺骗系统成功校验。
(3) 实现步骤:
1) 代理系统启动Activity的方法,将要启动的Activity替换成我们占坑的Activity,欺骗系统去检查的目的。需要拦截startActivity,系统启动Activity最终会调用:ActivityManagerNative.getDefault().startActivity
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target != null ? target.mEmbeddedID : null,
requestCode, 0, null, options);
//而ActivityManagerNative.getDefault()的方法是:
static public IActivityManager getDefault() {
return gDefault.get();
}
//gDefault是一个单例对象,Singleton是系统提供的单例辅助类,由于AMS需要频繁的与我们的应用通信,故采用单例把这个AMS的代理对象保存起来
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};
我们可以通过hook这个单例,改变代理ActivityManagerNative.getDefault()的返回值,就可以实现AMS的代理对象
/**
* Hook AMS
* 主要完成的操作是 "把真正要启动的Activity临时替换为在AndroidManifest.xml中声明的替身Activity"
* 进而骗过AMS
*/
public static void hookActivityManagerNative() Exception {
//获取ActivityManagerNative的类
Class<?> activityManagerNativeClass = Class.forName("android.app.ActivityManagerNative");
//拿到gDefault字段
Field gDefaultField = activityManagerNativeClass.getDeclaredField("gDefault");
gDefaultField.setAccessible(true);
//从gDefault字段中取出这个对象的值
Object gDefault = gDefaultField.get(null);
// gDefault是一个 android.util.Singleton对象; 我们取出这个单例里面的字段
Class<?> singleton = Class.forName("android.util.Singleton");
//这个gDefault是一个Singleton类型的,我们需要从Singleton中再取出这个单例的AMS代理
Field mInstanceField = singleton.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
//ams的代理对象
Object rawIActivityManager = mInstanceField.get(gDefault);
}
现在我们已经拿到了这个AMS的代理对象,现在需要创建一个自己的代理对象去拦截原AMS中的方法
class IActivityManagerHandler implements InvocationHandler {
...
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startActivity".equals(method.getName())) {
Log.e("Main","startActivity方法拦截了");
// 找到参数里面的第一个Intent 对象
Intent raw;
int index = 0;
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof Intent) {
index = i;
break;
}
}
raw = (Intent) args[index];
//创建一个要被掉包的Intent
Intent newIntent = new Intent();
// 替身Activity的包名, 也就是我们自己的"包名"
String stubPackage = MyApplication.getContext().getPackageName();
// 这里我们把启动的Activity临时替换为 ZhanKengActivitiy
ComponentName componentName = new ComponentName(stubPackage, ZhanKengActivitiy.class.getName());
newIntent.setComponent(componentName);
// 把我们原始要启动的TargetActivity先存起来
newIntent.putExtra(AMSHookHelper.EXTRA_TARGET_INTENT, raw);
// 替换掉Intent, 达到欺骗AMS的目的
args[index] = newIntent;
Log.e("Main","startActivity方法 hook 成功");
Log.e("Main","args[index] hook = " + args[index]);
return method.invoke(mBase, args);
}
return method.invoke(mBase, args);
}
}
使用动态代理去代理上面获取的AMS
// 创建一个这个对象的代理对象, 然后替换这个字段, 让我们的代理对象帮忙干活,这里我们使用动态代理
//动态代理依赖接口
Class<?> iActivityManagerInterface = Class.forName("android.app.IActivityManager");
//返回代理对象,IActivityManagerHandler是我们自己的代理对象
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class<?>[] { iActivityManagerInterface }, new IActivityManagerHandler(rawIActivityManager));
//将我们的代理设值给singleton的单例
mInstanceField.set(gDefault, proxy);
等系统检查完后,再次代拦截系统创建Activity的方法,将原来我们替换的Activity再替换回来,达到启动不在AndroidMainfest注册的目的
系统检查合法性后,会回调ActivityThread里的scheduleLaunchActivity方法,在这个方法里发送一个消息到ActivityThread的内部类H中
private class H extends Handler {
...
public void handleMessage(Message msg) {
case LAUNCH_ACTIVITY: {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
r.packageInfo = getPackageInfoNoCheck(
r.activityInfo.applicationInfo, r.compatInfo);
handleLaunchActivity(r, null);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
} break;
...
}
}
/**
* Handle system messages here.
*/
public void dispatchMessage(Message msg) {
if (msg.callback != null) {
//如果传递的Message本身就有callback,那么直接使用Message对象的callback方法;
handleCallback(msg);
} else {
if (mCallback != null) {
//如果Handler类的成员变量mCallback不为空,那么首先执行这个mCallback回调;
if (mCallback.handleMessage(msg)) {
//如果mCallback的回调返回true,那么表示消息已经成功处理;直接结束。
return;
}
}
// 如果mCallback的回调返回false,那么表示消息没有处理完毕,会继续使用Handler类的handleMessage方法处理消息。
handleMessage(msg);
}
}
通过上面分析,我们可以给这个H设置一个Callback让他在走handleMessage之前先走我们的方法,然后我们替换回之前的信息,再让他走H的handleMessage
/**
* 由于之前我们用替身欺骗了AMS; 现在我们要换回我们真正需要启动的Activity
* <p/>
* 不然就真的启动替身了, 狸猫换太子...
* <p/>
* 到最终要启动Activity的时候,会交给ActivityThread 的一个内部类叫做 H 来完成
* H 会完成这个消息转发; 最终调用它的callback
*/
public static void hookActivityThreadHandler() throws Exception {
// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
//他有一个方法返回了自己
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//执行方法得到ActivityThread对象
Object currentActivityThread = currentActivityThreadMethod.invoke(null);
// 由于ActivityThread一个进程只有一个,我们获取这个对象的mH
Field mHField = activityThreadClass.getDeclaredField("mH");
mHField.setAccessible(true);
//得到H这个Handler
Handler mH = (Handler) mHField.get(currentActivityThread);
Field mCallBackField = Handler.class.getDeclaredField("mCallback");
mCallBackField.setAccessible(true);
//设置我们自己的CallBackField
mCallBackField.set(mH, new ActivityThreadHandlerCallback(mH));
}
try {
// 把替身恢复成真身
Field intent = obj.getClass().getDeclaredField("intent");
intent.setAccessible(true);
Intent raw = (Intent) intent.get(obj);
Intent target = raw.getParcelableExtra(AMSHookHelper.EXTRA_TARGET_INTENT);
raw.setComponent(target.getComponent());
Log.e("Main","target = " + target);
} catch (Exception e) {
throw new RuntimeException("hook launch activity failed", e);
}
4.2.4 插件apk中的资源怎么加载
因为res里的每一个资源都会在R.java里生成一个对应的Integer类型的id,APP启动时会先把R.java注册到当前的上下文环境中,我们在代码中以R文件的方式使用资源正是通过使用这些id访问Res资源,然而插件的R.java没注册到上下文环境中,所以插件中的res资源无法通过id使用。
我们平时使用res资源一般通过getResources().getXXX(resid),我们来看看getResources()的源码:
@Override
public Resources getResources() {
if (mResources != null) {//直接使用mResources实例获取res资源
return mResources;
}
if (mOverrideConfiguration == null) {
mResources = super.getResources();//通过父类的getResources()方法
return mResources;
} else {
Context resc = createConfigurationContext(mOverrideConfiguration);
mResources = resc.getResources();
return mResources;
}
}
那mResources实例是怎么初始化的呢,它是通过Context的getResources()方法,而Context是一个抽象类,它的具体实现是在ContextImpl类,具体mResources实例是在ContextImpl类的构造函数中进行初始化的。
resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
overrideConfiguration, compatInfo);
mResources = resources;
//ResourcesManager中的getTopLevelResources方法
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
Resources r;
AssetManager assets = new AssetManager();//获取AssetManager实例
if (libDirs != null) {
for (String libDir : libDirs) {
if (libDir.endsWith(".apk")) {//先查找lib包中的APK文件
//调用AssetManager对象中addAssetPath将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 ……;
//通过AssetManager、DisplayMetrics、onfiguration、CompatibilityInfo实例创建我们需要的Resources实例
r = new Resources(assets, dm, config, compatInfo);
return r;
}
通过上面代码分析,我们只要反射调用addAssetPath这个方法,把插件apk的位置告诉AssetManager类,它就会根据apk内的resources.arsc和已编译资源完成资源加载任务。我们可以自己创建一个Resources实例出来作为插件apk的上下文,具体实现如下:
private Resources getPluginResoures(String apkName) {
try {
//1. 创建一个AssetManager实例
AssetManager assetManager = AssetManager.class.newInstance();
//2. 通过反射获取addAssetPath方法
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
//3. 将插件dex中的资源通过addAssetPath方法添加到AssetManager实例中
addAssetPath.invoke(assetManager, mDexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
//4. 获取宿主的resources实例
Resources superRes = super.getResources();
//5. 生成手件的Resources实例,它即为插件的上下文环境,通过它可以获取插件的res资源
mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),
superRes.getConfiguration());
return mResources;
}
完整加载插件中资源的代码如下:
/**
* 加载apk获得内部资源
* @param apkDir apk目录
* @param apkName apk名字,带.apk
* @throws Exception
*/
private void dynamicLoadApk(String apkDir, String apkName, String apkPackageName) throws Exception {
File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
DexClassLoader dexClassLoader = new DexClassLoader(apkDir+File.separator+apkName, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
//参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
Field field = clazz.getDeclaredField("one");//得到名为one的这张图片字段
int resId = field.getInt(R.id.class);//得到图片id
Resources mResources = getPluginResources(apkName);//得到插件apk中的Resource
if (mResources != null) {
//通过插件apk中的Resource得到resId对应的资源
findViewById(R.id.background).setBackgroundDrawable(mResources.getDrawable(resId));
}
}
具体插件化加载未安装apk下的资源的demo可参考: 插件化开发—动态加载技术加载已安装和未安装的apk
(4)插件apk中加载的资源res的id会不会和宿主apk里的资源id冲突
并不会,因为通过这种方式加载进来的res资源,并不是融入到主项目中,主项目里的res资源是保存在ContextImpl里的mResources实例中,整个项目共有,而新加进来的res资源是保存在新创建的Resources实例中的,即代理类有两套Res资源,并不是把新的res资源和原有的资源合并了,所以不怕R.id冲突。
5. 实际应用中一些注意事项
1) 当有多个插件化版本需要更新,如果管理不同插件与不同版本的差别,可通过上传不同版本的插件apk,并向主apk提供插件apk查询与下载功能;
2) 管理在线的插件apk,并能向不同的版本号的主app提供最合适的插件apk;
3) 如果最新插件apk出现紧急bug,需要提供旧版本回滚功能;
4) 出于安全考虑应该对app项目的请示信息做一些安全性校验;可通过校验插件APK的MD5值,如果插件APK的MD5值与我们服务器预置的数值不一样,就认为插被改过,弃用之。