百度APP Android包体积优化实践(二)Dex行号优化

1,465 阅读18分钟

图片

01 前言

在上一篇文章中,我们简要介绍了 Android 包体积优化的基本思路以及各优化项。本文我们会重点讲述 Dex 体积优化中的行号优化,优化目标是在可追溯原始调试信息的前提下,尽可能减少 DebugInfo 体积。

我们参考了业界已有的行号优化方案(如支付宝、R8),采用将行号集改为pc集的方式,做到最大程度复用 DebugInfo,同时解决了重载方法行号区间重叠问题,并提供完整的原始行号 retrace 方案。

如图1-1所示,为两个方法的 DebugInfo 可视化映射过程,我们会将指令集与原始行号的映射关系导出为 mapping 文件,并上传给服务端做后续的 retrace处理。可以发现,映射完成后两个方法的 DebugInfo 信息一致,即达到了可复用状态。

图片

图1-1 两个方法 DebugInfo 映射过程

接下来将详细讲述 DebugInfo 分析、现有方案对比、百度APP优化方案及收益 等内容。

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_SEQUENCE0x00debug_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);  return Thread::InternalStackTraceToStackTraceElementArray(soa, javaStackState); // 将StackTrace转化为StackTraceElement[]}// art/runtime/thread.ccjobjectArray Thread::InternalStackTraceToStackTraceElementArray(    const ScopedObjectAccessAlreadyRunnable& 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::PointerArray> method_trace = ObjPtr<mirror::PointerArray>::DownCast(decoded_traces->Get(0));        // 从StackTrace中获取 ArtMethod与对应pc        ArtMethod* 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对象        const ObjPtr<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 bool CodeItemDebugInfoAccessor::GetLineNumForPc(const uint32_t address,                                                       uint32_t* line_num) const {  return DecodeDebugPositionInfo([&](const DexFile::PositionInfo& entry) {    if (entry.address_ > address) {        return true;    }    *line_num = entry.line_;    return entry.address_ == address;  });}// code_item_accessors.h -> dex_file.h// 遍历dex中对应的debugInfobool DexFile::DecodeDebugPositionInfo(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,遍历长度会比原先长,但由于遍历中的处理极为简单,所以几乎不会查询性能造成影响。

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 相等判断逻辑(伪代码)public boolean 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(DebugEvent event) {  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 行号映射

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级别,使的系统的异常端上上报产生->性能平台展示解析结果的整个流程保证在了分钟级级别。

05 总结

本文主要介绍了 DebugInfo 的定位以及优化方案,其中重点讲述了目前百度APP所使用的Dex行号优化与复原方案。感谢各位阅读至此,如有问题请不吝指正。

————————END————————

参考资料:

[1] 支付宝行号优化juejin.cn/post/684490…

[2] Dex结构 source.android.com/devices/tec…

[3] Class结构

docs.oracle.com/javase/spec…

[4] ProGuard规则

www.guardsquare.com/manual/conf…

[5] ByteX github.com/bytedance/B…

[6] R8 r8.googlesource.com/r8

推荐阅读:

百度APP Android包体积优化实践(一)总览

百度APP iOS端内存优化实践-大块内存监控方案

百家号基于AE的视频渲染技术探索

百度工程师教你玩转设计模式(观察者模式)

Linux透明大页机制在云上大规模集群实践介绍

超高效!Swagger-Yapi的秘密