|
GEEK TALK
01 前言在上一篇文案中,咱们简要介绍了 Android 包体积优化的基本思路以及各优化项。本文咱们会重点讲述 Dex 体积优化中的行号优化,优化目的是在可追溯原始调试信息的前提下,尽可能减少 DebugInfo 体积。咱们参考了业界已有的行号优化方法(如支付宝、R8),采用将行号集改为pc集的方式,做到最大程度复用 DebugInfo,同期处理了重载办法行号区间重叠问题,并供给完整的原始行号 retrace 方法。如图1-1所示,为两个办法的 DebugInfo 可视化映射过程,咱们会将指令集与原始行号的映射关系导出为 mapping 文件,并上传给服务端做后续的 retrace处理。能够发掘,映射完成后两个办法的 DebugInfo 信息一致,即达到了可复用状态。图1-1 两个办法 DebugInfo 映射过程接下来将仔细讲述 DebugInfo 分析、现有方法对比、百度APP优化方法及收益 等内容。GEEK TALK
02 解构DebugInfo调试信息(DebugInfo)指的是应用于调试场景的字节码信息,重点包含源文件名、行号、局部变量、扩展调试信息等。行号优化便是去优化 DebugInfo 中包括的行号信息,以减少 DebugInfo 区域体积,从而达到减少字节码文件体积的目的。
丨2.1 Dex DebugInfo如图2-1所示,在Dex文件格式[2]中,DebugInfo 处在 data 区域,由一系列debug_info_item 构成。图2-1 Dex文件结构一般状况下,debug_info_item 与类办法一一对应,其在 Dex 中的引用关系如下图2-2所示。Dex 为块状结构,引用区域的位置均经过 x_off 偏移量确定。图2-2 class -> method -> debug_info引用关系debug_info_item结构如图2-3所示,重点由两部分形成:header 和一系列debug_event。header 中包括办法初始行号、办法参数数量、办法参数名三部分信息;除header 外的 debug_events 能够理解为一系列状态寄存器,记录pc指针与行号的偏移量。debug_info_item 本质上是一个状态机。图2-3 debug_info_item 结构常用的 debug_event 有以下几类:名字value参数描述DBG_END_SEQUENCE0x00没debug_info_item状态结束标识,不可修改DBG_ADVANCE_PC0x01pcDelta仅包括pc偏移值DBG_ADVANCE_LINE0x02lineDelta仅包括line偏移值Special Opcodes[0x0a,0xff]没可由value得到pc偏移值和line偏移值Special Opcodes value 与 pcDelta & lineDelta 的换算公式如下:DBG_FIRST_SPECIAL = 0x0a // the smallest special opcodeDBG_LINE_BASE = -4 // the smallest line number incrementDBG_LINE_RANGE = 15 // the number of line increments representedadjusted_opcode = opcode - DBG_FIRST_SPECIALline += DBG_LINE_BASE + (adjusted_opcode % DBG_LINE_RANGE)address += (adjusted_opcode / DBG_LINE_RANGE)
丨2.2 DebugInfo 运用场景DebugInfo 平常的运用场景是断点调试及堆栈定位(包含崩溃、ANR、内存分析等所有可输出办法堆栈的场景)。接下来以打印崩溃堆栈为例,系统怎样经过解析DebugInfo 输出反常定位。Throwable 对象初始化时会首要调用 nativeFillInStackTrace() 办法获取当前线程中 StackTrace,而 StackTrace 中存储的是 ArtMethod(ART虚拟机中办法对象)和对应pc值,无行号信息;真正打印堆栈时,经过调用 nativeGetStackTrace 办法将StackTrace 转化为 StackTraceElement[] ,StackTraceElement 会包括办法所属源文件与办法行号。如图2-4所示,反常堆栈末尾会表示办法源文件与行号。图2-4 反常堆栈StackTrace 转化为 StackTraceElement[] 的代码调用路径如下所示,即虚拟机将当前线程办法栈内容转化为图2-4中可读的堆栈信息的过程。// art/runtime/native/java_lang_Throwable.ccstatic jobjectArray Throwable_nativeGetStackTrace(JNIEnv* env, jclass, jobject javaStackState) { ... ScopedFastNativeObjectAccess soa(env); returnThread::InternalStackTraceToStackTraceElementArray(soa, javaStackState);// 将StackTrace转化为StackTraceElement[]}// art/runtime/thread.ccjobjectArray Thread::InternalStackTraceToStackTraceElementArray( constScopedObjectAccessAlreadyRunnable& soa, jobject internal, jobjectArray output_array, int* stack_depth) { ... // 遍历StackTrace for (uint32_t i = 0; i < static_cast<uint32_t>(depth); ++i) {ObjPtr<mirror::ObjectArray<mirror::Object>> decoded_traces = soa.Decode<mirror::Object>(internal)->AsObjectArray<mirror::Object>(); const ObjPtr<mirror:ointerArray> method_trace = ObjPtr<mirror:ointerArray>:ownCast(decoded_traces->Get(0)); // 从StackTrace中获取 ArtMethod与对应pcArtMethod* method = method_trace->GetElementPtrSize<ArtMethod*>(i, kRuntimePointerSize); uint32_t dex_pc = method_trace->GetElementPtrSize<uint32_t>(i + static_cast<uint32_t>(method_trace->GetLength()) / 2, kRuntimePointerSize); // 按照 ArtMethod与对应pc 创建 StackTraceElement对象 constObjPtr<mirror::StackTraceElement> obj = CreateStackTraceElement(soa, method, dex_pc); soa.Decode<mirror::ObjectArray<mirror::StackTraceElement>>(result)->Set<false>(static_cast<int32_t>(i), obj); } return result;}static ObjPtr<mirror::StackTraceElement> CreateStackTraceElement( const ScopedObjectAccessAlreadyRunnable& soa,ArtMethod* method, uint32_t dex_pc) REQUIRES_SHARED(Locks::mutator_lock_) { ... // 获取pc对应的代码行号 int32_t line_number; line_number = method->GetLineNumFromDexPC(dex_pc); ...}// ... art_method.h -> code_item_accessors.h// 当遍历debugInfo过程中,pc满足要求(大于等于StackTrace记录的pc)时,返回对应的行号inline boolCodeItemDebugInfoAccessor::GetLineNumForPc(const uint32_t address, uint32_t* line_num) const { return DecodeDebugPositionInfo([&](const DexFile:ositionInfo& entry) { if (entry.address_ > address) { return true; } *line_num = entry.line_; return entry.address_ == address; });}// code_item_accessors.h -> dex_file.h// 遍历dex中对应的debugInfoboolDexFile:ecodeDebugPositionInfo(const uint8_t* stream, const IndexToStringData& index_to_string_data, const DexDebugNewPosition& position_functor) { PositionInfo entry;entry.line_ = DecodeDebugInfoParameterNames(&stream, VoidFunctor()); for (;;) { uint8_t opcode = *stream++; switch (opcode) { case DBG_END_SEQUENCE: return true; // end of stream. case DBG_ADVANCE_PC: entry.address_ += DecodeUnsignedLeb128(&stream); break; case DBG_ADVANCE_LINE:entry.line_ += DecodeSignedLeb128(&stream); break; ... // 其他event类型处理,与局部变量、源文件关联 ... default: { int adjopcode = opcode - DBG_FIRST_SPECIAL; entry.address_ += adjopcode / DBG_LINE_RANGE;entry.line_ += DBG_LINE_BASE + (adjopcode % DBG_LINE_RANGE); break; } } }}从上面的代码中 GetLineNumForPc 办法能够看出,虚拟机经过指针寻找原始行号时会遍历对应的 debugInfo。因为咱们的方法中将 pcDelta 所有统一为1,遍历长度会比原先长,但因为遍历中的处理极为简单,因此几乎不会查找性能导致影响。GEEK TALK
03 现有优化方法
丨3.1 极限优化方法DebugInfo 做为运行没关信息是能够所有移除的。问题在于倘若直接移除 DebugInfo 的话,调试堆栈会没法供给准确的行号信息,图2-4 堆栈行号均会表示-1。倘若应用稳定性高、定位难度低,能够选取所有移除 DebugInfo。Java编译器、代码缩减混淆工具都供给了相应的选项用于不生成或移除class字节码中的 DebugInfo。如图3-1所示,在 Class 字节码文件[3]中,DebugInfo对应attributes区域中 SourceFile、SourceDebugExtension、LineNumberTable、LocalVariableTable 四项信息。图3-1 Class文件结构
编译选项-g:lines // 生成LineNumberTable-g:vars // 生成LineVariableTable-g:source // 生成SourceFile-g:none // 不生成任何debugInfo
Proguard规则[4]-keepattributes SourceFile // 保存SourceFile-keepattributes LineNumberTable // 保存LineNumberTable除此之外,亦能够在 transform 周期利用字节码操作工具移除 DebugInfo。字节已开源的 ByteX 字节码工具[5]中即运用了这种方法。
丨3.2 映射优化方法在实质状况中,应用会进行频繁地业务迭代与技术升级,高稳定性是必须连续守护的,因此咱们不会直接移除 DebugInfo,由于那会使问题的定位成本变得非常高。映射优化方法的基本规律是保存 debugInfo 区域,但让 Dex 中 method 与 debug_info_item 的1对1关系变为N对1的复用关系,debug_info_item 数量减少了,体积自然会减少。同期导出 debug_info_item 复用前后的映射文件,可据此还原崩溃堆栈。下文中提到的支付宝、R8 和百度APP 的行号优化均运用了映射优化方法。要做到 debug_info_item 复用,咱们首要必须确认 debug_info_item 的 equals 判断规律。若两个 debug_info_item 的构成部分均相同,则认为两者相等,就可复用。debug_info_item 的构成部分包含办法初始行号、办法参数、一系列 debug_events。因为咱们关心的堆栈信息中不包括办法参数,那样必须统一的就仅有初始行号和 debug_events。// debug_info_item 相等判断规律(伪代码)publicboolean equals(DebugInfoItem debugInfoItem) { return this.startLine == debugInfoItem.startLine && this.parameters.equals(debugInfoItem.parameters) && this.events.equals(debugInfoItem.events);}startLine 只是一个int值,赋值相同就可。debug_events 的 equals 规律亦与其内容关联,即 events 数量以及每一个 event 的类型与值。// debug_event 相等规律判断(伪代码)public boolean equals(DebugEventevent) { return this.type == event.type && this.value == event.value;}从以上的分析能够发掘,想要达成 debug_info_item 复用,必须掌控以下变量,使之尽可能保持相同:startLine、debug_events 数量、debug_event 类型、lineDelta、pcDelta(opcode不算在内,由于能够由lineDelta & pcDelta计算得到)。
重载办法行号区间重叠问题除了 startLine 外,其余四个变量取值是同步决定的,下文中会做仔细介绍。startLine 做为办法初始行号,是 lineDelta 的累加基数,看似能够固定赋值,例如所有办法都以1做为初始映射行号。但遇到重载办法时,倘若两个办法的映射后行号区间有重叠,咱们会没法确定映射后的行号应该还原至哪个办法。原由在于虚拟机解析出的堆栈中仅运用办法名做为办法独一标识,而非咱们一般认识的办法重载中 [办法名,参数类型,参数个数] 三者结合做为方法独一标识。举例如下:// 办法行号映射为:com.example.myapplication.MethodOverloadSample.test(): 1->21 2->22com.example.myapplication.MethodOverloadSample.test(String msg): 1->34 2->35...// 收集映射后办法堆栈:...at com.example.myapplication.MethodOverloadSample.test(MethodOverloadSample.java:2)...因为堆栈中仅包括办法名,咱们没法确定应该映射到行号22还是行号35。
丨3.3 支付宝行号优化方法支付宝介绍了两种行号优化方法。
方法一(1)编译时将 debugInfo 所有摘出来做为 debugInfo.dex,APK中再也不包括 debugInfo。(2)反常出现时,经过 hook Throwable,从其持有的 StackTrace 对象中解析得到的指令集行号并上传。(3)性能平台结合过程1中的debugInfo.dex,将指令集行号转化为原始行号。该方法原理是离线还原章节2.2中的流程,问题在于仅运用 Throwable 场景,且因为区别版本 JVM 的 StackTrace 对象结构区别,适配成本比较高。
方法二保存 N 个 debug_info_item,同期将其修改为办法指令集,即经过 debug_info_item 获取到的 lineNumber 实质上指的是令行号,而非代码行号。这种方法下变量 lineDelta == pcDelta,取值始终为1,由此 debug_event 亦就确定是 specail opcodes 类型;每一个 debug_info_item 中 debug_event 的数量亦能够按照实质状况人为设定,能够覆盖应用办法的指令数量就可。至此所有的变量都有了固定赋值,即 debug_info_item 做到了办法复用。百度APP 与支付宝APP 的行号优化方法在整体的行号复用策略上是类似的,都是经过让更加多的办法复用同一个 debug_info_item 来达到节省包体积的效果。百度App 行号优化方法在实现重载办法、R8 行号优化等行号完全可还原方面进行了更细化的思虑和设计。
丨3.4 R8行号优化方法[6]声明了 -keepattributes LineNumberTable后,R8不会移除行号信息,转而启用行号优化。其对 debug_info_item 的修改包含两处:startLine:startLine 默认为1。当遇到同名办法时,后一个办法的 startLine 为前一个办法优化后 endLine+1。原由如章节3.2 中说到的同名办法行号 retrace 问题。lineDelta:lineDelta 默认为1。这般修改后,一部分 debug_info_item 可复用,但因为 debug_events 数量以及 pcDelta 仍不可控,复用程度非常有限。R8 的行号优化映射结果如图3-2所示,其中一个办法可能对应一个或多个行号区间映射,其原由在于 lineDelta 强制为1,因此映射前后的行号区间 Delta 必要保持一致。图3-2 R8 行号映射除此之外,R8还利用 SourceDebugExtension 还原了 kotlin inline 办法的实质位置,如图3-3所示图3-3 R8还原 kotlin inline 行号映射GEEK TALK
04 百度APP Dex行号优化方法百度APP的行号优化对 startLine、pcDelta、lineDelta、debug_event 数量均进行了掌控,最后 debug_info_item 复用比例得到了极重提高。同期百度APP 联合内部性能平台,对线上收集到的崩溃、ANR 堆栈进行行号还原。流程如图4-1所示:图4-1 百度APP端到端的行号优化流程
丨4.1客户端行号优化
debug_info_item 变量掌控(1)startLine 默认值为100000。与R8默认值为1区别,选这么大的初始值是为了避免热修复、插件中存在同名办法时显现行号重叠,导致行号还原失败。理想的行号区间分布如下图所示。每成功分配一个行号区间后,咱们会立即初始化下一个行号区间的 next_startLine = ((this_startLine + this_inst_size) / default_gap + 1) * default_gap。当显现同名办法时,咱们会就现有的行号区间进行比对,next_startLine 是不是符合需求,倘若不符合还必须在叠加 default_gap(默认值为5000)。图4-2 理想行号区间(2)debug_event除了暗示初始结束的 debug_event 外,剩余所有都是 pcDelta=lineDelta=1的 special opcodes 类型。其中 debug_event 数量按照办法指令数量而定,取值为所属指令分区间的上限值。图4-3 指令数量区间与 debug_event 数量映射图4-4 映射后的 debug_events(3)pcDelta 首个 special opcodes 为0,其余为1。(4)lineDelta默认与 pcDelta 一致。即经过 debugInfo 获取代码行号,实质拿到的是映射后的指令行号。
行号映射生成的行号映射表格式如下所示:类名1: 办法描述符1: 映射后行号闭区间1 -> 原行号1 映射后行号闭区间2 -> 原行号2 办法描述符1: 映射后行号闭区间1 -> 原行号1 映射后行号闭区间2 -> 原行号2类名2: ...行号映射暗示例如下:com.baidu.searchbox.Application: void onCreate(android.os.Bundle): [1000-1050] -> 20 [1051-2000] -> 22 void onCreate(): [3000-3020] -> 30 [3021-3033] -> 31 void onStop(): [1000-1050] -> 50 [1051-2000] -> 55com.baidu.searchbox.MainActivity: void onResume(): [1000-1050] -> 100
兼容 R8 行号优化R8 对行号信息的处理有三种状况:移除、优化、保存。处理要求如图4-5 所示。图4-5 R8 处理行号规律其中 debug mode 参数由 AGP 掌控传入,日前相关参数是 buildType.isDebuggable。不外编译线上 release 包时是不会开启 isDebuggable 的,因此工程在启用了 R8 的状况下仅有行号移除与优化两种结果。此时咱们的行号优化工具处理的对象便是R8已然映射过一次的行号了。这儿的兼容做法有两种:(1)hook R8 任务,对R8 行号保存做自定义修改。这种办法工作量会比很强。(2)针对R8 的映射做 retrace。流程能够是[R8映射->百度APP行号优化映射](客户端) -> [百度APP行号retrace -> R8行号retrace](服务端),亦能够是[R8映射-> R8行号retrace ->百度APP行号优化映射](客户端) -> [百度APP行号retrace](服务端)。咱们日前采用的是后者。R8 行号映射内容与混淆一同输出在 mapping.txt 中,详细参考3.4章节。
工具运用最后行号优化工具以 gradle 插件形式接入工程,行号优化任务依托于 packageApplication 任务之前执行,处理对象为 minify 任务输出的 Dex 文件,并将优化后的 Dex 文件做为 packageApplication 任务输入。
体积优化效果百度APP 上线行号优化前,APK体积为 123.58M,其中dex体积为 37.42M;启用行号优化后,APK体积减小至120.54M,优化3.04M,占dex体积~8%。为了满足多个途径包共用一个行号映射文件的需求,咱们期盼类内映射行号尽可能保持不变,因此选取了类级别的行号区间分配。倘若在 Dex 级别进行行号区间分配,可优化更加多体积,实验显示可进一步优化400K。
丨4.2 性能平台行号映射还原百度APP 上线行号优化后,端上报的反常信息中再也不携带真正的行号,携带的行号为虚拟行号,虚拟行号并不可真正映射到反常出现时实质代码所在行,给业务方排查线上问题带来了很大麻烦。因此呢性能平台必须将虚拟行号进行映射解析。将端上上报的崩溃、卡顿等反常信息中的虚拟行号经过必定的解析算法 + APP发版时传入性能平台的行号映射表,最后映射成真实的行号,使的该行号能够真正映射到反常出现时实质代码所在行,最后提高业务方在性能平台上分析问题的能力。在APP应用中,尽管出现崩溃、卡顿等反常场景的概率很低,然则在日活过亿的用户级别下,产生的反常信息亦是千万、亿级别的,怎样对全量反常信息进行实时行号映射解析是性能平台面临的首要问题。
性能平台整体架构图性能平台采取如下架构对全量用户产生的反常信息的行号进行映射解析,设计重点分位三个部分:流式计算处理服务、多级缓存系统、映射文件解析服务。整体的架构图如下所示:图4-6 性能平台服务端整体架构图
映射文件解析服务在进行行号映射解析的过程中,必须原始反常信息 + 行映射解析文件 + 解析算法 ->真正行号。因此呢,在APP发版时,必须采用手动(性能平台上传)或自动(发版流水线配置)的方式将行映射解析文件上传到性能平台的解析服务器中,经过映射解析服务器将数据写入到多级缓存系统中,供流式计算引擎运用。例如,原始的映射文件如图4-7,其中包含了包名、类名、办法名、映射行号闭区间、真实行号等信息。图4-7 映射文件示例性能平台在将这些信息写入缓存系统时的结构(key-value)HashMap为:APP_版本_com.baidu.searchbox.Application.onCreate: [1000-1050] -> 20 [1051-2000] -> 22 [3000-3020] -> 30 [3021-3033] -> 31流式计算处理服务该部分的流程为,端上采集反常信息 -> 上报到日志中台 -> 性能平台数据汇总Bigpipe -> 性能平台根据业务分流 -> 各个子业务的Bigpipe -> 流式计算引擎进行行号解析等处理 -> 数据存储 -> 性能平台进行展示。流式计算引擎进行行号解析时,会将拜访频率最热的映射文件行号的Map结构加载到算子内存中。若内存中没法命中,则去多级缓存中去查找再加载到算子的内存中。
多级缓存系统针对查找的响应速度,数据在流式计算算子的内存中的读写速度 > Redis 等内存存储系统>列式存储系统 Table。多级缓存系统的由算子内存、Redis、Table等构建。最上层是实时流算子内存,响应速度最快,但容量受到限制,用来缓存拜访频率最高的映射文件索引,中间层是 Redis,重点存储线上的映射文件,最底层则为 Table,存储的是线上和线下场景的映射文件。针对咱们全部系统来讲,流式引擎算子内存中的缓存命中率高是咱们提高行映射解析时效性要紧保准。因此呢咱们设计了如下的缓存替换策略:(1)缓存具备高并发能力,能够并行的互不干扰的读写;(2)缓存具备老化能力,当一个数据版本N天未被命中时,缓存将其老化清除;(3)数据具备W-TinyLFU的替换策略,使得内存中的缓存为近期最频繁拜访的Key值。
设计和实现中关键问题的处理(1) 数据的幂等性在分布式的流式处理系统中,实时处理系统常常亦会面临崩溃,重启的状况,因此呢需求系统对数据的处理拥有幂等性,即精确消费一次数据的语义。在系统中,咱们经过实时计算引擎中的Checkpoint机制,保准数据的消费最少一次消费。而后在存储中,经过对数据的日志ID做为数据的独一标识,即一条反常信息数据即使多次消费亦只会存储一次。保准了全部系统的幂等性需求。(2) 数据的流量压力掌控在整体的设计中,数据的处理和数据的采集经过了中间件信息队列进行认识耦和削峰,当数据处在高峰期时,此时未能消费完的数据会保留在信息中间件的磁盘上。流量高峰的时间段都是较短的,待流量高峰期结束,数据处理模块又能将中间件中累积的数据处理完从而做到较好的压力掌控。(3) 数据处理的低延时采用多级缓存系统的设计,保准了每条数据的行解析映射在ms级别,使的系统的反常端上上报产生->性能平台展示解析结果的全部流程保准在了分钟级级别。GEEK TALK
05 总结本文重点介绍了 DebugInfo 的定位以及优化方法,其中重点讲述了日前百度APP所运用的Dex行号优化与复原方法。感谢各位阅读至此,如有问题请不吝指正。
END 参考资料:[1] 支付宝行号优化https://juejin.cn/post/6844903712201277448[2] Dex结构https://source.android.com/devices/tech/dalvik/dex-format[3] Class结构 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.1[4] roGuard规则https://www.guardsquare.com/manual/configuration/attributes[5] ByteX https://github.com/bytedance/ByteX[6] R8 https://r8.googlesource.com/r8举荐阅读:
一键三连,好运连连,bug不见 |
上一篇:独立站必备:零基础的谷歌SEO优化教学下一篇:百度APP Android包体积优化实践(一)总览
|