查看原文
其他

安卓TV插件化9.0内联崩溃原因及解决方案

CTV技术产品团队 爱奇艺技术产品团队 2023-03-27

安卓 TV 端应用的更新比较困难,一方面是受限于各个设备厂商的规则,应用更新策略比较慢,另一方面是 TV 用户主动更新的意愿比较低。因此插件化热更新在安卓 TV 端就成为了有效更新应用业务能力的必要技术手段,使用插件化热更新技术能在不安装新版 apk 的前提下,自动地更新整个应用的业务能力,不受系统本身的限制,不需要用户选择,极大地提升新版本的覆盖率。


插件化技术本质上是深度挖掘安卓系统的私有能力,需要对各个版本的安卓系统进行适配。不但要保证低版本的低性能设备运行顺畅,也要保证高版本的系统是可以使用插件化升级的。随着 TV 端高版本系统的比例逐渐增大,对高版本系统的适配成为了插件化技术的主要挑战。为支持低性能设备的首次安装运行顺畅,我们的插件化框架将插件加载功能与 TV 业务逻辑放在了同一个 dex 中,但这样的架构在高版本系统上又会产生内联崩溃的问题,在既要保证低端设备性能的同时,又要覆盖到高版本设备的前提下,内联崩溃是一个无法简单绕过的一个问题。


本文从内联崩溃的背景开始介绍,再结合 TV 端的插件化特点深入分析产生的原因,最后给出 TV 端的解决方案。


01

   背景


  •   什么是内联  
内联(inline)是一种编译优化方法。编译器自动地将函数体的代码插入到这个函数的调用处,将函数调用展开为函数体的代码。这种优化能消除函数调用的开销,但会使指令数量膨胀。

下面用 Kotlin 语言中的内联举例:

Kotlin 的内联使用 inline 关键字定义内联,将内联的选择权完全交给了使用者,只要写了 inline 关键字就一定会内联。另外,Kotlin 的内联触发时机是编译,在 Kotlin 编译为字节码的时候就会将 inline 函数调用展开,生成的字节码不会调用内联方法,而是直接将指令插入到调用处。

Kotlin 语言的内联可以说是最简单的一种内联,而本文要分析的内联崩溃的内联是 ART 虚拟机在将字节码编译为机器指令时进行的内联。这种内联是完全自动的,由 ART 虚拟机自己决定,没有语言层面的机制去触发它,而且根据不同版本的 ART 虚拟机,内联的规则和细节都可能不同。它的触发时机是运行时,也就是发生在安卓设备运行应用时,而不是在开发期间。具体来说,是发生在 JIT 和 AOT 的过程中的。

  •   内联崩溃的场景  
1、特点和条件
内联崩溃是一种 native 崩溃,有明确的 abort message,容易辨认,发生在以下场景:
  • 崩溃发生时一定在运行 epg 插件(或者叫宿主插件),one 版本时不可能发生。
  • 只有 epg 插件有这个问题。投屏、设置、体育等所有功能插件没有这个问题。
  • 只发生在 Android 9.0(P) 系统上。

触发的概率较低,但在大量用户的基础上有一定量级,奇异果的各个版本都能在 APM 中搜到这种崩溃。在 APM 上一直存在,但没有针对性地统计过具体在 9.0 系统的崩溃率。

这个内联崩溃问题本地不容易复现,需要长时间运行 one 版本才有可能触发内联,进而在运行插件版时发生崩溃。

这个内联崩溃是安卓插件化领域已知的问题,tinker 有公开分享的解决方案,但对于我们并不适用,后文会详细说明。

2、崩溃现场
Native 崩溃 abort message:

