首页 游戏 软件 资讯 排行榜 专题
首页
科技数码
R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃

R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃

热心网友
85
转载
2025-12-15

在Android开发中,即使是AGP、R8这样的最新工具链升级,也要保持足够的警惕。毕竟Android生态太过复杂,再加上开发者们千奇百怪的代码写法,不论多么完善的测试流程都无法规避这类特定场景的bug。

免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈

一、背景

二、复现问题

三、问题分析

1. ApiModel外联是什么?

2. 为什么会多生成一个new-instance指令?

3. R8是如何计算出API的版本?

4. 为什么try-catch也会导致该问题?

5. 为什么只升级AGP会导致R8功能出问题?

四、解决方案

1. 禁用ApiModel

2. 最新修复

3. 自行修复

4. 业务改造(推荐)

五、总结

一、背景

R8作为谷歌最新的编译优化工具,在编译阶段会对字节码进行大规模修改,以追求包体优化和性能提升。但是Android应用开发者数量太过庞大,无论测试流程多么完善,终究难以避免在一些特定场景下出现问题。

近期我们在升级项目的AGP,遇到了一个指向系统SurfaceTexture类的native崩溃问题。经反编译分析发现问题最终指向了smali字节码中多余的一行new-instance指令。

图片图片

图片图片

该指令创建了一个SurfaceTexture对象,但是并未调用其方法,这意味着构造方法没有执行,但是这个类重写了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); //... }}

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); }}

我们根据这个生成类的类名可以知道是R8中ApiModelOutline功能生成了这个类。

图片图片

我们进到R8工程中检索下相关的关键字,再加上demo多次尝试,可以确认满足以下条件能够必现该问题:

使用了高于当前minSdkVersion的系统函数/变量(仅限系统类,自己写的无效)用synchronized或者try语句块包裹了该调用,或者给该函数传参时有任何计算行为(除了传局部变量)。例如:new SurfaceTexture( getParmas() )new SurfaceTexture( if(enable) 1 : 2)new SurfaceTexture ( (boolean) enable )

三、问题分析

在确认复现条件之后,我们带着几个问题来逐个分析。

ApiModel外联是什么?

R8中的优化大多数跟包体优化有关,代码外联也是其中一种,但是外联的前提是代码重复的次数满足一定阈值,但是ApiModel会对所有调用了高版本系统API的代码做外联,包括只调用一次的场景。

