热修复基本原理

目前流行的热修复方案主要有以下三种:

  • 代码修复
  • 资源修复
  • 动态链接库修复

本章主要讲述 第一种方案:代码修复

代码修复

原理:对出现Bug的类进行修改或替换

类加载方案

核心思想:使用Android的类加载器,通过类加载器去加载已修复好Bug的Class并对有问题的Class进行覆盖。

加载完成后需要重启应用才可生效,因为当前在使用的类是无法卸载的即不可替换,只有重启后重新加载才可成功。

相关概念

  • 65536限制

    随着应用功能越来越复杂,代码量不断地增大,引入的库也会越来越多,可能导致出现异常

    1
    com.android.dex.DexIndexOverflowException:method ID not in [0,0xffff]:65536

    应用中是限制了引用方法超过最大数65536个。限制是由于DVM bytecode的限制导致的,因为DVM指令集的方法调用指令invoke-kind索引最大值为16bits,故为65536个方法。

  • LinearAlloc限制

    在安装应用时可能会提示INSTALL_FAILED_DEXOPT,产生的原因就是LinearAlloc限制,LinearAlloc是一个固定的缓存区,超出即会报错。

为了解决上述的两个问题,产生了DEX分包方案。主要在打包时将应用代码分成多个Dex,将应用启动时必须的类以及直接引用类放入主Dex中,其他代码放到次Dex中。应用启动时就先去加载主Dex,然后动态加载次Dex,从而缓解上述限制

原理分析

类加载方案需要通过ClassLoader的实现类完成。在Android中主要有两种类加载器:

  • DexClassLoader

    继承自BaseDexClassLoader,支持加载包含classes.dex的jar、apk,zip文件,可以是SD卡的路径。是实现热修复的关键。注意不要把优化后的文件放在外部存储,可能导致注入攻击。

    /libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java
    1
    2
    3
    4
    5
    6
    public class DexClassLoader extends BaseDexClassLoader{ 
    public DexClassLoader(String dexPath, String optimizedDirectory,
    String librarySearchPath, ClassLoader parent)
    {
    super(dexPath, null, librarySearchPath, parent);
    }
    }
  • PathClassLoader

    用来加载Android系统类和应用程序的类,在dalvik上只能加载已安装apk的dex(/data/app目录),在ART虚拟机上则没有这个限制

    /libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
    super(dexPath,null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
    super(dexPath,null, librarySearchPath, parent);
    }
    }
上述类加载器都继承自BaseDexClassLoader
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
public class BaseDexClassLoader extends ClassLoader{
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent)
{
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);
...
}

...
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
//根据类名去找出对应的类文件
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException(
"Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
}

主要构造函数介绍:

  • dexPath:指目标类所在的apk、dex或jar文件的路径,也可以是SD卡的路径,类加载器从该路径加载目标类。如果包含多个路径,路径之间必须用特定的分隔符去分隔,特定的分隔符从System.getProperty("path.separtor")获取(默认分割符为”:”)。最终将路径上的文件ODEX优化到optimizedDirectory,然后进行加载。
  • optimizedDirectory:解压出的dex文件路径,这个路径必须为内部路径,一般情况下的路径为/data/data/<Package_Name>/
  • librarySearchPath:存放目标类中使用的native文件库,也以”:”分割
  • parent:父加载器,在Android中以context.getClassLoader作为父加载器。

在Android8.0之后,optimizedDirectory参数失效。由子类去控制解压文件路径。

findClass()用来加载dex中的Class文件。内部调用到DexPathList.findClass()实现

DexPathList

内部存储的是一个个的Dex文件地址,方便后续进行寻找调用

/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class DexPathList{
private static final String DEX_SUFFIX = ".dex";
private static final String zipSeparator = "!/";

/** class definition context */
private final ClassLoader definingContext;
//存储dex文件
private Element[] dexElements;

public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory)
{
...
this.definingContext = definingContext;
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions);
...
}

}