entrypoint_utils-inl.h:94] Inlined method resolution crossed dex file boundary: from void com.gala.video.plugincenter.download.downloader.DownloadManager$TaskHolder.progress(com.gala.video.module.plugincenter.bean.download.DownloadItem, long, long, long, b...
崩溃栈:


  •   JIT、AOT、混合编译  
1、JIT
JIT(Just-In-Time),也称为即时编译。是一种运行时优化技术,虚拟机在运行时将字节码编译为机器码,从而提高执行效率。在普通的 JVM 上就有 JIT 的存在,安卓的 Dalvik 与 ART 也都实现了 JIT。JIT 是运行在 app 的进程中的,有一个独立的线程负责运行 JIT。

2、AOT
AOT(Ahead-Of-Time),也称为预编译。注意这个预编译是运行在安卓设备中的,不是我们开发过程中的编译。所谓预编译,是指在安装的时候进行一次全量编译,将字节码转换为机器码,从而提高运行速度。但是 AOT 由于进行了全量编译,会生成很大的二进制文件,占用更多的空间,并且安装过程比较慢,尤其是ROM升级后,所有 APP 都要重来一遍AOT,会陷入漫长的等待。

3、混合编译
由于 AOT 有占用空间大和安装时间长的缺点,从 Android N(7.0) 开始,ART 中引入了混合编译的模式。混合编译即将“解释执行”、“JIT”与“AOT”这三种代码执行模式按照一定的规则来切换,平衡运行效率、内存使用与CPU耗电。在应用安装时不做 AOT 编译,直接以解释方式执行,达到快速启动的效果。在应用运行时分析运行过的代码以及“热代码”,进行 JIT 编译。同时将哪些代码属于“热代码”的记录存储下来,等设备空闲与充电时,使用 AOT 编译这份配置中的“热代码”。



02

   原因分析


  •   abort message  
根据 abort message:
entrypoint_utils-inl.h:94] Inlined method resolution crossed dex file boundary…

我们找到 entrypoint_utils-inl.h 文件的 94 行:


这段代码和注释的大概意思是,如果被内联的函数与调用它的函数不在同一个 dex 中,在执行这个内联函数的时候,就会主动触发崩溃。

如果一个应用没有使用任何插件技术,那么是不可能产生这个问题的。因为一个函数被内联的前提条件之一就是调用它的函数与它在同一个 dex 中。这看似是一个矛盾,但因为有了插件化技术,就让这个场景有可能出现了。

  •   插件化  
先从奇异果的插件化架构说起:

图中 one 版本就是没使用宿主插件的版本,用户刚安装好我们的 apk 或者刚升级 apk 后启动就是这个版本。插件版本是下载并安装了宿主插件,并且重启后加载了宿主插件的版本。

我们只需考虑宿主插件,而不考虑其他的子插件,因为这些子插件始终是独立的 dex,不可能与其他模块的产生内联的相互调用。

由于这个问题与 dex 文件有关,所以下面从 dex 的角度说明插件相关架构。


可以简单地理解 one 版本只有一个dex 文件,而每一个插件都是一个额外的 dex 文件。宿主插件(也就是epg插件)也是额外的一个 dex 文件,这个 dex 文件就在我们每次部署插件时上传的 apk 内。

从功能的角度理解,将插件升级后不会更新的部分称为 host 部分,epg 插件的所有内容称为 epg 部分。

在 one 版本运行时,epg 部分与 host 部分属于同一个 dex,所以从 host 调用到 epg 的函数调用,是可能被内联的。经过插件升级,epg 部分是新的 dex,host 还是原来的 dex,从 host 调用到 epg 的函数调用就跨越了两个 dex。因此,在插件升级后可能会产生跨越 dex 的内联调用,也就触发了崩溃。

host 不会也不能直接调用 epg。host 会提供一些接口,接口的方法中有一些回调类型接口,epg 实现了回调接口,然后这些接口被 host 内部调用,就发生了 host 到 epg 的间接调用。

从模块设计角度看,epg 属于业务模块,依赖一些属于 host 部分的底层库,可直接调用。Host 部分不依赖业务,也就是不依赖 epg,没有直接调用,但有回调。出现内联崩溃的调用都是从 host 回调到 epg 的方法。

回调示例

  •   为什么主动崩溃  
1、原因
因为跨 dex 内联的调用一旦出现,就意味着被内联的机器码是“历史”代码,可能与“新”的另一个 dex 中的字节码不匹配。相当于生成了错误的机器码,与实际的字节码对应不上。这就可能产生各种无法预测的错误。

当然,如果插件 dex 与 one 版本的 dex 内容一模一样,理论上是不会有问题的。插件 dex 修改的内容越多,就越可能出现这个问题。从长期的代码演变角度来说,出现问题是必然的,出现了多少问题是概率性的。

