写点什么

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

作者:百度Geek说
  • 2022 年 7 月 21 日
  • 本文字数:9224 字

    阅读完需:约 30 分钟

01 前言

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


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


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



<p align=center>图 1-1 两个方法 DebugInfo 映射过程</p>


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

02 解构 DebugInfo

调试信息(DebugInfo)指的是应用于调试场景的字节码信息,主要包括源文件名、行号、局部变量、扩展调试信息等。行号优化就是去优化 DebugInfo 中包含的行号信息,以减少 DebugInfo 区域大小,从而达到减少字节码文件体积的目的。

2.1 Dex DebugInfo

如图 2-1 所示,在 Dex 文件格式[2]中,DebugInfo 处于 data 区域,由一系列 debug_info_item 组成。



<p align=center>图 2-1 Dex 文件结构</p>


通常情况下,debug_info_item 与类方法一一对应,其在 Dex 中的引用关系如下图 2-2 所示。Dex 为块状结构,引用区域的位置均通过 x_off 偏移量确定。



<p align=center>图 2-2 class -> method -> debug_info 引用关系</p>


debug_info_item 结构如图 2-3 所示,主要由两部分构成:header 和一系列 debug_event。


header 中包含方法起始行号、方法参数数量、方法参数名三部分信息;除 header 外的 debug_events 可以理解为一系列状态寄存器,记录 pc 指针与行号的偏移量。debug_info_item 本质上是一个状态机。



<p align=center>图 2-3 debug_info_item 结构</p>


常用的 debug_event 有以下几类:



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 所示,异常堆栈末尾会显示方法源文件与行号。



<p align=center>图 2-4 异常堆栈</p>


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 四项信息。



<p align=center>图 3-1 Class 文件结构</p>

编译选项

-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 必须保持一致。



<p align=center>图 3-2 R8 行号映射</p>


除此之外,R8 还利用 SourceDebugExtension 还原了 kotlin inline 方法的实际位置,如图 3-3 所示



<p align=center>图 3-3 R8 还原 kotlin inline 行号映射</p>

04 百度 APP Dex 行号优化方案

百度 APP 的行号优化对 startLine、pcDelta、lineDelta、debug_event 数量均进行了控制,最终 debug_info_item 复用比例得到了极大提升。同时百度 APP 联合内部性能平台,对线上收集到的崩溃、ANR 堆栈进行行号还原。流程如图 4-1 所示:



<p align=center>图 4-1 百度 APP 端到端的行号优化流程</p>

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)。



<p align=center>图 4-2 理想行号区间</p>


(2)debug_event


除了表示起始结束的 debug_event 外,剩余全部都是 pcDelta=lineDelta=1 的 special opcodes 类型。其中 debug_event 数量根据方法指令数量而定,取值为所属指令分区间的上限值。



<p align=center>图 4-3 指令数量区间与 debug_event 数量映射</p>



<p align=center>图 4-4 映射后的 debug_events</p>


(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,其中包含了包名、类名、方法名、映射行号闭区间、真实行号等信息。



<p align=center>图 4-7 映射文件示例</p>


性能平台在将这些信息写入缓存系统时的结构(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] 支付宝行号优化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] ProGuard 规则


https://www.guardsquare.com/manual/configuration/attributes


[5] ByteX https://github.com/bytedance/ByteX


[6] R8 https://r8.googlesource.com/r8


推荐阅读:


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


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


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


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


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


超高效!Swagger-Yapi的秘密

用户头像

百度Geek说

关注

百度官方技术账号 2021.01.22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
百度APP Android包体积优化实践(二)Dex行号优化_Java_百度Geek说_InfoQ写作社区