写点什么

百度 APP Android 包体积优化实践(三)资源优化

作者:百度Geek说
  • 2022 年 8 月 04 日
  • 本文字数:8355 字

    阅读完需:约 27 分钟

01 前言

百度 APP Android 包体积优化实践系列文章的前两篇分别介绍了体积优化的整体方案和 Dex 行号优化的具体内容。Dex 行号优化基于尽可能减少 Dex 文件中的 DebugInfo 体积来优化包体积。资源优化则通过优化 APK 中的资源项来优化包体积,本文我们会介绍百度 APP 在资源优化上的实践。首先介绍 APK 中资源部分的结构,然后对比分析现存的资源优化工具,介绍百度 App 自定义优化开发方案,最后还会介绍一些带来其他收益的资源优化。


百度 APP Android 包体积优化实践系列文章回顾:


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


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

02 APK 资源项

如下图所示,可以看到 APK 中有三部分内容与资源相关:res/ 目录、resources.arsc、assets/ 目录。除了 assets/ 目录外,其余两个资源项初始设计目的是为了实现更方便的机型适配和语言适配等,提高兼容性,因此存在一些优化的空间。



APK 结构

2.1 res/

res/ 资源通常包括用到的各种静态内容,如位图,颜色,布局定义,用户界面字符串,动画等等,这些资源一般放置在项目的 res/ 下特定子目录中。


对应资源目录名称格式如下:


<resources_type_name>-<qualifier_1>-<qualifier_2>


resources_type_name 即资源类型,必须完全匹配,否则不会被编译链接到 APK 中。Qualifier 即配置标识,可添加多个 qualifier 以匹配到最适合的资源,是多机型适配的基础。qualifier 的内容及顺序必须完全匹配,否则会编译失败,提示 Invalid resource directory name


除了 res/raw/下可放任意类型资源外,其他目录下资源文件格式均受严格控制。如果放置了范围外的类型文件会编译失败,提示 The file name must end with <指定的扩展名>,由此可见文件后缀名是编译校验的一部分。后缀名校验通过后,AAPT2 还会对资源文件内容进行校验,实际格式与后缀名不匹配的话也会报错。

2.2 resources.arsc

resource.arsc 文件是 Apk 打包过程中由 AAPT2 根据 res/ 目录下资源生成的一个资源索引文件,负责将代码中的资源引用映射到 res/ 下最合适的资源文件或资源内容。


下图中可以看出 arsc 中的重点信息包括:包名、资源类型、资源 ID、资源名、资源配置。



arsc 主要信息


通过阅读在 arsc 中寻找对应资源的源码,可以看到在 LoadedPackage::GetEntryOffset 方法中,有两种资源 entry 偏移量定位方式,其中 SPARSE 格式在 Android O+ 引入。我们以下图为例,假设 0x7f020010 和 0x7f020011 两个 ID 对应的 entry 为空,则两种方式的布局如下图所示,可以发现 SPARSE 格式在体积上会有优化,但查找资源的时间复杂度会从 O(1)上升到 O(logn)。



arsc DENSE & SPARSE 格式

2.3 assets/

assets/ 下的资源属于 raw 文件,raw 文件表示需以原始形式保存的任意文件。从目录结构到文件内容均由开发者直接控制,使用时通过 AssetManager 直接获取。本质上 assets/和 res/ 的资源文件读取方式是一样的,都是 AssetsProvider 将 APK 内对应路径的文件解压映射到内存中。不同的是开发者调用 API 到 AssetsProvider 读取文件之间的路径,res/ 做了更多封装,所以相应地限制也会多一些。


由于 assets 资源文件灵活度很高,通用优化机制对其作用有限,我们一般会采取后下发的方式直接抹除这部分体积。后续我们的优化项全部针对 res/ 和 resources.arsc 展开。

03 现有资源优化工具

3.1 AGP 和 AAPT

AGP(Android Gradle Plugin)在编译流程中定义了不少资源优化相关的任务,AGP 资源优化任务底层都是通过 AAPT2 完成的(除了旧资源缩减任务)。AAPT2(Android 资源打包工具)是一种构建工具,Android Studio 和 Android Gradle 插件使用它来编译和打包应用的资源。AAPT2 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。下面介绍一些资源优化相关任务和 AAPT2 的优化参数。

OptimizeResources