在 9.0 上系统主动崩溃是安卓系统的一种主动防御,用“自杀”来防止发生跨 dex 内联可能导致的“无法预测”的程序行为。在早于 9.0 的版本上谷歌可能还没有发现这种跨 dex 内联的问题,然后在 10.0 的版本又放宽了限制,可能认识到了跨 dex 内联是低概率事件,还是让玩弄 dex 的玩家自己负责吧。

所以,9.0 inline 问题不只是在 9.0 上的显性崩溃问题,在 7.0 以上除了 9.0 以外的其他版本上,仍然可能会有隐性的“无法预测”的程序行为,可能是 crash,也可能是逻辑上的异常。如果通过阻止内联的方式解决了 9.0 inline 崩溃问题,也可以顺带解决跨 dex 内联在所有系统版本上的潜在问题。

2、“无法预测”的错误
在内联生成的机器码中,并不一定将整个调用链上的所有字节码都生成了机器码。比如A.a() 调用 B.b(),B.b() 调用 C.c() 。一种可能的情况是:A.a() 内联了 B.b(),但对 C.c() 的调用没有内联。

这种情况下,在 A.a() 中执行C.c() 的时候会从机器码跳回虚拟机执行 ,然后就得走类的加载和方法的解析(resolve)的流程:找到 解析缓存(DexCache[]),并根据缓存中的偏移指针,找到这个类,进而找到方法去执行。注意需要两个值才能找到类,一个是 DexCache 数组,另一个是这个数组中的类 C 的偏移指针(下标)。

这个 DexCache 数组是与dex 文件相关联的,不同的 dex 文件 DexCache 数组也不一样。在编译生成的机器码中,使用的是一个函数指针直接跳到了 native 版本的 FindClass,得到的结果是保证正确的。也就是说在运行插件版时能正确获取到插件 dex 的 DexCache[]。

然而 DexCache 数组中的偏移量却是“写死”在机器码中的立即数,也就是dex2oat 编译后直接写在指令中的。用旧 DexCache 数组中的偏移量,在新 DexCache 数组中查找 Class,最后得到的不一定是正确的 Class。在这个 native 的层次上,不会有 ClassCastException 抛出来阻止进一步发生错误,只会默默执行直到出现问题。比如调用方法对应不上导致崩溃、static 变量找不到返回 null 等等。

03

   解决方案


  •   业界方案——Tinker  

Tinker 最终采用的应对方案是去掉 ART 环境下的合成增量 dex 的逻辑,直接合成全量的 NewDex,这样除了 loader 类,所有方法统一都用了 NewDex 里的,也就不怕有方法被内联了。——摘自《ART 下的方法内联策略及其对 Android 热修复方案的影响分析 》

Tinker 的方案相当于找到了一个切面,将不可能互相调用的 loader 与其他 Class 切开,将所有类分成两个集合。热修复后,通过合成 dex 的方式,将“其他Class”部分完全替换掉,“其他Class” 的内部就没有了内联问题,并且 loader 部分与“其他 Class”部分是没有相互调用的,也就不可能内联。因此 Tinker 是通过整体架构的方式规划 Class 避免内联。

  •   我们的方案  
在不修改插件架构的前提下,无法使用 Tinker 的方案。没有一个切面可以保证宿主和插件不互相调用,例如我们的下载模块是放在插件中心内的。

既然无法通过 dex 分割的方式预防内联,那我们只能选择其他方式预防或阻止内联。阻止内联应该在 one 版本上阻止,因为是 one 版本上发生的内联引发的问题,也就是说要处理的是 apk 升级的 apk,而不是插件升级包 apk。

内联的目的是对程序的优化,而且也确实能提升应用的执行效率,我们没必要,也不应该,也很难在整个应用范围内全面地禁止内联。上文说过我们的内联方向是从 host 调用 epg,epg 实现了 host 提供的接口被 host 调用。在 APM 上,目前捕获到了两个调用的内联引发的崩溃,这种调用的数量不会很大,我们是否能够精确找到这些调用并加以处理呢?

