关于android:百度APP-Android包体积优化实践二Dex行号优化

9次阅读

共计 11887 个字符,预计需要花费 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 组成。

图 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 有以下几类:

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 所示

<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 数量依据办法指令数量而定,取值为所属指令分区间的上限值。

图 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] 支付宝行号优化 https://juejin.cn/post/684490…

[2] Dex 构造 https://source.android.com/de…

[3] Class 构造

https://docs.oracle.com/javas…

[4] ProGuard 规定

https://www.guardsquare.com/m…

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

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

举荐浏览:

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

百度 APP iOS 端内存优化实际 - 大块内存监控计划

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

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

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

超高效!Swagger-Yapi 的机密

正文完
 0