保存当前的类加载器definingContext,并调用makeDexElements()初始化Element数组。

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
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
// 1.创建Element集合
ArrayList<Element> elements = new ArrayList<Element>();
// 2.遍历所有dex文件(也可能是jar、apk或zip文件)
for (File file : files) {
if(file.isDirectory()){
elements[elementsPos++] = new Element(file);
}else if(file.isFile){
String name = file.getName();
...
// 如果是dex文件
if (name.endsWith(DEX_SUFFIX)) {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if(dex!=null){
elements[elementsPos++] = new Elements(dex,null);
}
// 如果是apk、jar、zip文件(这部分在不同的Android版本中,处理方式有细微差别)
} else {
DexFile dex = null;
try{
dex = loadDexFile(file, optimizedDirectory);
}

}
...
// 3.将dex文件或压缩文件包装成Element对象,并添加到Element集合中
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
}
// 4.将Element集合转成Element数组返回
if(elementsPos != elements.length){
elements = Arrays.copyOf(elements,elementsPos);
}
return elements;
}

makeDexElement中,将传入的文件(Dex、apk、zip)封装成一个个的Element对象,然后添加至Element集合中。

在Android的类加载器中,他们只会去解析dex文件。通过loadDexFile()就可以将其他类型的文件转换成dex文件以供加载。

findClass():寻找类名相同的类并返回

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
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
// 遍历出一个dex文件
DexFile dex = element.dexFile;

if (dex != null) {
Class clazz = element.findClass(name,definingContext,suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}

static class Element{
...
public Class<?> findClass(String name, ClassLoader definingContext,
List<Throwable> suppressed) {
//在dex文件中查找类名相同的类
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext,suppressed): null;
}
...
}

findClass()中,对Elements数组进行遍历,一旦找到与传入类名相同的类即返回。

Element内部封装了DexFile,DexFile用于加载Dex文件,因此一个Element对象会对应一个Dex文件。多个Element组成了有序数组dexElements。需要查找类时便去遍历dexElements,再去调用findClass()查找类。在Dex中存储的是一堆Class文件,需要在dex文件中通过loadClassBinaryName()去找寻对应的Class文件。如果没有找到就接着去下一个Element中寻找。

实现原理

经过上述的源码分析,加载一个类时都会从dexElements数组获取到对应的类之后再进行加载。遍历过程由数组头部开始。所以我们可以将已修复好的Class打包成一个Dex文件并放置到dexElements数组的第一个位置(也解决了CLASS_ISPREVERIFIED问题,当打上该标记时该类就无法被替换),这样就可以保证已修复好的Class会被优先加载而排在数组后面的Bug类就不会被加载(由于双亲委托机制)。

双亲委托机制:如果一个类加载器收到了类加载的请求,不会自己去尝试加载这个类,而把这个请求委派给父类加载器去完成,每一层都是如此,依次向上递归,直到委托到最顶层的Bootstrap ClassLoader,若父加载器无法处理加载请求(它的搜索范围内没有找到所需的类时),则交由子加载器去加载。

双亲委托机制的好处:

  • 避免重复加载,若Class已被加载则从缓存中获取不会重新加载
  • 更加安全,例如java.lang.Object基础类的加载都需要最终委派到BootstrapClassLoader进行加载,即时去自定义类加载器进行加载也不会产生多个类。
{% fullimage /images/类加载方案.png,类加载方案,类加载方案%}