AGP 4.2 + 注册了一个新的编译任务 OptimizeResourcesTask,顾名思义是对资源进行优化,在 LinkResourcesTask(即资源链接) 或 ShrinkResourceTask (即资源缩减)之后执行。该优化任务在 debuggable false 情况下默认开启,可以使用 android.enableResourceOptimizations = false 手动关闭。


// com/android/build/gradle/internal/tasks/OptimizeResourcesTask.class// OptimizeResourcesTask关联了AAPT2提供的优化项enum class AAPT2OptimizeFlags(val flag: String) {    COLLAPSE_RESOURCE_NAMES("--collapse-resource-names"),    SHORTEN_RESOURCE_PATHS("--shorten-resource-paths"),    ENABLE_SPARSE_ENCODING("--enable-sparse-encoding")}internal fun doFullTaskAction(params: OptimizeResourcesTask.OptimizeResourcesParams)  {    // 添加 资源路径优化 参数    val optimizeFlags = mutableSetOf(        AAPT2OptimizeFlags.SHORTEN_RESOURCE_PATHS.flag    )    // 目前enableResourceObfuscation默认为false,且没有提供参数配置,所以不会开启资源名优化任务    if (params.enableResourceObfuscation.get()) {        optimizeFlags += AAPT2OptimizeFlags.COLLAPSE_RESOURCE_NAMES.flag    }}
复制代码


从上面的代码可以看出,OptimizeResourcesTask 本质是调用 AAPT2 完成资源优化,目前只使用了 SHORTEN_RESOURCE_PATHS,即资源路径优化。优化前后结果对比如下:



资源文件路径优化效果(arsc)


APK 中实际文件路径也发生了变化(但可以发现 res/color/ 目录没有变,稍后我们会讲述原因)。



资源文件路径优化效果

ShrinkResources

资源缩减是 AGP 初期版本就注册的优化任务,在 MinifyTask (即代码缩减)后执行。


该任务会对资源的声明及使用(包括源码使用、manifest 使用、资源内部使用)进行分析,最终会将仅声明未使用的资源文件替换为预先设定好的 Dummy entry(即该文件格式下的最小体积格式化文件)。


但是优化的同时也存在一些限制:


1、必须启用严格模式


2、没有完全删除无用的资源文件


3、没有删除无用的 value 资源


针对后两个问题,AGP4.2+ 也提供了实验性选项 android.experimental.enableNewResourceShrinker.preciseShrinking(AGP7.1 以下还需同时启用新资源缩减器 android.experimental.enableNewResourceShrinker),开启后可利用 AAPT2 完全移除无用资源文件,同时移除 arsc 中的无用资源。但因为优化在链接任务之后,资源 ID 已经分配完毕,所以被移除的资源还是会保留填充占位(DENSE 格式)。优化效果如下所示:



  • 启用 preciseShrinking 效果

  • 任务顺序



MinifyTask —> ShrinkResourcesTask —> OptimizeResourceTask(自定义 & 官方) 任务的顺序是不可变的。

resConfigs

resConfigs 是 BaseFalvor 提供的资源配置选项,可配置多个资源配置项,最终非这些配置项的资源不会被打包进 APK 中。


根据是否为分辨率配置,resConfigs 的具体实现不同(会使用不同的 AAPT2 参数)。


(1) 分辨率配置


  • 分辨率配置最多配置一个值,若配置多个会编译报错 Cannot filter assets for multiple densities using SDK build tools 21 or later. Consider using apk splits instead**。**

  • 使用安全优化。优化逻辑如图所示(不会出现 NO_ENTRY)。



分辨率配置

(2) 非分辨率配置

  • 可以配置多个值。例如语言配置可以同时选英文、中文。

  • 使用激进优化。该类型配置下,非目标配置均会被移除(可能出现 NO_ENTRY)。

splits

splits 的作用是分包,例如根据不同分辨率打多个包。与 resConfigs 的区别是可以指定多个分辨率,一次性出包;但仅支持分辨率配置。谷歌官方建议,分包需求优先使用 AAB,应用商店不支持 AAB 的情况下再使用 splits。

--shorten-resource-paths

添加资源路径优化参数后,AAPT2 会处理除了 res/color 目录外的全部资源路径,并在指定目录输出优化前后的路径映射文件。


// Android detects ColorStateLists via pathname, skip res/color*if (util::StartsWith(res_subdir, "res/color"))      continue;
复制代码


但翻看 Android 源码没有发现对应的使用,只是会对 res/color 目录下的资源扩展名进行校验,以区分 xml 文件和其他格式文件(这里进一步决定了后续的扩展名优化加白策略)。

