Dex 分包

2022/08/22

MultiDex 是什么

multiDexEnabled true

简单地来说,MultiDex 做的事情就是:

  1. 解压得到 dex 并进行 dexOpt ;
  2. 把主dex文件除外的 dex 文件都追加到 PathClassLoader 中 DexPathListde Element[] 数组中

会遇到的crash

1. java.lang.VerifyError: Verifier rejected class com.xxx

发生 Caused by: java.lang.VerifyError: Verifier rejected class 及 java.lang.NoClassDefFoundError

依赖第三方应用时,丢包/类、方法等 运行时报错

1、检查分包问题,主要类分包放到主dex中

注意:在 MultiDex.install() 完成之前,不要通过反射或 JNI 执行 MultiDex.install() 或其他任何代码。MultiDex 跟踪功能不会追踪这些调用,从而导致出现 ClassNotFoundException,或因 DEX 文件之间的类分区错误而导致验证错误。如果不是这个问题 继续看下面

2、检查依赖版本编译环境是否一致

3、检查插件侵入式代码影响(重要)

4、升级所依赖版本 ———————————————— 版权声明:本文为CSDN博主「lwqldsyzx」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/lwqldsyzx/article/details/115190132

2. java.lang.VerifyError:一技组合拳,另一个方向修复

  1. 使用了D8

  2. 寄存器类型匹配失败

    可以看出方法使用的寄存器 5 个。一个 catch 异常处理。参数2个。 Debug 包仅仅比 Release 包在异常处理处多个一个 move-exception 指令。
       
    字节码的异同是因为项目中使用 D8 。D8 生成 Dex 的时候会做一些优化。如字符串优化, new-array 指令优化,分支指令优化等。 其中包含一些无效指令的删除。 比如一个异常被 catch。 但并没有对异常进行操作。在 Release 模式下那么 D8 认为 move-exception 指令是一个无意义的操作,该指令将会被移除。
       
    至此我们已经知道了出现问题的大概。
    因为 D8 对 Dex 优化。生成特定的指令排列导致在部分虚拟机校验失败。
    
  3. dexduup工具查看Dex字节码

  4. 转下文

0x00 背景

最近整体升级了项目的工具链。 使用了 D8 作为项目的主力。 在 Release 包在 5.1 上出现了 java.lang.VerifyError 异常。

0x01 问题定位

VerifyError 错误一般出现的 5.0 以下。通常由分包导致的。但是这次发生的机子是 5.1 。

我们将问题代码进行简化如下。

public class A {

    // 方法调用入口
    public int method1(Activity activity) {
        if (Build.VERSION.SDK_INT >= 24 && activity.isInMultiWindowMode()) {
            // 节点1 
            return 0;
        }
        try {
            // 节点2 
            Point screenSize = method2((Runnable) activity);
            method3(activity, screenSize);
            return 1;
        } catch (Exception e) {
            // 节点3 
            return 0;
        }
    }

    private Point method2(Runnable activity) {
        return new Point();
    }

    private void method3(Activity activity, Point screenSize) {
        //忽略
    }
}

运行奔溃如下:

java.lang.VerifyError: Verifier rejected class com.dim.A due to bad method int com.dim.A.method1(android.app.Activity) (declaration of 'com.dim.A' appears in /data/app/com.dim-2/base.apk)

往往单纯的奔溃信息是不足以发现问题的。查找上下文日志获取更多信息。

I/art: Verification error in int com.dim.A.method1(android.app.Activity)
I/art: int com.dim.A.method1(android.app.Activity): [0x7] couldn't find method android.app.Activity.isInMultiWindowMode ()Z
I/art: int com.dim.A.method1(android.app.Activity) failed to verify: int com.dim.A.method1(android.app.Activity): [0x1A] register v1 has type Undefined but expected Integer return-1nr on invalid register v1 
E/art: Verification failed on class com.dim.A in /data/app/com.dim-2/base.apk because: Verifier rejected class com.dim.A due to bad method int com.dim.A.method1(android.app.Activity)