修复实战

  1. 制作一个有Bug的类

    1
    2
    3
    4
    5
    6
    7
    8
    public BugActivity extends AppCompatActivity{
    @Override
    public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.act_bug);
    Log.e("Bug", String.valueOf(2/0));
    }
    }
  2. 制作一个Bug修复类

    1
    2
    3
    4
    5
    6
    7
    8
    public BugActivity extends AppCompatActivity{
    @Override
    public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.act_bug);
    Log.e("Bug", "fix");
    }
    }
  3. 将Bug修复类BugActivity.class打包成dex(DVM只能识别dex文件)

    rebuild project之后在build->intermediates->javac可以找到对应的class文件。

    取出该class文件后(取出时需要带上完整的包名路径),就需要通过SDK/build-tools/XX/dx将class文件转成dex文件

    1
    2
    3
    4
    //dex文件中放置着对应的class文件及其完整路径 
    Mac:sh dx --dex --output=../dex/classes2.dex ../dex
    Win: dx --dex --output=../dex/classes2.dex ../dex
    //执行完毕后就会生成对应的dex文件 -- classes2.dex

    此时就可以得到最终需要替换进去的dex文件。

  4. 加载dex文件

    利用反射机制去修改DexClassLoader中的dexElements,需要把修复过后的classes2.dex插入到头部位置,保证可以优先加载。

    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
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    public class FixDexUtil {
    //列出修复支持的文件格式
    private static final String DEX_SUFFIX = ".dex";
    private static final String APK_SUFFIX = ".apk";
    private static final String JAR_SUFFIX = ".jar";
    private static final String ZIP_SUFFIX = ".zip";

    private static final String DEX_DIR = "odex";

    private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
    private static HashSet<File> loadedDex = new HashSet<>();

    static {
    //清理已存在的dex
    loadedDex.clear();
    }

    /**
    * 加载补丁,使用默认目录:data/data/包名/files/odex
    *
    * @param context
    */

    public static void loadFixedDex(Context context) {
    loadFixedDex(context, null);
    }

    /**
    * 加载补丁
    *
    * @param context 上下文
    * @param patchFilesDir 补丁所在目录
    */

    public static void loadFixedDex(Context context, File patchFilesDir) {
    boolean canFix = false;
    // 遍历所有的修复dex , 因为可能是多个dex修复包
    File fileDir = patchFilesDir != null ?
    patchFilesDir :
    new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(这个可以任意位置)

    File[] listFiles = fileDir.listFiles();
    if (listFiles != null && listFiles.length != 0)
    for (File file : listFiles) {
    if (file.getName().startsWith("classes") &&
    (file.getName().endsWith(DEX_SUFFIX)
    || file.getName().endsWith(APK_SUFFIX)
    || file.getName().endsWith(JAR_SUFFIX)
    || file.getName().endsWith(ZIP_SUFFIX))) {

    loadedDex.add(file);// 存入集合
    //有修复包的存在,意味需要修复
    canFix = true;
    }
    }
    // dex合并之前的dex
    if (canFix)
    doDexInject(context, loadedDex);
    }

    private static void doDexInject(Context appContext, HashSet<File> loadedDex) {
    String optimizeDir = appContext.getFilesDir().getAbsolutePath() +
    File.separator + OPTIMIZE_DEX_DIR;
    // data/data/包名/files/optimize_dex(这个必须是自己程序下的目录)

    File fopt = new File(optimizeDir);
    if (!fopt.exists()) {
    fopt.mkdirs();
    }
    try {
    // 1.加载应用程序dex的Loader
    PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
    for (File dex : loadedDex) {
    // 2.加载指定的修复的dex文件的Loader
    DexClassLoader dexLoader = new DexClassLoader(
    dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
    fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
    null,// 加载dex时需要的库
    pathLoader// 父类加载器
    );
    // 3.开始合并
    // 合并的目标是Element[],重新赋值它的值即可

    /**
    * BaseDexClassLoader中有 变量: DexPathList pathList
    * DexPathList中有 变量 Element[] dexElements
    * 依次反射即可
    */


    //3.1 准备好pathList的引用
    Object dexPathList = getPathList(dexLoader);
    Object pathPathList = getPathList(pathLoader);
    //3.2 从pathList中反射出element集合
    Object leftDexElements = getDexElements(dexPathList);
    Object rightDexElements = getDexElements(pathPathList);
    //3.3 合并两个dex数组
    Object dexElements = combineArray(leftDexElements, rightDexElements);

    // 重写给PathList里面的Element[] dexElements;赋值
    Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
    setField(pathList, pathList.getClass(), "dexElements", dexElements);
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    /**
    * 反射给对象中的属性重新赋值
    */

    private static void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
    Field declaredField = cl.getDeclaredField(field);
    declaredField.setAccessible(true);
    declaredField.set(obj, value);
    }

    /**
    * 反射得到对象中的属性值
    */

    private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
    Field localField = cl.getDeclaredField(field);
    localField.setAccessible(true);
    return localField.get(obj);
    }

    /**
    * 反射得到类加载器中的pathList对象
    */

    private static Object getPathList(Object baseDexClassLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    /**
    * 反射得到pathList中的dexElements
    */

    private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
    return getField(pathList, pathList.getClass(), "dexElements");
    }

    /**
    * 数组合并
    */

    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
    Class<?> clazz = arrayLhs.getClass().getComponentType();
    int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
    int j = Array.getLength(arrayRhs);// 得到原dex数组长度
    int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
    Object result = Array.newInstance(clazz, k);// 创建一个类型为clazz,长度为k的新数组
    System.arraycopy(arrayLhs, 0, result, 0, i);
    System.arraycopy(arrayRhs, 0, result, i, j);
    return result;
    }

    }

    该类中主要的功能:

    1. 获取对应目录中存在的apk、dex、jar,zip文件
    2. 将文件转换成Element格式并生成一个elements数组
    3. 将生成的数组与原先存在的dexElements数组进行合并
    4. 合并完成后利用反射将数组放置回ClassLoader

    如果要加载的文件格式为apk、jar,zip需要进行一些特殊处理

    这些文件格式中需要有一个classes.dex文件,不然会出错

  5. 进行检测以及修复工作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //在项目初始化时便去进行修复检测
    class MyApplication extends Application{
    @Override
    protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    //提前进行初始化 提前至 onCreate()之前
    FixDexUtil.loadFixedDex(base, Environment.getExternalStorageDirectory());
    }
    }
  6. 将修复好的classes2.dex文件放到对应的目录中,然后重新打开应用,重新观察结果即可。

