原创 永乐 2025-09-01 18:31 上海
这次的ApiModel外联优化问题就是一个很好的例子——它只在特定条件下才会暴露,但一旦出现就是必现的native崩溃。所以对于这种影响面无法评估的重大升级,还是需要经过足够长时间的独立灰度验证,才能合入主干分支。

💡 **ApiModel外联机制探究**:R8的“ApiModel外联”功能旨在解决低版本设备上可能出现的类验证错误,通过将可能引发问题的代码(特别是调用高版本API的场景)抽离到单独的生成类中,从而规避运行时类验证的耗时。然而,当这些被外联的代码(如`new SurfaceTexture()`)所在的对象重写了`finalize`方法时,若`new-instance`指令在构造函数`init`方法未被正确调用前就生成,可能导致GC回收时触发Native崩溃。
⚙️ **指令生成与问题根源**:文章详细阐述了R8如何通过两次遍历来处理代码外联。第一次遍历会将高版本API的构造函数调用替换为外联函数调用。第二次遍历则处理`new-instance`指令,若检测到`new-instance`和`invoke init`之间存在除局部变量声明外的其他指令(如参数计算或`try-catch`引入的`FALLTHROUGH`指令),R8会保留`new-instance`指令以维持代码语义,但这恰恰是引发Native崩溃的根源。
🔄 **解决方案与实践建议**:面对此问题,文章提供了多种解决方案:直接禁用ApiModel功能(需谨慎评估影响)、等待官方修复(目前仅针对部分类)、自行修改R8源码(复杂且维护成本高)以及业务改造(推荐)。业务改造通过调整代码写法,避免在调用高版本API时使用`try-catch`、`synchronized`或进行参数计算,从而绕过R8的优化逻辑。同时,建议在升级重大工具链时进行长时间的独立灰度验证,以规避潜在风险。
原创 永乐 2025-09-01 18:31 上海
这次的ApiModel外联优化问题就是一个很好的例子——它只在特定条件下才会暴露,但一旦出现就是必现的native崩溃。所以对于这种影响面无法评估的重大升级,还是需要经过足够长时间的独立灰度验证,才能合入主干分支。
R8作为谷歌官方的编译优化工具,在编译阶段会对字节码进行大规模修改,以追求包体优化和性能提升。但是Android应用开发者数量太过庞大,无论测试流程多么完善,终究难以避免在一些特定场景下出现问题。
近期我们在升级项目的AGP,遇到了一个指向系统SurfaceTexture类的native崩溃问题。经反编译分析发现问题最终指向了smali字节码中多余的一行new-instance指令。
该指令创建了一个SurfaceTexture对象,但是并未调用其<init>方法,这意味着构造方法没有执行,但是这个类重写了finalize方法,后续被gc回收时会调用其中的nativeFinalize这个JNI方法,最终在native层执行析构函数时触发了SIGNALL 11的内存访问错误.我们注意到多出来的new-instance指令下面紧接着的是对a0.e 类中的静态方法 i() 的调用,其内部实现就是SurfaceTexture的构造方法。这是典型的代码外联操作,即一段相同的代码在工程中多次出现,则会被抽出来单独作为一个静态函数,原先的调用点则替换成该函数的调用,这样可以减小代码体积,是常见的编码思路。
例如:class Activity{void onCreate(){// ...String a = xx.xxx();String b = xx.xxx();Log.e("log",a+b);//...}void onReusme(){// ...String a = xx.xxx();String b = xx.xxx();Log.e("log",a+b);//...}}
我们根据这个生成类的类名可以知道是R8中ApiModelOutline功能生成了这个类。我们进到R8工程中检索下相关的关键字,再加上demo多次尝试,可以确认满足以下条件能够必现该问题:使用了高于当前minSdkVersion的系统函数/变量(仅限系统类,自己写的无效)用synchronized或者try语句块包裹了该调用,或者给该函数传参时有任何计算行为(除了传局部变量)。例如:new SurfaceTexture( getParmas() )new SurfaceTexture( if(enable) 1 : 2)new SurfaceTexture ( (boolean) enable )class Activity{void onCreate(){// ...Activity$Outline.log();//...}void onReusme(){// ...Activity$Outline.log();//...}}//外联生成的类class Activity$Outline{public static void log(){String a = xx.xxx();String b = xx.xxx();Log.e("log",a+b);}}
ApiModel后//安装apk后验证失败,运行时验证失败,但是能正常执行class MainActivity{void onCreate(){if(android.sdk > 26){new SurfaceTexture(false);}}}
class MainActivity{void onCreate(){if(android.sdk > 26){a0.b(); //这样类验证就能成功}}}//生成的外联类,类验证会失败,但是运行时不可能走到,不影响class a0{public static void b(){new SurfaceTexture(false);}}
更多关于ApiModel的详细介绍,见这篇文章:https://medium.com/androiddevelopers/mitigating-soft-verification-issues-in-r8-and-d8-7e9e06827dfd
值得一提的是sychronized语句块在javac编译之后会为其内部代码生成try-catch,这是为了确保在语句块抛异常时能够正常释放锁,因此和问题有关的是try-catch语句块,和synchronized无关。
D8目前R8已经整合D8,因此输入class文件之后就会先通过D8转为dex格式,并持有在内存中。转换之后的指令基本和class字节码基本类似。IRcode为了做进一步的优化,会将dex格式的代码转化成R8自定义的IRcode格式,其特点是代码分块。案例:问题根因在R8工程里检索ApiModel关键字,最终定位到针对构造函数生成外联函数和指令替换的代码:InstanceInitializerOutliner->rewriteCode执行此方法之前的指令如下:
java:new SurfaceTexture(false);
dex:: -1: NewInstance v1 <- android.graphics.SurfaceTexture: -1: ConstNumber v2(0) <- 0 (INT): -1: Invoke-Direct v1, v2(0); method: void android.graphics.SurfaceTexture.<init>(boolean)
对整个方法中所有的指令从上往下进行遍历,第一次遍历主要是:
检索 <init>方法调用的指令
判断该方法的androidApiLevel是否高于minSDK
生成包含完整构造函数指令的外联函数,并替换<init>函数调用为外联函数调用。
执行完替换逻辑,就记录信息到map中,key是<init>对应的new-instance指令,value是前一步中替换的新指令。
经过这一步,字节码会变成这样:
具体替换逻辑如下(可以参考注释理解):第二次遍历则是对new-instance指令的处理:找到new-instance指令
查询map,确认<init>方法已完成替换
根据canSkipClInit方法返回的结果分为两种场景:
无类初始化逻辑:直接移除new-instance指令,不影响原代码的语义。
| java写法 | new-intance和invoke <init>指令之间的指令 |
new SurfaceTexture( getParmas() ) | invoke-virtual v2 <-; method: void xx.xx.xx |
new SurfaceTexture( if(enable) 1 : 2) | StaticGet v3 <- ; field: boolean xxx.xxx.xx |
new SurfaceTexture ( (boolean) enable ) | : -1: CheckCast v5 <- v3; java.lang.Boolean : -1: Invoke-Virtual v6 <- v5; method: boolean java.lang.Boolean.booleanValue() |
FALLTHROUGH指令表示指令自然流转,没有实际含义,它主要是为了帮助优化器识别哪些指令是可达的。
例如下面这种写法,case1没有写break,这样会接着执行case2的代码:其字节码如下:正常有break的话,会对应一条GOTO 指令跳转到switch语句块最后一行,但是没写break的话,就会出现:在12行执行 goto 13 跳转到13行的指令,这种指令毫无意义,且运行时会消耗性能,因此可以替换成FALLTHROUGH指令,这样最终在生成dex文件时会被移除掉,从而避免浪费性能。switch (value) {case 1:System.out.println("One");// 故意不写breakcase 2:System.out.println("Two");break;case 3:System.out.println("Three");break;}
既然没用为什么还要加这个指令?class文件是通过Exception table来指定异常处理的指令范围,而dex文件则是通过为每一行可能产生throwable的指令后面添加FALLTHROUGH指令来实现try-catch。这里会把每一行可能崩溃的指令都链接到catch指令所在的block中,确保任意位置的崩溃都能正常走到catch中。问题根因在R8 4.0.26版本,IRCode翻译器新增了对FALLTHROUGH指令的处理,即新建一个block并生成一条GOTO指令指向新的block。根据前文的结论,GOTO指令一样会被认为是类初始化相关的逻辑,因此try-catch语句块一样会导致最终多出来一个new-instance字节码。public static void switchWithFallthrough(int);Code:stack=2, locals=1, args_size=1// 加载参数0: iload_0// 检查case 11: iconst_12: if_icmpne 13 // 如果不等于1,跳转到case 25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;8: ldc #3 // String One10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V12: goto 13// case 2 (fallthrough目标)13: iconst_214: if_icmpne 28 // 如果不等于2,跳转到case 317: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;20: ldc #5 // String Two22: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V25: goto 40 // 跳转到switch结束// case 328: iconst_329: if_icmpne 40 // 如果不等于3,跳转到结束32: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;35: ldc #6 // String Three37: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V// switch结束40: return
System.setProperty("com.android.tools.r8.disableApiModeling", "1")虽说这是个实验中的功能,且逻辑相对独立,但是考虑到后续还有内联优化等操作,贸然关闭整个功能无法评估影响面,潜在的稳定性风险较高。AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