发现两个异常信息:

  1. isInMultiWindowMode 方法未找到 : 找不到 isInMultiWindowMode 方法。 这个方法是在 api 24 上加入的, 确实在 android 5.1 ( api 22) 上不存在。 就这?
  2. 寄存器类型匹配失败: java 虚拟机检验类合法性的时候会匹配栈帧。 对应 android 虚拟机校验寄存器注册表。

根源问题在寄存器类型匹配失败。 导致校验方法失败从而校验类失败。

比较吊诡的是这个问题只出现在 android 5.1 上。 并且只在 Release 包上出现。 据其原因我们使用 dexduup 工具 查看该方法在 Debug 和 Release 包生成的 Dex 字节码的异同。

image-20220823123056583

可以看出方法使用的寄存器 5 个。一个 catch 异常处理。参数2个。 Debug 包仅仅比 Release 包在异常处理处多个一个 move-exception 指令。

字节码的异同是因为项目中使用 D8 。D8 生成 Dex 的时候会做一些优化。如字符串优化, new-array 指令优化,分支指令优化等。 其中包含一些无效指令的删除。 比如一个异常被 catch。 但并没有对异常进行操作。在 Release 模式下那么 D8 认为 move-exception 指令是一个无意义的操作,该指令将会被移除。

至此我们已经知道了出现问题的大概。 因为 D8 对 Dex 优化。生成特定的指令排列导致在部分虚拟机校验失败。

0x02 问题回溯

查看 art 相关代码 art 方法校验入口在 MethodVerifier::Verify()

insn_flags_.reset(new InstructionFlags[code_item_->insns_size_in_code_units_]());
// Run through the instructions and see if the width checks out.
bool result = ComputeWidthsAndCountOps();
// Flag instructions guarded by a "try" block and check exception handlers.
result = result && ScanTryCatchBlocks();
// Perform static instruction verification.
result = result && VerifyInstructions();
// Perform code-flow analysis and return.
result = result && VerifyCodeFlow();
// Compute information for compiler.
if (result && Runtime::Current()->IsCompiler()) {
  result = Runtime::Current()->GetCompilerCallbacks()->MethodVerified(this);
}

校验方法主要以下几个方面

  1. 校验指令大小是否超过声明大小。
  2. 校验方法指令使用的寄存器是否越界。
  3. 校验跳转指令是否越界或错误。
  4. 校验指令引用的元素在 Dex 位置是否正确。
  5. 校验寄存器注册表否正确。即从寄存器读取的类型是否匹配声明的类型。
  6. 锁 是否被正确释放。

这次这个错误是在校验寄存器注册表出现的。

寄存注册表校验流程如下:

为每个指令设置一个 insn_flags 标记。当对应的 insn_flags 设置为 Changed。 那么该指令需要被校验。art 会从第一个指令开始校验 。 校验指令的同时会设置其他的指令设置 Changed。如操作指令会设置下一个指令为 Changed。分支指令因为存在多个分支的指令。 会对多个分支的第一个指令设置 Changed。回值指令 则不会为任何指令设置。 通过检查是否还存在 Changed 标记位来检查是否完成校验工作。 关于指令的类型定义都 dex_instruction_list.h

kContinue` 为`操作指令`
`kBranch` 为`分支指令`
`kReturn` 为`回值指令

指令在运行的时候还存在一个寄存器注册表。寄存器注册表很大一部分体现了当前运行的环境。 当遇到分支指令的时候, 由于存在分支跳转。还需要把寄存器注册表状态转移到所有的分支上。 一个指令多次被执行的时候。就会存在多张寄存器注册表,需要合并这些表。当合并不兼容的时候, 需要重新校验该分支的代码。

从字节码流程中观察寄存器注册表的变化。来定位问题。