1、精确定位内联调用
我们可以简单地手动检查 host 调用epg 的代码,但这种方式肯定是不完备的,因为我们能看到的代码只是一小部分,还有隐藏在 jar 包等无法直接看到的部分。如果未来代码发生了改变。即使只是重构,也有可能使原来不内联的调用变成内联的调用,因此手动查找和分析并不靠谱,必须有一种自动搜索方式来精确查找。

举个例子,触发内联的条件之一就是被内联的函数的字节码的数量不能超过一个最大值,如果重构简化了代码可能字节码数量就会减少,从不会被内联变成可能被内联。

在编译过程中直接读取字节码,在字节码中搜索可能发生内联的调用语句,收集这些调用的信息,再根据这些信息通过某种方式修改字节码,达到阻止内联的方式。

我们的 xpluin 插件中已经实现了一些 ASM 脚手架来修改字节码,可以直接选择这些工具来读取和改写字节码。

收集好所有的可能内联的调用语句后,就可以考虑使用哪种方式来阻止内联。

2、阻止内联的方案
以下条件必须都满足才能触发内联:
  1. App 不是 Debug 版本的;
  2. 被调用的方法所在的类与调用者所在的类位于同一个 Dex;(注意,符合 Class N 命名规则的多个 Dex 要看成同一个 Dex)
  3. 被调用的方法的字节码条数不超过 dex2oat 通过--inline-max-code-units指定的值,6.x 默认为 100,7.x 默认为 32;
  4. 被调用的方法不含 try 块;
  5. 被调用的方法不含非法字节码;
  6. 对于7.x版本,被调用方法还不能包含对接口方法的调用。(invoke-interface指令)
——摘自《ART 下的方法内联策略及其对 Android 热修复方案的影响分析》

虽然这些条件只要打破一个就可以阻止内联,但实际上可以利用的点并不多。比如,我们不可能将线上包改为 debug 版本;我们也不可能插入一些非法字节码;我们更不可能增加字节码数量。对我们可能有用的只有 2 和 4 了,针对 "2. dex 拆分" 和 "4. try块",有两种方案如下:

  • ClassLoader 拆分方案
这个方案是利用了这样一个隐藏规则,即使是同一个 dex,由不同的 ClassLoader 加载的类,也不算同一个 dex 内。

由于 ClassLoader 的双亲委派模型规则,一个 Class 直接引用的其他 Class,直接走 native 层的查找路径,不走 Java ClassLoader。因此不受约束的 Class 引用结构,使用多个 ClassLoader 会产生双向引用结构,徒增 ClassLoader 的复杂度。

考虑到自定义 ClassLoader 在 host 范围内,如果出问题,host 代码无法通过插件升级修复,风险较大,因此最终并没有采用这个方案。

  • 插入 try-catch 方案
ClassLoader 方案是以类为最小单位处理函数内联,而插入 try 块的是针对函数的。在收集好调用信息之后,就非常简单了,在 epg 中找到需要插入 try 块的函数,在函数中插入一个 try 块即可。

  •   搜索"可能内联的调用"编译时算法  
使用 AOP 的方式,在编译期 gradle Transform 阶段找到所有的需要修改的方法,通过 ASM 修改字节码,在这些要修改的方法中插入一条 try 语句。这个问题的特点:输入规模比较大,总共有 2W+ 个类。但解的数量很少,约 100 以内(12.4 版本处理了 85 个)。

算法简介:
插入的字节码

为什么要插入这么复杂的一条语句呢?直接插入空的 try-catch 语句不行么?

不行,在由 java 字节码转换为dex 字节码的阶段,d8 会进行一些优化操作。如果 try {} 中是空的,那么不会生成任何代码;如果是无意义的代码,比如只定义了一个局部变量 int a = 0; 也会被优化删除掉。因此只能插入一些简单的有副作用的代码。

04

   结论


本文介绍了一种适合 TV 端的解决插件化内联崩溃的方法:通过查找可能的内联调用点,在编译期自动插入字节码阻止内联的触发的方式来预防内联崩溃问题,适用于TV 端插件化框架不分割业务 dex 的特点。目前该方法通过若干版本的验证已解决内联崩溃问题。


也许你还想看
会员服务优雅上下线实践
爱奇艺海外版HTTPS效率提升的探索和实践
爱奇艺海外App的网络优化实践

 关注我们,更多精彩内容陪伴你!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存