--resources-config-path args

该参数的值是配置文件路径,配置文件格式为:type/resource_name#[directive][,directive]


其中 directive 可选项包括:


  • no_collapse。资源名优化加白。

  • no_obfuscate。同 no_collapse(虽然目前跟 no_collapse 作用一样,但根据命名看未来有可能会满足混淆需求,资源同名化 小节会讲什么场景下有资源名混淆的需求)。

  • remove。移除该资源,优先级高于前两类 directive(我们认为这个优先级不合理)。是资源缩减 preciseShrinking 的底层实现。

--collapse-resource-names

添加该优化参数后,除了配置文件中的加白资源,其余资源名均会折叠为同一个字符串。

--enable-sparse-encoding

添加该优化参数后,在 arsc 文件生成的资源映射流程中,会根据 arsc 的格式选择查找资源 entry 偏移量的方法。这有助于优化 APK 大小,但会降低资源检索性能。SPARSE 格式就是通过这个优化参数开启的。

3.2 AndResGurard

AndResGuard 是微信提供的 Android 资源混淆打包工具,国内的 Android 资源优化基础基本是由 AndResGuard 奠定的,是目前应用最为广泛的资源优化工具。支持资源路径混淆、资源名同化、产物压缩。

3.3 AabResGuard

AabResGuard 是字节于 20 年开源的资源优化工具,其在 AndResGuard 的基础上,专门针对 AAB 产物进行优化,同时增加资源文件和字符串的去重。

04 百度 APP 资源优化工具

最终我们选择基于 AAPT2 做二次开发,增加百度 App 资源优化逻辑。主要出于以下考虑:


(1) 多格式产物支持,包括 APK 和 AAB 格式。同时 AAPT2 支持 resources.ap_ 和 resources.pb 的双向转换。


(2) 未来可见范围内的 AGP 升级适配,减少版本兼容成本。


(3) 稳定可靠。

4.1 资源文件路径优化

在资源优化方面我们首要考虑的就是资源文件路径优化。一般来说,一个资源文件的路径在 APK 中会体现在以下几处地方,分别是:


(1) resources.arsc 文件


通过了解 resources.arsc 文件结构信息,如下图所示,可以看到在全局字符串池(strPool)中,记录了完整的资源路径。



全局字符串池中的路径信息


(2) 在签名过程产生的 MANIFEST.MF 文件


如下图所示, 在签名过程中会计算每个文件对应的 SHA1-Digest 值保存在 MANIFEST.MF 文件中。



MANIFEST.MF 文件中资源的摘要信息


(3) APK(ZIP)文件中的数据存储区和中心目录区


我们知道 APK 文件实际上是 ZIP 格式,而 ZIP 文件格式大致可以分为三个部分:数据存储区(File Entry)、中心目录区(Central Directory)以及一个目录结束标识(End of central directory record)。


对于 ZIP 中的一个文件,文件路径会分别在数据存储区和中心目录区同时保存,例如对于 ZIP 中一个路径为 res/mipmap-anydpi-v26/ic_launcher.xml 的资源,通过分析其二进制,可以看到文件路径分别存在数据存储区的 frFileName 字段和中心目录区的 deFileName 字段中,如下图所示。



数据存储区中的路径信息



中心目录区中的路径信息


由于资源路径同时存在上述四处地方,而且除了 MANIFEST.MF 文件是可压缩的,其他三处均不可压缩。因此如果能对资源路径进行缩减,带来的将是近乎四倍的收益。例如,对每个资源文件,其资源路径缩减一个字符(占用 1byte),按照以上方式所述再乘以四倍的收益,可减少大约 4byte 体积,假设一个 App 中有 10000 个资源文件,就可以优化将近 40k 的体积。如果能大幅减少资源文件路径长度则会带来更明显的收益。


百度 App 在资源路径方面的具体优化点主要分为以下三点:

资源文件目录优化

我们将资源文件所属目录从 res/type[-config_qualifier ]修改为 r/,尽可能的缩短了资源文件的路径长度。

资源文件名优化

我们通过一致性 Hash 映射机制,保持了原资源路径与优化后的路径固定映射,优化后的文件名固定为三个字符,相比原文件名有了明显缩短,实际测试有较少的哈希冲突,这样能够保持较小的安装差量包,同时也减少了覆盖安装后首次启动因为资源名称和资源 ID 变化造成的崩溃问题。


