热修复 CLASS_ISPREVERIFIED 问题(二)

热修复的基本原理在上篇文章中已经介绍过了,就是把补丁包的 dex 文件通过反射的方式插入到 ClassLoader 的 dexElements 的数组的最前面,这样 ClassLoader 在加载类的时候,首先加载到的就是修复之后的 Class 对象,有 bug 的 Class 对象就不会被加载到。看起来不难,但是会遇见一些问题,比如:补丁包的 dex 文件怎么打呢?通过反射将补丁包的 dex 文件加载到 dexElements 数组的最前面,具体代码怎么写呢?这样做之后,就可以顺利将修复之后的 Class 文件加载到虚拟机中了吗?

这些都会在本篇文件中一一详细介绍,本篇目录如下:



1. 测试 Demo

在上篇文章中,我们介绍了热修复的基本原理,就是将修复了 Bug 的类的 Class 文件打包成 dex 文件,并通过反射的方式插入到 ClassLoader 的 dexElements 的最前面,那么我们就按照这个思路开始动手写代码吧,源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// MainActivity.java
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.tv).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this, LoadBugClass.getBugClass(), Toast.LENGTH_SHORT).show();
Log.i(TAG, "The msg is " + LoadBugClass.getBugClass());
}
});
}
}

1
2
3
4
5
6
7
// LoadBugClass.java
public class LoadBugClass {
public static String getBugClass() {
return new BugClass().getMsg();
}
}
1
2
3
4
5
6
7
// BugClass.java
public class BugClass {
public String getMsg() {
return "This class has bug";
}
}
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
<!--activity_main.xml-->
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.lijiankun24.hotfixpractice.MainActivity">
<TextView
android:id="@+id/tv"
android:layout_width="0dp"
android:layout_height="48dp"
android:background="@android:color/holo_blue_light"
android:gravity="center"
android:text="Click Me!"
android:textColor="@android:color/white"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</android.support.constraint.ConstraintLayout>

包结构如下所示:



效果如下图所示:



2. 制作补丁 dex 文件

可以看到,现在 BugClass 类中存在一个 bug,点击 Click Me! 按钮会弹出 This class has bug 的提示,我们现在修复 BugClass 类,如下所示:

1
2
3
4
5
6
7
// BugClass.java
public class BugClass {
public String getMsg() {
return "This class has no bug";
}
}

修复好 bug 之后,就开始制作补丁包了,该如何制作补丁包呢?制作步骤如下所示:

  1. 重新编译项目,Rebuild Project 一下就行
  2. 在如下图所示的位置找到 BugClass.class 文件



  3. 将该 BugClass.class 文件拷贝出来,要注意将包也一同拷贝出来,如下图所示:



  4. 然后到 BugClass.class 文件所在的位置,执行 jar -cvf patch.jar com 命令,如下图所示:



  5. 然后再执行 dx --dex --output=patch_dex.jar patch.jar 命令,就会最终得到含有 BugClass.class 的 dex 文件了,如下图所示:



    这一步需要配置一下 dx 的环境变量

经过上述5个步骤,一个完整的 dex 补丁包文件就制作好了

3. 加载补丁包 dex 文件

经过上面的步骤,我们已经拥有修复了 bug 的含有 BugClass.class 的补丁包 dex 文件。

3.1 思路

在上一篇文章中,我们也讲了,是通过反射的方式,在 Application 的 onCreate() 方法中,将补丁包 dex 文件加载到 ClassLoader 的 dexElements 数组的最前面,捋一下思路,分为以下几个步骤:

  1. 首先,通过 Context 对象得到系统的 PathClassLoader 对象,从而得到该 PathClassLoader 中的 dexElements 数组对象
  2. 然后,通过新建一个 DexClassLoader 对象,并加载补丁包 dex 文件,得到补丁包 dex 文件中的 dexElements 数组对象
  3. 将两个 dexElements 数组对象合并得到新的 dexElements 数组,并设置到 PathClassLoader 对象中

