AndroidTV应用更新困难,一方面受限于各设备厂商的规则,应用更新策略比较慢,另一方面,电视用户主动更新意愿较低。因此,插件热更新成为AndroidTV端有效更新应用业务能力的必要技术手段。采用插件热更新技术,无需安装新版apk,即可自动更新整个应用的业务能力,不受系统影响。自身的局限性不需要用户选择,大大提高了新版本的覆盖率。插件技术本质上是对Android系统私有能力的深度挖掘,需要适配Android系统的各个版本。不仅低版本低性能设备要流畅运行,高版本系统也可以使用插件升级。随着电视端高版本系统占比逐渐增加,适配高版本系统成为外挂技术的一大挑战。为了支持低性能设备首次安装的流畅运行,我们的插件框架将插件加载功能和TV业务逻辑放在同一个dex中,但是这种架构在高版本系统上会造成inlinecrash.在保证低端设备性能和覆盖高端设备的前提下,inlinecrash是一个无法轻易绕过的问题。本文从inlinecrash的背景入手,然后结合TV端的外挂特点深入分析原因,最后给出TV端的解决方案。01背景什么是内联Inline(内联)是一种编译优化方法。编译器自动将函数体的代码插入到这个函数的调用中,将函数调用展开成函数体的代码。这种优化消除了函数调用的开销,但增加了指令数。这里有一个Kotlin语言中内联的例子:Kotlin的内联使用inline关键字来定义内联,内联的选择完全留给用户。只要写了inline关键字,就一定会被内联。另外,Kotlin的内联触发时机是编译。当Kotlin被编译成字节码时,内联函数调用会被扩展。生成的字节码不会调用内联方法,而是直接将指令插入到调用处。Kotlin语言的内联可以说是最简单的一种内联,而本文要分析的inlinecrash的内联就是ART虚拟机在将字节码编译成机器指令时进行的内联。这种内联是完全自动的,由ART虚拟机自己决定,没有语言级别的机制去触发,而且根据不同版本的ART虚拟机,内联的规则和细节可能不同。它在运行时触发,即在Android设备运行应用程序时触发,而不是在开发期间触发。具体来说,它发生在JIT和AOT过程中。inlinecrash的场景1.特征和条件inlinecrash是一种nativecrash,有清??晰的abort信息,容易识别,发生在以下场景:epgplugin(或hostplugin)必须在crash时运行,一个版本不可能发生。只有epg插件有这个问题。投屏、设置、运动等所有功能插件均无此问题。仅发生在Android9.0(P)系统上。触发概率较低,但基于大量用户具有一定的水平。所有版本的Kiwi都可以在APM中找到这种崩溃。APM上一直存在,只是没有具体统计9.0系统的崩溃率。这个内联崩溃问题不容易在本地重现。运行一个版本需要很长时间才能触发内联,然后运行插件版本时崩溃。这个inlinecrash是安卓插件领域的一个已知问题。Tinker有一个公开分享的解决方案,但并不适用于我们,后面会详细说明。2.崩溃站点的本机崩溃中止消息:entrypoint_utils-inl.h:94]内联方法解析越过dex文件边界:fromvoidcom.gala.video.plugincenter.download.downloader.DownloadManager$TaskHolder.progress(com.gala.video.module.plugincenter.bean.download.DownloadItem,long,long,long,b...崩溃栈:JIT,AOT,混合编译1,JITJIT(Just-In-Time),也称just-in-timecompilation.是一种Runtime优化技术,虚拟机在运行时将字节码编译成机器码,从而提高执行效率。JIT存在于普通的JVM上,Android的Dalvik和ART也实现了JIT。是一个独立的线程,负责运行JIT。2.AOTAOT(Ahead-Of-Time),也叫预编译,注意这个预编译是在Android设备上运行的,不是我们开发过程中编译的。所以-所谓预编译是指在安装时进行全量编译转换字节码转化为机器码,从而提高运行速度。但是由于AOT全编译会生成一个很大的二进制文件,占用空间比较大,安装过程也比较慢,尤其是ROM升级后,所有APP都要重新经过AOT,会导致漫长的等待。3、混合编译由于AOT存在占用空间大、安装时间长的缺点,从AndroidN(7.0)开始,ART引入了混合编译模式。混合编译就是按照一定的规则在“解释执行”、“JIT”和“AOT”三种代码执行模式之间切换,以平衡运行效率、内存占用和CPU功耗。应用安装时不进行AOT编译,直接以解释方式执行,达到快速启动的效果。分析应用程序运行时的运行代码和“热点代码”,进行JIT编译。同时存储哪些代码属于“热码”的记录,在设备空闲和充电时,使用AOT编译本配置中的“热码”。02原因分析AbortmessageAccordingtoabortmessage:entrypoint_utils-inl.h:94]Inlinedmethodresolutioncrossedcrosseddexfileboundary...我们找到entrypoint_utils-inl.h文件的第94行:这段代码和注释的大概意思就是如果要内联的函数和调用它的函数不在同一个dex中。当执行这个内联函数时,它会主动触发崩溃。如果一个应用程序没有使用任何插件技术,是不可能造成这个问题的。因为函数被内联的前提之一是调用它的函数和它在同一个dex中。这看似矛盾,但因为有了外挂技术,这种场景才有了可能。插件启动是Kiwi的插件架构:图中的一个版本是没??有宿主插件的版本,也就是用户安装我们的apk或者只是升级apk后启动的版本。插件版本是宿主插件下载安装的版本,重启后加载宿主插件。我们只需要考虑宿主插件,不需要考虑其他子插件,因为这些子插件始终是独立的dex,不可能与其他模块产生内联互调用。由于这个问题与dex文件有关,下面从dex的角度来描述plugin相关的架构。可以简单理解为一个版本只有一个dex文件,每个plugin多一个dex文件。host插件(也就是epg插件)也是一个额外的dex文件,在我们每次部署插件时上传的apk里面。从功能上看,插件升级后不会更新的部分称为host部分,epg插件的所有内容称为epg部分。一个版本运行时,epg部分和host部分属于同一个dex,所以host对epg的函数调用可能是内联的。插件升级后epg部分是新的dex,host还是原来的dex。从主机到epg的函数调用跨越两个索引。因此,插件升级后可能会产生跨dex的inline调用,从而引发crash。主机不会也不能直接调用epg。宿主会提供一些接口,接口方法中有一些回调类型的接口。epg实现了回调接口,然后host内部调用这些接口,就产生了host对epg的间接调用。从模块设计的角度来看,epg是一个业务模块,依赖于host部分的一些底层库,可以直接调用。Host部分不依赖业务,即不依赖epg,也没有直接调用,但是有回调。内联崩溃的调用都是从主机到epg方法的回调。回调示例为什么activecrash1、原因是一旦发生cross-dexinlinecall,意味着内联的机器码是“历史”代码,可能与另一个dex中的“新”字节码不匹配。相当于生成了错误的机器码,与实际的字节码不对应。这可能会导致各种不可预知的错误。当然,如果plugindex的内容和one版本的完全一样,理论上是没有问题的。plugindex修改的内容越多,越容易出现这个问题。从长期的代码演化来看,问题是不可避免的,出现多少问题是有概率的。9.0上的主动系统崩溃是Android系统的主动防御,用“自杀”来防止cross-dex内联可能导致的“不可预知”的程序行为。在9.0之前的版本中,Google可能没有发现这个cross-dex内联问题,然后在10.0版本中放宽了限制。可能意识到cross-dex内联是一个小概率事件,或者让玩dex的玩家自己负责。因此,9.0内联问题不仅仅是9.0上的显式崩溃问题。在除9.0以外的7.0以上的其他版本上,仍然可能存在隐藏的“不可预知”的程序行为,可能是崩溃,也可能是逻辑错误。关于异常。如果9.0的inlinecrash问题是通过防止内联来解决的,那么也可以顺便解决所有系统版本上cross-dex内联的潜在问题。2、“不可预知”的错误inline生成的机器码中,并不是整个调用链中的所有字节码都必然生成为机器码。例如,A.a()调用B.b(),而B.b()调用C.c()。一种可能的情况是:A.a()内联了B.b(),但是C.c()的调用没有被内联。这样的话,当C.c()在A.a()中执行时,会从机器码跳回虚拟机执行,然后还要经过类加载和方法解析(resolve)的过程:解析缓存(DexCache[]),根据缓存中的偏移指针,找到这个类,然后找到要执行的方法。注意找类需要两个值,一个是DexCache数组,一个是C类在这个数组中的偏移指针(下标)。这个DexCache数组是和dex文件相关联的,不同的dex文件有不同的DexCache数组。在编译后的机器码中,使用函数指针直接跳转到原生版本的FindClass,保证结果正确。也就是说运行插件版本时可以正确获取到插件dex的DexCache[]。但是,DexCache数组中的偏移量是机器码中“硬编码”的立即数,编译dex2oat后直接写在指令中。使用旧的DexCache数组中的偏移量去寻找新的DexCache数组中的Class,最终的结果不一定是正确的Class。在这个本机级别,不会抛出ClassCastException以防止进一步的错误,它只会静默执行直到出现问题。比如调用方法不对应导致崩溃,找不到静态变量返回null等等。03解决方案行业解决方案——TinkerTinker最终的解决方案是去掉ART环境下合成增量dex的逻辑,直接合成全量的NewDex。这样一来,除了loader类,NewDex中的所有方法都是统一使用的,就不怕有内联的方法了。——摘自《ART 下的方法内联策略及其对 Android 热修复方案的影响分析 》Tinker的解决方案相当于找一个切面,把不能互相调用的loader和其他Class分开,把所有的class分成两组。热修后,“OtherClass”部分完全被合成dex替代,“OtherClass”内部不存在内联问题,loader部分和“OtherClass”部分不相互调用,即,不可能内联。所以Tinker通过整体架构来规划Class,避免内联。我们的方案在不修改插件架构的情况下无法使用Tinker的方案。没有一个方面可以保证宿主和插件不会互相调用。比如我们的下载模块放在插件中心。既然无法通过dex分段来防止内联,那么我们只能选择其他方式来防止或防止内联。阻塞内联应该是阻塞在一个版本上,因为是在一个版本上发生内联导致的问题,也就是说要处理apk升级的apk,而不是plugin升级包apk。内联的目的是优化程序,确实可以提高应用程序的执行效率。没有必要,也不应该,而且很难在整个应用程序中完全禁止内联。上面说了,我们的inlinedirection是从host调用epg,epg实现了host提供的接口,被host调用。在APM上,当前捕获了两个调用的内联导致的崩溃。这样的调用次数不会很多。我们能否准确找到这些电话并进行处理?1.准确定位内联调用我们可以简单的手动查看宿主调用epg的代码,但是这种方法肯定是不完整的,因为我们能看到的代码只是一小部分,还有隐藏的jar包不能直接看到部分。如果将来代码发生变化。即使只是重构,也有可能将原来的非内联调用变成内联调用,所以人工查找分析是不靠谱的,必须要有自动查找的方法才能准确找到。例如,触发内联的条件之一是内联函数的字节码数不能超过最大值。如果重构简化了代码,那么字节码的数量可能会减少,并且永远不会被内联。可以内联。在编译过程中直接读取字节码,在字节码中搜索可能的内联调用语句,收集这些调用的信息,然后根据这些信息以一定的方式修改字节码,防止内联。我们的xpluin插件中已经实现了一些ASM脚手架来修改字节码,可以直接选择这些工具来读取和改写字节码。收集完所有可能被内联的调用语句后,就可以考虑使用哪种方法来防止内联了。2、防止内联的解决方法:必须满足以下条件才能触发内联:App不是Debug版本;被调用方法的类与调用者的类位于同一个Dex中;(注意,符合ClassN命名规则的类MultipleDexes应该视为同一个Dex)调用方法的字节码数不超过dex2oat通过--inline-max-code-units指定的值,6.x默认为100,7.x默认为32;调用的方法不包含try块;调用的方法不包含非法字节码;对于7.x版本,被调用的方法不能包含对接口方法的调用。(invoke-interfaceinstruction)——摘自《ART 下的方法内联策略及其对 Android 热修复方案的影响分析》虽然打破其中一个条件可以防止内联,但实际上可以利用的点并不多。比如我们不可能把在线包改成debug版本;我们不可能插入一些非法的字节码;我们更不可能增加字节码的数量。只有2和4可能对我们有用。对于“2.dexsplit”和“4.tryblock”,有如下两种方案:ClassLoadersplitscheme这种方案利用了这样一个潜规则,即使是同一个dex,不同ClassLoader加载的classes也不会包含在同一个索引中。由于ClassLoader的双亲委派模型规则,一个Class直接引用的其他Class直接走native层的搜索路径,而不是JavaClassLoader。因此,无约束的Class引用结构,使用多个ClassLoader会产生双向引用结构,增加ClassLoader的复杂度。考虑到自定义ClassLoader在宿主范围内,如果出现问题,宿主代码无法通过插件升级修复,存在风险,所以最终没有采用该方案。插入try-catch方案ClassLoader方案是以类为最小单位来处理函数内联,而插入try块的是针对函数。收集来电信息后,就很简单了。在epg中找到需要插入try块的函数,在函数中插入try块。搜索“可能的内联调用”。编译期算法使用AOP的方式,在编译期的gradleTransform阶段找到所有需要修改的方法,通过ASM修改字节码,在这些需要修改的方法中插入try语句。本题特点:输入规模比较大,一共2W+类。但是解决方案的数量很少,大约在100个以内(12.4版本处理了85个)。算法介绍:插入的字节码为什么要插入这么复杂的语句?只插入一个空的try-catch语句不行吗?不会,d8在java字节码到dex字节码的转换过程中会进行一些优化操作。如果try{}为空,则不会生成代码;如果是无意义的代码,比如只定义了一个局部变量inta=0;它也会被优化删除。因此,只能插入一些有副作用的简单代码。04结语本文介绍了一种适用于TV端的插件inlinecrash的解决方案:防止inlinecrash,通过寻找可能的inline调用点,在编译时自动插入字节码防止inline触发,适用于TV端的特性端插件框架不划分服务dex。目前该方法已经通过多个版本的验证解决了inlinecrash问题。