std::string ShortenFileName(const android::StringPiece& file_path, int output_length) {    std::size_t hash_num = std::hash<android::StringPiece>{}(file_path);    std::string result = "";    // Convert to (modified) base64 so that it is a proper file path.    for (int i = 0; i < output_length; i++) {        uint8_t sextet = hash_num & 0x3f;        hash_num >>= 6;        result += base64_chars[sextet];     }     return result;}
复制代码

资源文件扩展名优化

除此之外,我们还较为激进地去掉了大部分文件的扩展名,这样每个资源至少可以优化 4 个 byte。


文件的扩展名主要有两个作用,一是给使用者辨别文件格式,二是操作系统默认使用什么软件加载文件,真正的文件格式并不受扩展名影响。对 Android 系统来说,res 文件扩展名也有两个作用:


(1)在编译期利用扩展名快速校验,限制文件类型。


(2)运行期间获取文件流后,根据扩展名进行不同的解析封装操作(或者再次校验),再传递给上层。


由于我们的优化是在资源编译之后进行,所以问题 1 可以不用考虑。针对问题 2,我们发现源码中使用扩展名的情况包括:


private ComplexColor loadComplexColorForCookie(Resources wrapper, TypedValue value, int id,        Resources.Theme theme) {    ...    if (file.endsWith(".xml")) {        // xml 格式解析    } else {        // 校验不通过,必须是xml文件    }    ...}
private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value, int id, int density) { ... if (file.endsWith(".xml")) { // xml 格式解析 } else { // 其他格式解析 } ...}
复制代码

分析上面的代码可以发现,是将 res/color 和 res/drawable 目录下的文件分为 xml 格式和其他格式,所以只需要针对这两类目录下的 xml 格式文件保留扩展名即可。

bool ResourcePathShortener::Consume(IAaptContext* context, ResourceTable* table) {  // res/color 和 res/drawable 目录下的xml文件扩展名需要保留  if (util::StartsWith(res_subdir, "res/color") || util::StartsWith(res_subdir, "res/drawable")) {      if (util::StartsWith(extension, ".xml")) {        keep_extensions = true;      }  }}
复制代码


除此之外,我们还配置了资源文件路径优化白名单机制,对于需要通过路径查找资源等特殊情况进行了豁免。


通过上述对三种路径优化方式,我们分析 APK 可以直观的看出优化的结果。



路径优化前后对比

4.2 资源名优化

资源名优化主要包含了资源同名化和资源名混淆两部分。


如第 4 章开始处介绍,除了 arsc 文件中的全局字符串池记录了完整的资源路径,在 arsc 中的 Package 数据块中还保存了所有资源名的字符串池。



资源名字符串


在实际应用中,我们默认通过资源 id 查找资源内容,对资源名的使用频率十分低,仅限于通过资源名反查资源 id 以及 通过资源 id 获取资源名两种情况。所以资源项名称字符串池所占据的空间即是我们的优化对象。极限优化结果是,这个池子里仅存放一个字符串,所有 ResTable_entry 的资源项名称 index 均指向这个池子里仅有的字符串,即所有资源的名字都变得一样了。考虑到豁免的需求,我们也增加了白名单机制。对于资源文件来说,虽然文件名和 ResourceEntryName 的内容是一样的,但实质是两个不同的概念,所以优化与加白都应该分开处理。


由于现在 arsc 不能压缩,资源名对应的字符串都是可以实实在在优化的体积。


在实际使用中,如果调用了以下接口,那么同名化后,不能通过资源名区分资源,可能会导致某些场景的失效。例如全埋点场景,通常会收集 UI 控件的名字(也就是资源名)作为唯一标识。在同名化后必须修改为将[资源名,资源类型,包名]作为唯一标识。


// android/content/res/Resources.javapublic int getIdentifier(String name, String defType, String defPackage)
public String getResourceName(@AnyRes int resid)
public String getResourceEntryName(@AnyRes int resid)
// android/content/ContentResolver.java// URI scheme = android.resource,内部调用的还是Resources.getIdentifierpublic final @Nullable InputStream openInputStream(@NonNull Uri uri)
public final @Nullable AssetFileDescriptor openAssetFileDescriptor(@NonNull Uri uri, @NonNull String mode, @Nullable CancellationSignal cancellationSignal)
复制代码


除此之外,我们还提供了混淆功能,可以输出混淆前后的资源名映射文件。对于上面全埋点场景的例子,建议使用资源名混淆。这样既保证了场景的有效性,也可以减少一部分体积。

4.3 arsc configuraion 稀疏条目优化

我们知道 arsc 的 Package 数据块中包含了 Type Spec(类型规范数据块)列表,每个 Type Spec 包含了 configuraion 列表。每一个资源 id 是从属于特定 Type Spec 的,会在该 Type Spec 下面的所有 configuration 列表中有对应的 res value 信息。这是同一个资源 ID 在不同配置下,找到不同资源值的原理。根据起始偏移量和每个字符串的偏移量数组,我们就能定位资源。如果这个资源对应的 configuration 不存在,仍会保留一个 res value 的空间(值为 0)  ,占用 4 个字节的空间,以满足偏移量查询方式。


如下图中的空白区域所示,对一个名为 abc_edit_text_material 的资源来说,只存在于默认的 drawable 目录下,其他配置项均为空白占位,有较大的优化空间。



resources.arsc 空白占位


因此通过优化 arsc 中不必要的 configuraion,就可以减少对齐占位。百度 App 目前主要是以优化源码中的资源目录来实现,删除不必要的资源类型路径,从而达到减少 configuraion 的目的。


如第 2 章介绍,AAPT2 已经支持对稀疏条目进行优化,百度 App 由于 minSdkVersion 的原因暂未开启。

4.4 其它优化

上面讲得是集成在编译流程中的体积优化项,还有一部分优化由于时间原因或者成本原因没有做到工具里,这里也会逐一介绍。这部分优化关系到的不止是体积,还有开发效率等。

图片文件压缩

图片压缩主要有两种方式:


(1)减少颜色数。一张图具备颜色数量越多,单个 pixel 位数就会越多。一般情况下,非渐进色图片只需要 256 种颜色(即 pixel 8bit)。TinyPng 采用的就是这个原理。


(2)移除元数据。图片中会携带版权、相机信息等元数据,可以选择移除这部分数据。


我们对比了多种业界图片压缩工具,最终选择了 ImageOptim 工具来完成图片压缩。ImageOptim 能移除元数据,并支持无损压缩,在磁盘空间和带宽方面收益明显。

重复资源

重复资源指的是资源内容相同,但资源路径不同的资源,这个问题会导致重复的体积。我们可以通过对比 md5 判断资源文件是否重复。

相似资源

相较重复资源,相似资源出现的概率更高、更不容易被发现。对于图片资源,可以使用 opecv 中集成的特征检测器计算相似度,应用内置资源通常特征点数量少,计算速度快。


重复资源与相似资源最佳的解决方案是协同 UE 共建资源平台,从源头上提升资源复用率。

AAB

从 2021 年 8 月开始,谷歌商店要求应用以 AAB 格式上架,其主要目的是在应用分发处消化机型适配和动态功能造成的体积增加,避免了开发者管理多个分包的麻烦事。

声明式 UI

随着声明式 UI 逐渐走上前台,越来越多的替代传统的 View + xml 的格式,逻辑代码与 UI 布局之间的转化隔阂势必会被消除。Compose 带来的优点很多,其中之一即是体积会比 View + xml 更小。在谷歌官方的 《Jetpack Compose 使用前后对比》 一文说道:Tivi 应用在使用了 Compose 后,我们发现 APK 大小缩减了 41%,方法数减少了 17%。

05 总结

本文主要介绍了百度 APP 资源优化方案,其中重点讲述了在资源路径和资源名方面的优化。感谢各位阅读至此,如有问题请不吝指正。


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


参考资料:


[1] 应用资源


https://developer.android.com/guide/topics/resources/providing-resources#ResourceTypes


[2] AAPT2


https://developer.android.com/studio/command-line/aapt2?hl=zh-cn


[3] ZIP 结构


https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.2.0.txt


[4] 使用别名


https://developer.android.com/training/multiscreen/screensizes#************TaskUseAliasFilters


[5] ImageOptim


https://imageoptim.com/mac


[6] Jetpack Compose — Before and after


https://medium.com/androiddevelopers/jetpack-compose-before-and-after-8b43ba0b7d4f


推荐阅读:


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


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


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


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


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


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

用户头像

百度Geek说

关注

百度官方技术账号 2021.01.22 加入

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

评论

发布
暂无评论
百度APP Android包体积优化实践(三)资源优化_Andriod_百度Geek说_InfoQ写作社区