您的位置:首页 > 移动开发 > Android开发

Android 热修复原理

2016-12-13 18:07 246 查看


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)
1
2
1
2
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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
1
2
3
4
5
6
7
8
9
10
11
12
13

插件工程中有一个
UIUtil
类,提供了几个静态的方法分别用来获取对应的资源(文字,图标,颜色)

接下来我们看看宿主工程中如何加载这里的资源。
首先我们需要创建一个
DexClassLoader

DexClassLoader classLoader = new DexClassLoader(filePath, fileRelease, null, getClassLoader());
1
2
1
2

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();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

运行流程是这样的,首先我们把插件
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());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

这样处理之后我们就重新生成了一个
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这样
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));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

噢,忘了说,在插件工程中创建一个
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");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

这里增加了一个静态方法设置一个
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

运行的结果是一样的。

第三种方案


还有一种方式就和前两种方式的思路截然不同了。这里的思路是在宿主工程中创建一个代理
Activity
,然后插件
Apk
中的
Activity
就仅仅是一个普通的
java
类,对应着几个声明周期方法,然后通过反射在代理
Activity
的生命周期方法中调用对应的插件
Activity
的方法。我这里没有实践过,但是理论上是一种不错的方案。 

具体看这篇文章

总结


还是那句话,热修复的坑很多,这里的知识仅仅是冰山一角,还有很多问题需要解决,但是这样折腾一下,起码不会对热修复这东西两眼懵逼了。

源码地址
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: