百度APP Android包体积优化实践系列文章的前两篇分别介绍了体积优化的整体方案和Dex行号优化的具体内容。Dex行号优化基于尽可能减少Dex文件中的DebugInfo 体积来优化包体积。资源优化则通过优化APK中的资源项来优化包体积,本文我们会介绍百度APP 在资源优化上的实践。首先介绍 APK 中资源部分的结构,然后对比分析现存的资源优化工具,介绍百度App自定义优化开发方案,最后还会介绍一些带来其他收益的资源优化。
百度APP Android包体积优化实践系列文章回顾:
[百度APP Android包体积优化实践(一)总览]
[百度APP Android包体积优化实践(二)Dex行号优化]
如下图所示,可以看到APK 中有三部分内容与资源相关:res/ 目录、resources.arsc、assets/ 目录。除了assets/ 目录外,其余两个资源项初始设计目的是为了实现更方便的机型适配和语言适配等,提高兼容性,因此存在一些优化的空间。
APK 结构
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还会对资源文件内容进行校验,实际格式与后缀名不匹配的话也会报错。
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格式
assets/ 下的资源属于 raw 文件,raw 文件表示需以原始形式保存的任意文件。从目录结构到文件内容均由开发者直接控制,使用时通过 AssetManager 直接获取。本质上 assets/和 res/ 的资源文件读取方式是一样的,都是 AssetsProvider 将 APK 内对应路径的文件解压映射到内存中。不同的是开发者调用 API 到 AssetsProvider 读取文件之间的路径,res/ 做了更多封装,所以相应地限制也会多一些。
由于 assets 资源文件灵活度很高,通用优化机制对其作用有限,我们一般会采取后下发的方式直接抹除这部分体积。后续我们的优化项全部针对 res/ 和resources.arsc展开。
AGP(Android Gradle Plugin)在编译流程中定义了不少资源优化相关的任务,AGP 资源优化任务底层都是通过 AAPT2 完成的(除了旧资源缩减任务)。AAPT2(Android 资源打包工具)是一种构建工具,Android Studio 和 Android Gradle 插件使用它来编译和打包应用的资源。AAPT2 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。下面介绍一些资源优化相关任务和AAPT2的优化参数。
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/ 目录没有变,稍后我们会讲述原因)。
资源文件路径优化效果
资源缩减是 AGP 初期版本就注册的优化任务,在 MinifyTask (即代码缩减)后执行。
该任务会对资源的声明及使用(包括源码使用、manifest 使用、资源内部使用)进行分析,最终会将仅声明未使用的资源文件替换为预先设定好的 Dummy entry(即该文件格式下的最小体积格式化文件)。
但是优化的同时也存在一些限制:
针对后两个问题,AGP4.2+ 也提供了实验性选项 android.experimental.enableNewResourceShrinker.preciseShrinking(AGP7.1以下还需同时启用新资源缩减器 android.experimental.enableNewResourceShrinker),开启后可利用 AAPT2 完全移除无用资源文件,同时移除 arsc 中的无用资源。但因为优化在链接任务之后,资源 ID 已经分配完毕,所以被移除的资源还是会保留填充占位(DENSE格式)。优化效果如下所示:
启用 preciseShrinking 效果
MinifyTask —> ShrinkResourcesTask —> OptimizeResourceTask(自定义 & 官方) 任务的顺序是不可变的。
resConfigs 是 BaseFalvor 提供的资源配置选项,可配置多个资源配置项,最终非这些配置项的资源不会被打包进 APK 中。
根据是否为分辨率配置,resConfigs 的具体实现不同(会使用不同的 AAPT2 参数)。
(1) 分辨率配置
分辨率配置
splits 的作用是分包,例如根据不同分辨率打多个包。与 resConfigs 的区别是可以指定多个分辨率,一次性出包;但仅支持分辨率配置。谷歌官方建议,分包需求优先使用 AAB,应用商店不支持 AAB 的情况下再使用splits。
添加资源路径优化参数后,AAPT2 会处理除了 res/color 目录外的全部资源路径,并在指定目录输出优化前后的路径映射文件。
// Android detects ColorStateLists via pathname, skip res/color*
if (util::StartsWith(res_subdir, "res/color"))
continue;
但翻看Android源码没有发现对应的使用,只是会对res/color目录下的资源扩展名进行校验,以区分xml文件和其他格式文件(这里进一步决定了后续的扩展名优化加白策略)。
该参数的值是配置文件路径,配置文件格式为:type/resource_name#[directive][,directive]
其中 directive 可选项包括:
添加该优化参数后,除了配置文件中的加白资源,其余资源名均会折叠为同一个字符串。
添加该优化参数后,在arsc文件生成的资源映射流程中,会根据arsc的格式选择查找资源 entry 偏移量的方法。这有助于优化 APK 大小,但会降低资源检索性能。SPARSE 格式就是通过这个优化参数开启的。
AndResGuard 是微信提供的Android资源混淆打包工具,国内的 Android 资源优化基础基本是由 AndResGuard 奠定的,是目前应用最为广泛的资源优化工具。支持资源路径混淆、资源名同化、产物压缩。
AabResGuard 是字节于20年开源的资源优化工具,其在 AndResGuard 的基础上,专门针对 AAB 产物进行优化,同时增加资源文件和字符串的去重。
最终我们选择基于 AAPT2 做二次开发,增加百度App资源优化逻辑。主要出于以下考虑:
(1) 多格式产物支持,包括APK 和 AAB 格式。同时AAPT2支持 resources.ap_ 和 resources.pb 的双向转换。
(2) 未来可见范围内的AGP升级适配,减少版本兼容成本。
(3) 稳定可靠。
在资源优化方面我们首要考虑的就是资源文件路径优化。一般来说,一个资源文件的路径在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章开始处介绍,除了arsc文件中的全局字符串池记录了完整的资源路径,在arsc中的Package数据块中还保存了所有资源名的字符串池。
资源名字符串
在实际应用中,我们默认通过资源 id 查找资源内容,对资源名的使用频率十分低,仅限于通过资源名反查资源 id 以及 通过资源 id 获取资源名两种情况。所以资源项名称字符串池所占据的空间即是我们的优化对象。极限优化结果是,这个池子里仅存放一个字符串,所有 ResTable_entry 的资源项名称 index均指向这个池子里仅有的字符串,即所有资源的名字都变得一样了。考虑到豁免的需求,我们也增加了白名单机制。对于资源文件来说,虽然文件名和 ResourceEntryName 的内容是一样的,但实质是两个不同的概念,所以优化与加白都应该分开处理。
由于现在 arsc 不能压缩,资源名对应的字符串都是可以实实在在优化的体积。
在实际使用中,如果调用了以下接口,那么同名化后,不能通过资源名区分资源,可能会导致某些场景的失效。例如全埋点场景,通常会收集UI控件的名字(也就是资源名)作为唯一标识。在同名化后必须修改为将[资源名,资源类型,包名]作为唯一标识。
// android/content/res/Resources.java
public 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.getIdentifier
public final @Nullable InputStream openInputStream(@NonNull Uri uri)
public final @Nullable AssetFileDescriptor openAssetFileDescriptor(@NonNull Uri uri,
@NonNull String mode, @Nullable CancellationSignal cancellationSignal)
除此之外,我们还提供了混淆功能,可以输出混淆前后的资源名映射文件。对于上面全埋点场景的例子,建议使用资源名混淆。这样既保证了场景的有效性,也可以减少一部分体积。
我们知道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的原因暂未开启。
上面讲得是集成在编译流程中的体积优化项,还有一部分优化由于时间原因或者成本原因没有做到工具里,这里也会逐一介绍。这部分优化关系到的不止是体积,还有开发效率等。**
图片压缩主要有两种方式:
(1)减少颜色数。一张图具备颜色数量越多,单个pixel位数就会越多。一般情况下,非渐进色图片只需要256种颜色(即pixel 8bit)。TinyPng采用的就是这个原理。
(2)移除元数据。图片中会携带版权、相机信息等元数据,可以选择移除这部分数据。
我们对比了多种业界图片压缩工具,最终选择了ImageOptim工具来完成图片压缩。ImageOptim能移除元数据,并支持无损压缩,在磁盘空间和带宽方面收益明显。*
重复资源指的是资源内容相同,但资源路径不同的资源,这个问题会导致重复的体积。我们可以通过对比md5判断资源文件是否重复。
相较重复资源,相似资源出现的概率更高、更不容易被发现。对于图片资源,可以使用opecv中集成的特征检测器计算相似度,应用内置资源通常特征点数量少,计算速度快。
重复资源与相似资源最佳的解决方案是协同UE共建资源平台,从源头上提升资源复用率。
从2021年8月开始,谷歌商店要求应用以AAB格式上架,其主要目的是在应用分发处消化机型适配和动态功能造成的体积增加,避免了开发者管理多个分包的麻烦事。
随着声明式UI 逐渐走上前台,越来越多的替代传统的View + xml的格式,逻辑代码与 UI 布局之间的转化隔阂势必会被消除。Compose 带来的优点很多,其中之一即是体积会比View + xml更小。在谷歌官方的 《Jetpack Compose 使用前后对比》 一文说道:Tivi应用在使用了 Compose 后,我们发现 APK 大小缩减了 41%,方法数减少了 17%。
本文主要介绍了百度APP资源优化方案,其中重点讲述了在资源路径和资源名方面的优化。感谢各位阅读至此,如有问题请不吝指正。
[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
[6] Jetpack Compose — Before and after
https://medium.com/androiddevelopers/jetpack-compose-before-and-after-8b43ba0b7d4f
扫一扫
在手机上阅读