|0000: sget v0, Landroid/os/Build$VERSION;.SDK_INT:I // field@0000
|0002: const/4 v1, #int 0 // #0
|0003: const/16 v2, #int 24 // #18
|0005: if-lt v0, v2, 000e // +0009
|0007: invoke-virtual {v4}, Landroid/app/Activity;.isInMultiWindowMode:()Z // method@0001
|000a: move-result v0
|000b: if-eqz v0, 000e // +0003
|000d: return v1
|000e: move-object v0, v4
|000f: check-cast v0, Ljava/lang/Runnable; // type@001c
|0011: invoke-virtual {v3, v0}, Lcom/dim/A;.method2:(Ljava/lang/Runnable;)Landroid/graphics/Point; // method@0008
|0014: move-result-object v0
|0015: invoke-direct {v3, v4, v0}, Lcom/dim/A;.method3:(Landroid/app/Activity;Landroid/graphics/Point;)V // 
|0018: const/4 v1, #int 1 // #1
|0019: return v1
|001a: return v1
catches       : 1
    0x000e - 0x0018
    Ljava/lang/Exception; -> 0x001a
  1. 第一步 该方法声明寄存器5个,初始化寄存器注册表 V0~V4: xxxL1L2 x: 未定义 L1 :this 对象类型 L2 :第一个入参
  2. 第二步 校验第一个指令 0000 sget V0 设置指令 0002 的 insn_flags 为 Changed 寄存器注册表 IxxL1L2
  3. 第三步 校验指令 0002 const/4 v1, #int 0 设置下一个指令 0003 的 insn_flags 为 Changed 寄存器注册表 IIxL1L2
  4. 第四步 校验指令 0003 const/16 v2, #int 24 设置下一个指令 0005 的 insn_flags 为 Changed 寄存器注册表 IIIL1L2
  5. 第五步 校验分支指令 0005: if-lt v0, v2, 000e 设置下一个指令 0007 的 insn_flags 为 Changed 设置下个分支第一个指令 000e 的 insn_flags 为 Changed 寄存器注册表 IIIL1L2 复制寄存注册表到 000e 上
  6. 第六步 校验指令 0007: invoke-virtual {v4}, Landroid/app/Activity;.isInMultiWindowMode:()Z 检验发现 isInMultiWindowMode 方法不存在。该异常会导致出现运行期异常。 该条链路以下的指令不再校验。 不再为任何指令设置 Changed 。 当前寄存器注册表 IIIL1L2
  7. 第七步 由于 000e 的 insn_flags 还是 Changed。还需要校验指令 000e 指令 校验指令 000e: move-object v0, v4 0x00e - 0x0018 是位于 try catch 里面的指令。 try catch 里所有可能发生异常的指令。都会走到 catch 的处理逻辑中。 所以需要把进入该指令前的寄存器注册表状态转移到 0x001a 中。进入前的寄存器注册表保存在 saved_line_ 变量上。理论上 move-object 指令是不会发生异常的。 但是 api 22 存在的一个 bug 。 由于第六步的异常导致所有的指令都强制设置为会发生异常。 导致 art 错误的把一个未赋值的 saved_line_ 寄存器注册表赋值给 0x001a ,同时设置 0x001a 的 insn_flags 设置为 Changed 。 执行指令是否会发生异常查看 dex_instruction_list.h kThrow
  8. 第八步 检验 001a: return v1。 检验寄存器1 由于当前寄存器注册表未赋值为 xxxxx 校验失败。结束校验。抛出异常。

异常现场复现。

0x03 总结

Bug 如何出现 ?

这个 Bug 是一套组合拼凑起来的。

  1. 一个运行期异常。
  2. 紧跟一个 try catch 代码块。
  3. try catch 第一个指令运行不会发生异常。
  4. catch异常处理第一个指令是一个从寄存器读的操作。

如何解决这个 Bug ?

  1. 弃用 D8 使用 dx 来转化 Dex。 (历史的倒退)
  2. 弃用 release 模式的 D8 来生成 Dex(优化力度变小)
  3. 规避特定的排序。 (看天吃饭) 节点1 去除 isInMultiWindowMode 方法调用。 节点2 关闭强转。 节点3 处理异常。 节点3 return 非 0 。
  4. 对 D8 进行干预。 关闭 move-exception 指令的优化 MoveException.java

image.png

Bug 影响范围 ?

问题存在在 api 21-22 在 api 23 被修复。 修复的 commit 如下:

  1. saved_line_ 正确被赋值。 d7f8d059 diff
  2. have_pending_runtime_throw_failure_ 状态及时重置。 3ae8da0 diff

文内导航