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,所以需要在宿主工程中声明这个组件。
第二种方案
这里的方法比较贴近
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的方法。我这里没有实践过,但是理论上是一种不错的方案。
具体看这篇文章
总结
还是那句话,热修复的坑很多,这里的知识仅仅是冰山一角,还有很多问题需要解决,但是这样折腾一下,起码不会对热修复这东西两眼懵逼了。
源码地址
相关文章推荐
- Android 热修复Nuwa的原理及Gradle插件源码解析
- Android热补丁动态修复技术(一):从Dex分包原理到热补丁
- Android 中免 Root 实现 Hook 的 Dexposed 实现原理解析以及如何实现应用的热修复
- Android 主要的热修复方案原理分析
- Android中热修复框架Robust原理解析+并将框架代码从"闭源"变成"开源"(下篇)
- 关于Android APP在线热修复bug方案的调研(二)(MultiDex的原理分析---Nuwa)
- Android 热修复原理与实现方案学习
- Android中热修复框架AndFix原理解析及案例使用
- Android 热修复原理和实现
- Android热修复框架AndFix原理解析及使用
- Android中免Root实现Hook的Dexposed框架实现原理解析以及如何实现应用的热修复
- Android 热修复技术(1)---原理
- (4.2.32.6)android热修复之Andfix方式:Andfix的Hook方式打补丁原理
- Android热修复框架AndFix原理解析及使用
- Android热补丁动态修复技术(一)dex分包原理
- Android 热修复原理
- Android-AndFix 热修复框架原理及源码解析
- Android中免Root实现Hook的Dexposed框架实现原理解析以及如何实现应用的热修复
- Android中热修复框架Robust原理解析+并将框架代码从"闭源"变成"开源"(上篇)
- Android热修复实现原理以及方法