ApiModel并非为了包体优化,我们通过R8工程的issueTracker(https://issuetracker.google.com/issues/333477035)检索到了相关的信息:

图片图片

译:AGP新增的ApiModel功能是为了防止在低版本设备上不可能执行的代码引起类验证错误,从而降低App启动耗时。

从这篇介绍ART虚拟机类验证的文档(https://chromium.googlesource.com/chromium/src/+/HEAD/build/android/docs/class_verification_failures.md#chromium_s-solution)就能够理解上面这句话的含义:

ART虚拟机会在APK安装之后立刻执行 AOT class verification,即对dex文件中所有的类进行验证,如果验证成功则后续运行时将不需要再进行验证,反之若失败,则该class会被ART打上RetryVerificationAtRuntime的标记,后续运行时还得重新执行类验证。

同时这些失败的类也将无法被dex2oat优化成oat格式的优化字节码(oat字节码的加载和执行速度更快)。

图片图片

如果是在MainActivity,启动任务中使用了这些高版本API,那么在低版本设备App启动时就必须额外执行一次类验证(比较耗时,有的类能到8mshttps://issues.chromium.org/issues/40574431),而ApiModel外联则是相当于将这些肯定验证失败的函数的调用单独抽到一个生成类中,这样运行时就能将类验证失败问题彻底隔离在生成类中,从而规避运行时的类验证耗时。

//安装apk后验证失败,运行时验证失败,但是能正常执行class MainActivity{ void onCreate(){ if(android.sdk > 26){ new SurfaceTexture(false); } }}

ApiModel后

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

为什么会多生成一个new-instance指令?

介绍完ApiModel之后,我们已经知道了为什么方法的调用被替换成了一个生成函数的调用,接下来我们再分析下导致崩溃的罪魁祸首 new-instance 指令是如何出现的。

我们先来了解下java文件在编译过程中的格式转换过程,因为ApiModel是基于IRCode格式(R8自定义的格式)来做外联。

文件转换

javac

javac将java文件编译成class文件

值得一提的是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.(boolean)对整个方法中所有的指令从上往下进行遍历,第一次遍历主要是:

检索 方法调用的指令

判断该方法的androidApiLevel是否高于minSDK

生成包含完整构造函数指令的外联函数,并替换函数调用为外联函数调用。

执行完替换逻辑,就记录信息到map中,key是对应的new-instance指令,value是前一步中替换的新指令。

经过这一步,字节码会变成这样:

图片图片

具体替换逻辑如下(可以参考注释理解):

图片图片

第二次遍历则是对new-instance指令的处理:

找到new-instance指令

查询map,确认方法已完成替换

根据canSkipClInit方法返回的结果分为两种场景:

无类初始化逻辑:直接移除new-instance指令,不影响原代码的语义。

图片图片

有类初始化逻辑:生成外联函数,只包含该new-instance指令,和前一次遍历一样进行指令替换。

图片图片

具体替换逻辑:

图片图片

问题重点就在于canSkipClInit这个函数的实现。

它会检查 new-intance指令和invoke 指令之间是否存在任何局部变量声明以外的指令,如果存在,他会认为这些指令是这个类初始化的逻辑,因此为了保留源代码的执行顺序,这种情况下就是需要额外执行一次new-instance指令来触发类初始化。

图片图片

但是实际上,如果在调用这个构造函数传参时执行了任何运算(和类加载无关),都会生成相关的指令插在中间,例如:

从作者留下的todo也能看出,后续准备扩展这个方法,实现对这些夹在中间的指令的判断,如果是对类初始化无影响的入参计算逻辑,则也将正常移除new-intance指令。

图片图片

值得一提的是,我们最终APK里 new-intance指令并没有被外联,这是因为SurfaceTexture这个类本身在安卓21之前的版本就已经存在,只是入参为bool类型的构造方法是在安卓26新增的,所以他其实是被外联之后又被内联回到了调用处,因此看起来像是没有被外联。

图片图片

小结

至此,我们就明白了多出来一个看似无用的new-intance指令,实际上是为了保全源代码的语义,触发类加载用的,但是作者没有考虑到这些被优化的类可能重写了finalize方法来释放一些本就不存在的资源。

而且不局限于调用native函数,只要是重写了finalize,并在里面访问一些在构造函数中初始化的成员变量,一样可能造成NPE等崩溃。

R8是如何计算出API的版本?

图片图片

R83.3版本开始,它编译时会下载一个.ser格式的数据库文件,里面记录了所有系统API、变量与安卓版本号的映射信息,在运行时通过行号和偏移量来寻找各自的版本号。

图片图片

为什么try-catch也会导致该问题?

前面解释了在构造函数入参中添加函数调用等写法导致的字节码异常原因,但是实际上这次我们遇到的崩溃场景是在sychronized里new了一个SurfaceTexture。

图片图片

前文中已经解释过,sychronized在编译成class后会生成try-catch语句块,这段代码改成用try-catch语句块包裹,一样会复现崩溃,因此我们跟踪try-catch在文件转换过程中对字节码的影响即可。

回到class文件转dex文件的阶段,我们发现try语句块中的每一行指令,都会在其后生成一条FALLTHROUGH指令。

dex格式:

图片图片

FALLTHROUGH是什么指令,他是做什么的?

FALLTHROUGH指令表示指令自然流转,没有实际含义,它主要是为了帮助优化器识别哪些指令是可达的。

例如下面这种写法,case1没有写break,这样会接着执行case2的代码:

switch (value) { case 1: System.out.println("One"); // 故意不写break case 2: System.out.println("Two"); break; case 3: System.out.println("Three"); break; }

其字节码如下:

正常有break的话,会对应一条GOTO 指令跳转到switch语句块最后一行,但是没写break的话,就会出现:

在12行执行 goto 13 跳转到13行的指令,这种指令毫无意义,且运行时会消耗性能,因此可以替换成FALLTHROUGH指令,这样最终在生成dex文件时会被移除掉,从而避免浪费性能。

public static void switchWithFallthrough(int); Code: stack=2, locals=1, args_size=1 // 加载参数 0: iload_0 // 检查case 1 1: iconst_1 2: if_icmpne 13 // 如果不等于1,跳转到case 2 5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 8: ldc #3 // String One 10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 12: goto 13 // case 2 (fallthrough目标) 13: iconst_2 14: if_icmpne 28 // 如果不等于2,跳转到case 3 17: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 20: ldc #5 // String Two 22: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 25: goto 40 // 跳转到switch结束 // case 3 28: iconst_3 29: if_icmpne 40 // 如果不等于3,跳转到结束 32: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 35: ldc #6 // String Three 37: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V // switch结束 40: return

既然没用为什么还要加这个指令?

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字节码。

为什么只升级AGP会导致R8功能出问题?

我们在数个版本之前就已经单独升级了R8,正好涵盖了ApiModel这个变更,但是直到近期才升级了AGP。

可以看到从AGP7.3-beta版本开始,才默认打开ApiModel功能,这就解释了为什么升级AGP之后才出现此崩溃。

图片

四、解决方案

禁用ApiModel

ApiModel通过牺牲些微包体,换来启动阶段类验证耗时,但是从他覆盖的类范围来看,对启动速度的收益微乎其微,因此可以直接通过配置开关关闭整个功能。

System.setProperty("com.android.tools.r8.disableApiModeling", "1")

虽说这是个实验中的功能,且逻辑相对独立,但是考虑到后续还有内联优化等操作,贸然关闭整个功能无法评估影响面,潜在的稳定性风险较高。

最新修复

该问题反馈给R8团队后,最新提供了临时规避的方案,即确保高版本API在单独的函数中调用。

https://issuetracker.google.com/issues/441137561

图片图片

随后不久就提了MR针对SurfaceTexture这个类禁用了ApiModel,并未彻底解决此问题。https://r8-review.googlesource.com/c/r8/+/109044

图片图片

最新的修复方案比较权威,且影响面较小,但是并未彻底解决问题。

自行修复

如果要修复此问题,关键是要将多余的new-instance指令替换成一个合适的触发类加载的指令,根据java最新文档里的介绍,只有new对象,访问静态的成员变量或者函数的指令才能安全的触发类加载,比较理想的方案是改成访问静态变量,但是很多类并没有静态变量,比如SurfaceTexture就没有。

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.5

图片

因此我们可以考虑结合getStatic指令和扫描finalize的方式来解决该问题:

图片图片

虽说可以通过打印日志来约束此改动的影响面,但毕竟要自行修改并编译R8的jar包,且需要自行长期维护,整体影响面还是偏大,对稳定性要求高的App不建议采用该方案。

业务改造(推荐)

在前文中提到的外联函数生成处打印日志,即可感知到工程中有哪些类受ApiModel影响,如果数量不多,分别让业务改造其相关的写法,确保传参时是局部变量且无try-catch/synchronized语句块即可。

图片

考虑到App整体的稳定性,最终我们采用了业务改造的方式绕过了此问题,并在R8异常代码处添加了日志告警来预防后续增量问题,并仿照最新MR中的写法补充了类的黑名单,用于应对无法编辑的三方库引入此问题的场景。

五、总结

在Android开发中,即使是AGP、R8这样的最新工具链升级,也要保持足够的警惕。毕竟Android生态太过复杂,再加上开发者们千奇百怪的代码写法,不论多么完善的测试流程都无法规避这类特定场景的bug。

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

来源:https://www.51cto.com/article/824338.html
免责声明: 游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

相关攻略

佳能将于下周推出全画幅 V 系列无反相机支持12-bit RAW视频拍摄
科技数码
佳能将于下周推出全画幅 V 系列无反相机支持12-bit RAW视频拍摄

佳能新品前瞻:全画幅Vlog相机或将于下周登场 近日,影像圈内传来新消息。据知名爆料网站Canon Rumors称,佳能计划在下周推出一款全新的全画幅V系列无反相机。这并非空xue来风,其意图相当明确:与现有的APS-C画幅机型EOS R50 V形成高低搭配,进一步完善其在视频创作领域的布局。 那么

热心网友
04.22
R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃
科技数码
R8疑难杂症分析实战:外联优化设计缺陷引起的崩溃

在Android开发中,即使是AGP、R8这样的最新工具链升级,也要保持足够的警惕。毕竟Android生态太过复杂,再加上开发者们千奇百怪的代码写法,不论多么完善的测试流程都无法规避这类特定场景的b

热心网友
12.15

最新APP

宝宝过生日
宝宝过生日
应用辅助 04-07
台球世界
台球世界
体育竞技 04-07
解绳子
解绳子
休闲益智 04-07
骑兵冲突
骑兵冲突
棋牌策略 04-07
三国真龙传
三国真龙传
角色扮演 04-07

热门推荐

币安操作流程图解:从注册到交易的全流程指南
web3.0
币安操作流程图解:从注册到交易的全流程指南

本文旨在为新用户提供一份清晰的币安平台操作指引。内容涵盖了从账户注册、资金充值到资产划转、交易下单及记录查询的全流程。指南以逻辑顺序展开,详细说明了每个步骤的关键操作与注意事项,帮助用户快速熟悉平台基本功能,安全高效地开始数字资产交易。

热心网友
05.10
币安限额详解:认证等级、支付方式与风控规则全解析
web3.0
币安限额详解:认证等级、支付方式与风控规则全解析

本文解释了比安平台限额的成因,主要源于其多层次的风控体系。文章从用户认证等级入手,分析了不同KYC级别对应的权限与限额差异,随后探讨了支付方式对交易限额的具体影响,最后详细解读了平台动态风控规则的核心逻辑,帮助用户理解并适应平台的安全管理机制。

热心网友
05.10
币安新手入门指南:官网下载注册安全全流程避坑教程
web3.0
币安新手入门指南:官网下载注册安全全流程避坑教程

对于初次接触加密货币交易的新手而言,Binance平台的操作入门环节往往隐藏着诸多细节。从官网真伪辨别、客户端下载渠道选择,到注册流程的合规性、安全设置的完备性,每一步都至关重要。本文旨在梳理这些初始步骤中的关键点与常见误区,帮助用户建立安全、顺畅的起点,避免因基础操作失误导致后续困扰或风险。

热心网友
05.10
币安市价单与限价单详解:新手如何选择更省心省钱的交易方式
web3.0
币安市价单与限价单详解:新手如何选择更省心省钱的交易方式

市价单操作简单,能快速成交,但价格不可控,在波动剧烈的市场中可能产生滑点,导致实际成交价偏离预期。限价单允许设定具体价格,能控制成本,但可能无法立即成交。对于新手而言,理解两种订单的核心差异至关重要,需根据市场状况、交易目标和个人风险承受能力灵活选择,避免盲目追求便捷而忽视潜在风险。

热心网友
05.10
币安订单中心详解:如何查看未成交、已成交与历史记录
web3.0
币安订单中心详解:如何查看未成交、已成交与历史记录

币安订单中心是交易者管理持仓的核心界面,主要包含未成交订单、已成交订单和历史记录三大板块。未成交订单显示当前挂单状态,便于实时监控和调整策略;已成交订单提供即时交易确认与成本分析;历史记录则用于复盘长期交易表现。理解各部分功能,能有效提升交易决策效率和资金管理水平。

热心网友
05.10