3.2 编码实现

  1. 将打包好的 patch_dex.jar 文件放在 sdcard 位置下
  2. ApplicationonCreate() 方法中编码,如下所示:
    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
    // MyApplication
    public class MyApplication extends Application {
    @Override
    public void onCreate() {
    super.onCreate();
    // 获取补丁,如果存在就执行注入操作
    String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar");
    File file = new File(dexPath);
    if (file.exists()) {
    inject(dexPath);
    } else {
    Log.e("BugFixApplication", dexPath + "不存在");
    }
    }
    /**
    * 要注入的dex的路径
    *
    * @param path
    */
    private void inject(String path) {
    try {
    // 获取classes的dexElements
    Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
    Object pathList = getField(cl, "pathList", getClassLoader());
    Object baseElements = getField(pathList.getClass(), "dexElements", pathList);
    // 获取patch_dex的dexElements(需要先加载dex)
    String dexopt = getDir("dexopt", 0).getAbsolutePath();
    DexClassLoader dexClassLoader = new DexClassLoader(path, dexopt, dexopt, getClassLoader());
    Object obj = getField(cl, "pathList", dexClassLoader);
    Object dexElements = getField(obj.getClass(), "dexElements", obj);
    // 合并两个Elements
    Object combineElements = combineArray(dexElements, baseElements);
    // 将合并后的Element数组重新赋值给app的classLoader
    setField(pathList.getClass(), "dexElements", pathList, combineElements);
    //======== 以下是测试是否成功注入 =================
    Object object = getField(pathList.getClass(), "dexElements", pathList);
    int length = Array.getLength(object);
    Log.e("BugFixApplication", "length = " + length);
    } catch (ClassNotFoundException e) {
    e.printStackTrace();
    } catch (IllegalAccessException e) {
    e.printStackTrace();
    } catch (NoSuchFieldException e) {
    e.printStackTrace();
    }
    }
    /**
    * 通过反射获取对象的属性值
    */
    private Object getField(Class<?> cl, String fieldName, Object object) throws NoSuchFieldException, IllegalAccessException {
    Field field = cl.getDeclaredField(fieldName);
    field.setAccessible(true);
    return field.get(object);
    }
    /**
    * 通过反射设置对象的属性值
    */
    private void setField(Class<?> cl, String fieldName, Object object, Object value) throws NoSuchFieldException, IllegalAccessException {
    Field field = cl.getDeclaredField(fieldName);
    field.setAccessible(true);
    field.set(object, value);
    }
    /**
    * 通过反射合并两个数组
    */
    private Object combineArray(Object firstArr, Object secondArr) {
    int firstLength = Array.getLength(firstArr);
    int secondLength = Array.getLength(secondArr);
    int length = firstLength + secondLength;
    Class<?> componentType = firstArr.getClass().getComponentType();
    Object newArr = Array.newInstance(componentType, length);
    for (int i = 0; i < length; i++) {
    if (i < firstLength) {
    Array.set(newArr, i, Array.get(firstArr, i));
    } else {
    Array.set(newArr, i, Array.get(secondArr, i - firstLength));
    }
    }
    return newArr;
    }
    }

通过上面的步骤,按道理已经可以通过加载补丁包 dex 文件的方式动态地修复 bug 了,但是当项目运行起来之后,很不幸报错了:java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation

4. CLASS_ISPREVERIFIED 问题

java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation 就是热修复中大名鼎鼎的 CLASS_ISPREVERIFIED 问题,问题的具体原因在 安卓App热补丁动态修复技术介绍 有详细的介绍。在这里就不详细介绍,感兴趣的可以查看。

4.1 原因