底层替换方案

底层替换方案是在已经加载了的类中直接去替换原有方法,是在原来类的基础上进行修改。由于在原有类进行修改限制会比较多,且不能增减原有类的方法和字段,否则会破坏原有类的结构。底层的替换方案还与反射有所关联。

传统的底层替换方案,都是直接去修改虚拟机方法实现的具体字段。主要是去操作ArtMethod结构体,但是会存在兼容性问题,可能由于厂商对其进行了修改。

优化点就是 直接替换整个ArtMethod结构体,这样就不会存在兼容性的问题。

ArtMethod:包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等。

优点:底层替换方案直接替换了方法,而且是立即生效不需要进行重启操作。

Instant Run方案

Instant Run的部署方式有以下三种:

  • Hot Swap效率最高。代码的增量改变不需要重启App,甚至Activity都不需要重启。修改一个现有方法中的代码多采用这种部署方式。
  • Warm Swap:App不需要重启,但是Activity需要重启。修改或删除一个现有的资源文件时多采用这种部署方式。
  • Cold Swap:App需要重启,但是不需要重新安装。添加、删除或修改一个字段和方法或者修改一个类等多采用这种部署方式。
传统编译部署 Instant Run编译部署

工作原理

利用ASM在每一个方法中注入类似如下的代码:

1
2
3
4
5
6
7
8
9
10
public interface IncrementalChange {
Object access$dispatch(String id, Object... args);
}

//注入代码如下
IncrementalChange localIncrementalChange = $change;
if(localIncrementalChange!=null){
localIncrementalChange.access$dispatch("");
return;
}

$change指代了方法是否发生变化,如果发生变化就会调用到access$dispatch()生成对应的替换类Class$override替代执行原有方法,即完成了对原有方法的修改。

内容引用

《深入探索Android热修复技术原理》

《Android进阶解密》