Android热修复原理普及
这段时间比较难闲,就抽空研究一下Android
热修复的原理。自从Android
热修复这项技术出现之后,随之而现的是多种热修复方案的出现。前两天又看到一篇文章分析了几种热修复方案的比较。
原文地址是:[Android热修复] 技术方案的选型与验证
看完这篇文章,有点汗颜。有这么多的热修复方案,并且他们之间的实现原理也不一样,各有优缺点。
然后在尼古拉斯_赵四的博客中看到几篇关于热修复的文章,对着这几篇文章撸了一番。大概的了解了热修复一种原理,其思路和QQ空间提出的安卓App热补丁动态修复技术介绍,原理上有相同之处,采用的是ClassLoader
替换dex
的方案。
本文对于这些实践做出一点总结。本文有些代码片段、图片来自上述文章。
首先列出热修复需要解决的几个问题:
- 资源替换
- 类替换(四大组件、类)
- SO补丁
基于上面3个问题,我做了几个测试,分别是动态加载资源、和动态运行APK
中的Activity
。至于SO
补丁方面的,由于本人技术有限,没有研究。
技术普及
在Android
中有两个类加载器,分别为PathClassLoader
和DexClassLoader
。其中我们正常开发的APP使用的类加载器就是PathClassLoader
。
关于这两个类在代码中的实际使用:
PathClassLoader
:通过Context getClassLoader()
获取。DexClassLoader
:通过构造函数new DexClassLoader()
获取。
DexClassLoader
的构造函数原型是:
public DexClassLoader(String dexPath, String dexOutputDir, String libPath,
ClassLoader parent)
dexPath
: 表示加载的APK/dex/jar
路径dexOutOutDir
: 解压文件的路径,因为APK
和JAR
最终都要解压出dex
文件,这个路径是用来存放dex
文件的。libPath
:加载的时候用到的lib
库,一般为null
parent
:DexClassLoader
的父加载器
资源的加载
目标:加载另一个APK
中的资源文件
思路:Andorid APP
默认的类加载是PathClassLoader
,这个只能加载自己APK
的dex
文件,所以我们需要使用DexClassLoader
。我们用DexClassLoader
加载外部的APK之后,通过反射获取对应的资源。
项目分为2个工程,一个宿主工程,一个插件工程。
首先我们看插件工程:
public class UIUtil {
public static String getTextString(Context ctx){
return ctx.getResources().getString(R.string.text);
}
public static Drawable getImageDrawable(Context ctx){
return ctx.getResources().getDrawable(R.mipmap.ic_launcher);
}
public static int getTextBackgroundId(Context ctx){
return ctx.getResources().getColor(R.color.color_green);
}
}
插件工程中有一个UIUtil
类,提供了几个静态的方法分别用来获取对应的资源(文字,图标,颜色)
接下来我们看看宿主工程中如何加载这里的资源。
- 首先我们需要创建一个
DexClassLoader
DexClassLoader classLoader = new DexClassLoader(filePath, fileRelease, null, getClassLoader());
filePath
指的是插件APK
的文件路径,注意:这里需要放在/data/data/packagename/
中才能生效。因为Android
系统的限制,自己加载的dex
只能在程序独有的文件中存在。
这里代码最后一个参数传进来的是getClassLoader()
,实际上就是PathClassLoader
,那么为什么需要把这个PathClassLoader
作为DexClassLoader
的父加载器呢。这里的ClassLoader
符合Java
类加载器的双亲委派机制,具体关于这两个ClassLoader
的介绍,请看这篇文章
通过反射调用APK中的方法。
Class clazz = null;
try {
clazz = classLoader.loadClass("com.example.resourceloaderapk.UIUtil");
//设置文字
Method method = clazz.getMethod("getTextString", Context.class);
String str = (String) method.invoke(null, this);
textV.setText(str);
//设置背景
method = clazz.getMethod("getTextBackgroundId", Context.class);
int color = (int) method.invoke(null, this);
Log.i("Loader", "color = " + color);
textV.setBackgroundColor(color);
//设置图片
method = clazz.getMethod("getImageDrawable", Context.class);
Drawable drawable = (Drawable) method.invoke(null, this);
Log.i("Loader", "drawable =" + drawable);
imgV.setImageDrawable(drawable);
} catch (Exception e) {
e.printStackTrace();
}
运行流程是这样的,首先我们把插件APK
打包之后,拷贝到/data/data/宿主apk packagename/
中的任意目录。然后上面初始化DexClassLoader
的时候把这个路径传过去。从代码可以看到,首先加载UIUtil
类,然后调用它的几个方法来获取对应的资源。
这样运行之后发现文字是可以获取的,但是image
和color
是无法获取到,会抛出Resources$NotFoundException
,资源找不到异常。我们知道,APK
中所有的资源都是通过Resources
来获取的。看回上面的代码,是通过传递一个this
关键字把宿主的Context
对象传递过去的。这个Context
对象是宿主工程的Context
,它并不能访问插件APK
的资源,那么我们需要做的就是把插件APK
的资源加载到宿主Context
中对应的Resources
对象中。
这里使用的方法是调用AssetManager
的addAssetPath()
方法,将一个APK
中的资源加载到Resources
中,这个方法是隐藏的,我们通过反射获取,如下:
/**
* 此方法的作用是把resource.apk中的资源加载到AssetManager中,
* 然后在重组一个Resources对象,这个Resources对象包括了resource.apk中的资源。
* <p/>
* resource.apk 中是使用Context.getResources()获得Resource对象的,
* 所以还要重写一些getResources()方法,返回该Resources对象
*
* @param dexPath
*/
protected void loadResource(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method method = assetManager.getClass().getMethod("addAssetPath", String.class);
method.invoke(assetManager, dexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources resource = getResources();
mResources = new Resources(mAssetManager, resource.getDisplayMetrics(), resource.getConfiguration());
mTheme = mResources.newTheme();
mTheme.setTo(getTheme());
}
这样处理之后我们就重新生成了一个Resource
对象,AssetManger
对象和Theme
对象。我们需要重写下面3个方法达到替换的目的。
@Override
public AssetManager getAssets() {
return mAssetManager == null ? super.getAssets() : mAssetManager;
}
@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}
@Override
public Resources.Theme getTheme() {
return mTheme == null ? super.getTheme() : mTheme;
}
这样getResources
获取到的就是我们新生成的mResources
对象了,这个对象包括了原有的资源和插件APK
的资源。
至此,加载插件APK中的资源就实现了功能。
总结
这里仅仅是对动态加载一些R文件的引用成功了,但是还有很多问题没有深入的去解决,比如相同包名的情况下该如何处理?接口如何统一?资源和原有的资源同名怎么处理?这么多问题的存在都需要大量的时间来解决,这里只是给出一个思路,犹如管中窥豹,由此对于资源的加载有一个感性的基础认识。
动态加载Activity
由上面的描述我们知道,一个应用的默认类加载器是PathClassLoader
,我们加载插件的时候使用的是DexClassLoader
。虽然我们可以用DexClassLoader
来获取到Activity
的实例,但是我们不能仅仅new
一个Intent
对象然后启动Activity
,因为我们从DexClassLoader
中加载的Activity
类仅仅是一个普通的JAVA
类,Android
四大组件都有自己的启动流程和生命周期,使用DexClassLoader
不会涉及到任何生命流程的东西。
既然这样,那么就要从Activity
的启动流程入手了。我们需要做的不是详细了解Activity
的启动流程,思路是将加载了dex
的DexClassLoader
绑定到系统启动Activity
的类加载器上就行了。
第一种方案
了解过一点Andorid
源码的小伙伴应该都知道,我们Activity
的启动流程涉及到ActivityThread
类,我们来看看它的源码:
在里面有一个静态的sCurrentActivityThread
对象,我们暂且不管他是如何创建实例的,因为应用启动的时候就会启动一个Activity
,这时候sCurrentActivityThread
对象肯定不为空。我们获取到ActivityThread
对象之后,我们再看看代码,里面有一个mPackages
保存的是以packageName
为key
,LoadedApk
为value
的map
。
再点开LoadedApk
来观察:
里面有个mClassLoader
对象。好了,好嗨森,我们只需要把自己的DexClassLoader
设置到这个mClassLoader
对象就能正常启动Activity
了
代码如下:
public void replaceLoadedApk(View v) {
try {
//通过替换LoadedApk中的mClassLoader来达到加载apk中的Activity
String fileDir = getCacheDir().getAbsolutePath();
String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME; //源dex/jar/apk 目录
DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());
Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[]{}, new Object[]{});
String packageName = getPackageName();
//通过反射获取ActivityThread的 mPackages 对象
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mPackages");
//通过反射获mPackages获得当前的LoadedApk对象
WeakReference wr = (WeakReference) mPackages.get(packageName);
Log.i(TAG, "wr = " + wr.get());
//替换LoadedApk中的mClassLoader 为我们自己的DexClassLoader
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), loader);
Log.i(TAG, "classloader = " + loader);
startResourceActivity(filePath, loader);
} catch (Exception e) {
Log.i(TAG, "load apk error :" + Log.getStackTraceString(e));
}
}
/**
* 启动插件Activity
* @param filePath
* @param loader
* @throws ClassNotFoundException
* @throws NoSuchFieldException
* @throws IllegalAccessException
* @throws NoSuchMethodException
* @throws InvocationTargetException
*/
private void startResourceActivity(String filePath, ClassLoader loader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
//加载资源
loadResources(filePath);
//加载Activity ,确保这里的类名和Constants.RESOURCE_APK_NAME 中的 类名相同
Class clazz = loader.loadClass("com.example.resourceloaderapk.MainActivity");
//找到R.layout.activity_main
Class rClazz = loader.loadClass("com.example.resourceloaderapk.R$layout");
Field field = rClazz.getField("activity_main");
Integer ojb = (Integer)field.get(null);
View view = LayoutInflater.from(this).inflate(ojb, null);
//设置静态变量。这里为什么要设置静态变量呢。
// 因为测试发现setContentView() 没有起作用。
// 所以在启动Activity之前保存一个静态的View,设置到Activity中
Method method = clazz.getMethod("setLayoutView", View.class);
method.invoke(null, view);
//找到MainActivity,然后启动
startActivity(new Intent(this, clazz));
}
噢,忘了说,在插件工程中创建一个MainActivity
,包名为com.example.resourceloaderapk
,里面给各个声明周期打一下log
public class MainActivity extends AppCompatActivity {
public static final String TAG = "Resource_MainActivity";
private static View parentView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if(parentView == null){
setContentView(R.layout.activity_main);
}else{
setContentView(parentView);
}
}
public static void setLayoutView(View view){
parentView = view;
}
@Override
protected void onResume() {
super.onResume();
Log.i(TAG, "resource activity onResume");
}
@Override
protected void onStart() {
super.onStart();
Log.i(TAG, "resource activity onStart");
}
@Override
protected void onStop() {
super.onStop();
Log.i(TAG, "resource activity onStop");
}
@Override
protected void onPause() {
super.onPause();
Log.i(TAG, "resource activity onPause");
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.i(TAG, "resource activity onDestroy");
}
这里增加了一个静态方法设置一个View
,然后在onCreate
中优先加载这个View
。具体原因是因为在实践过程中,发现setContentView(layoutId)
并不生效,所以先生成一个View
在加载页面了。
在这里的过程中,在运行的时候发现会抛出一个熟悉的错误:Unable to find explicit activity class. have you decleared this activity in the AndroidManifest.xml?
没理由呀,在插件工程中已经声明了的,但是想想还是能理解,我们在宿主Activity
中`startActivity,所以需要在宿主工程中声明这个组件。
第二种方案
这里的方法比较贴近QQ
空间提出的替换dex的方案。PathClassLoader
和DexClassLoader
都是属于BaseDexClassLoader
的子类。
然后BaseDexClassLoader
中一个成员为DexPath pathList
:
再看看DexPathList
:
里面有个Element
数组,这个数组是用来存放dex
文件的路径的,系统默认的类加载器是PathClassLoader
,程序加载之后会释放出一个dex
文件,那么我们的做法就是,把DexClassLoader
的dexElements
和PathClassLoader
的dexElements
文件合并之后再放到PathClassLoader
的pathList
中。这样Activity
的启动流程也是正确的。
如下:
public void injectDexElements(View v){
Log.i(TAG,"this classloader = " + getClassLoader());
PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader();
String fileDir = getCacheDir().getAbsolutePath();
String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME; //源dex/jar/apk 目录
DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());
try {
//把PathClassLoader和DexClassLoader的pathList对象中的 dexElements 合并
Object dexElements = combineArray(
getDexElements(getPathList(pathClassLoader)),
getDexElements(getPathList(loader)));
//把合并后的dexElements设置到PathClassLoader的 pathList对象中的 dexElements
Object pathList = getPathList(pathClassLoader);
setField(pathList, pathList.getClass(), "dexElements", dexElements);
startResourceActivity(filePath,pathClassLoader);
} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
private static Object getPathList(Object baseDexClassLoader)
throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
ClassLoader bc = (ClassLoader)baseDexClassLoader;
return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
private static Object getField(Object obj, Class<?> cl, String field)
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
private static Object getDexElements(Object paramObject)
throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
return getField(paramObject, paramObject.getClass(), "dexElements");
}
private static void setField(Object obj, Class<?> cl, String field,
Object value) throws NoSuchFieldException,
IllegalArgumentException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
localField.set(obj, value);
}
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> localClass = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);
int j = i + Array.getLength(arrayRhs);
Object result = Array.newInstance(localClass, j);
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(arrayLhs, k));
} else {
Array.set(result, k, Array.get(arrayRhs, k - i));
}
}
return result;
}
运行的结果是一样的。
第三种方案
还有一种方式就和前两种方式的思路截然不同了。这里的思路是在宿主工程中创建一个代理Activity
,然后插件Apk
中的Activity
就仅仅是一个普通的java
类,对应着几个声明周期方法,然后通过反射在代理Activity
的生命周期方法中调用对应的插件Activity
的方法。我这里没有实践过,但是理论上是一种不错的方案。
具体看这篇文章
总结
还是那句话,热修复的坑很多,这里的知识仅仅是冰山一角,还有很多问题需要解决,但是这样折腾一下,起码不会对热修复这东西两眼懵逼了。