大概介绍一下 CLASS_ISPREVERIFIED 原因如下:

  1. 在安装 Apk 的时候,虚拟机会通过 optdex 工具将 dex 文件优化成 odex 文件执行,在这个过程中会有一个 class 校验的过程
  2. 在校验某个类的 class 文件时,如果在该类的私有方法、构造方法、静态方法、@Override 方法中引用了其他的类,该类和其他类在同一个 dex 文件中,则该类的 class 文件则会被打上 CLASS_ISPREVERIFIED 的标签
  3. 但是在上述热修复的过程中,LoadBugClass 中的静态方法引用了 BugClass 类,而且 LoadBugClassBugClass 是在同一个 dex 文件中的,所以 LoadBugClass 被打上了 CLASS_ISPREVERIFIED 的标签,但是在运行的时候,LoadBugClass 类加载的却是补丁包中的 BugClass 文件,所以会出现如上问题
  4. 在分包方案 mutildex 中,为什么不会出现上述的异常呢?多个类也是放在不同的 dex 文件中的
  5. 因为在 mutildex 分包方案中,如果一个类在私有方法、构造方法、静态方法、@Override 方法中引用了其他 dex 文件中的类的话,就不会被打上 CLASS_ISPREVERIFIED 的标签,所以就不会报上述异常
  6. 要想解决上述问题,类不能被打上 CLASS_ISPREVERIFIED 的标签,这样就可以加载补丁包中的类了。

4.2 解决办法

为了阻止类被打上 CLASS_ISPREVERIFIED 的标签,需要在类的私有方法、构造方法、静态方法、@Override 方法中引用其他 dex 文件中的类,QQ 空间提出的解决方案是在类的无参构造方法中引用一个其他 dex 的类,如下图所示:



其中 AntilazyLoad 类会被打包成单独的 hack.dex,这样当安装 apk 的时候,classes.dex 内的类都会引用一个在不相同 dex 中的 AntilazyLoad 类,这样就防止了类被打上 CLASS_ISPREVERIFIED 的标志了,只要没被打上这个标志的类都可以进行打补丁操作。

解释说明:

  1. 在类的无参构造方法中引用其他 dex 文件中的类,是因为一个类即使没有显示的构造方法,也会有隐式的构造方法,并不会增加方法数
  2. 在应用启动的时候,AntilazyLoad 需要最先被引入进来,否则如果是在其他类之后引入进来的话,其他类在初始化的时候,会找不到 AntilazyLoad
  3. 一般在 Application 中的 onCreate() 方法中动态地加载其他 dex 文件,所以在 Application 方法的构造方法中不可以引入 AntilazyLoad

解决方法现在有了,那么接下来该如何编码实现呢?在 QQ 空间的文章中并没有具体的介绍如何编码实现在一个类的构造方法中,如何引用其他 dex 中的类,只是提了一句在字节码插入代码,而不是源代码插入,使用的是 javaassist 库来进行字节码插入的

  1. 不可以在源代码插入其他 dex 文件中的类,是因为 hack.dex 在编码的时候并没有被加载进来,AntilazyLoad 类并不存在,编译会不通过的
  2. 需要在源代码编译完成之后,在类的字节码文件中,在类的构造方法中插入其他 dex 文件中的类,可以使用 javaassist 工具实现
  3. 在 Android Studio 中,java 文件 —-> class 文件 —-> dex 文件这个流程都是由 gradle 构建工具自动完成的,怎么在 class 文件 —-> dex 文件的过程中,使用 javaassist 工具向类的构造方法中插入其他 dex 中的类文件的引用该如何实现呢?

好了,这篇文章至此就算是完成了,在第三篇文章中会介绍如何解决上面提到的第三个问题,就是在 class 文件 —-> dex 文件的过程中,使用 javaassist 工具向类的构造方法中插入其他 dex 中的类文件的引用该如何实现


参考资料:

Android热补丁动态修复技术(二):实战!CLASS_ISPREVERIFIED问题!Altsuki的博客

安卓App热补丁动态修复技术介绍