随着业务的快速迭代,抖音 Android 端的包大小爆发式增加。包大小直接影响到下载转化率、推广成本、运行内存和安装时间等因素,所以对 apk 进行瘦身是一件颇有必要且收益很大的事情。apk 主要由 dex、resource、asserts、native libraries 和 meta-data 组成,针对每一部分,均可以专项去作包大小优化。
抖音 Android 端通过一段时间努力,包大小优化已经取得了阶段性的成果。目前仍在持续的优化中。html
- | 优化前 | 优化后 | 百分比 |
---|---|---|---|
抖音 | 73MB | 61.5MB | 15.7% |
抖音 lite | 10MB | 4.9MB | 51% |
其中,资源在 apk 包体积中占比很大,针对资源进行优化是包大小优化中很重要的部分。本着追求极致的原则,本文将详细阐述抖音 Android 端针对资源部分的优化措施。android
在不进行压缩的状况下,图片大小计算公式:图片大小=长 x 宽 x 图片位深。一张原始图像(1920x1080),若是每一个像素 32bit 表示(RGBA),那么图像须要的存储大小 1920x1080x4 = 8294400Byte,大约 8M,一张图这么大是难以接受的。所以咱们使用的图片都是通过压缩的。
图片压缩利用的是空间冗余和视觉冗余原理:git
抖音 Android 研发团队开发了 Gradle 插件 McImage,在编译期间 hook 资源,采用开源的算法 pngquant/guetzli 进行压缩,支持 webp 压缩。与 tinypng 等一些已知的方案相比,存在如下优点:github
McImage 支持两种优化方式,这两种优化方式不可同时使用:web
webp 的压缩比要高于 pngquant、guetzli,因此如今更推荐使用 ConvertWebp 这种压缩方式。算法
McImage 还被应用于字节跳动旗下多个产品的图片压缩优化工做中,收益以下:api
描述 | 收益 |
---|---|
抖音-Compress | 9.5MB |
抖音-ConvertWebp | 11.6MB |
火山-ConvertWebp | 3.6MB |
Vigo-ConvertWebp | 4MB |
Vigo aab-Compress | 1.2MB |
vigo aab-ConvertWebp | 3.2MB |
多闪-ConvertWebp | 3.5MB |
除了压缩、优化图片,McImage 还提供了如下功能:数组
tinypng 与 webp 到底哪一个压缩比更高呢?在网上找不到两种压缩算法压缩比的直接比较,须要更直观的对比,因而作了以下的实验:缓存
描述 | 大小 |
---|---|
原图 | 13463.07KB |
webp 压缩 | 4177.18KB |
tinypng 压缩 | 6732.18KB |
描述 | 大小 |
---|---|
原图 APK | 9617.53KB |
webp 压缩 APK | 3924.06KB |
tinypng 压缩 APK | 5386.80KB |
经过这两组实验对比,能够看出 webp 的压缩比是优于 tinypng 的。以前也手动的使用 webp 工具压缩过抖音工程中全部图片,包大小减小了 1.6MB 左右。所以选择了 Webp 压缩算法。安全
webp 压缩算法,相较于 pngquant、guetzli、tinypng,webp 压缩比更高,因此 webbp 压缩图片应该是更优的选择。可是 Android 设备对 webp 的支持存在兼容性问题,在 4.3 以上才彻底支持。经过官网咱们知道,想在应用中直接使用带有透明度的 webp,minSDK 至少须要是 18。
- | 优势 | 缺点 |
---|---|---|
提供特定 api 兼容 | 实现起来简单 | 侵入性太强,必须用特定接口或特定 View 进行加载 |
LayoutInflater setFactory 进行兼容 | 实现起来简单 | 须要针对全部的 ImageView 及子 View 处理,且必须有统一的 Activity、Fregment 的基类处理 |
运行时 hook 系统关键方法 | 方法替换,能够作到无侵入式 | 实现起来复杂些 |
想要作到无侵入式的兼容,运行时 hook 不失为一种最佳的选择。可是运行时 hook 方案,须要解决如下几点问题:
经过对 Xposed、AndFix、Cydia Substrate、dexposed 等常见的 Android Java hook 方案的调研对比,dexposed 具备不须要 root、又能 hook 系统方法的特色,最终选择 dexposed:
经过阅读源码,发现全部图片被加载解析成 Bitmap 的过程,最终都调用到了 BitmapFactory 中的方法。 好比 ImageView 的 setImageResource()
的调用路径以下:
ImageView 的 setImageResource 过程,Bitmap 的建立是经过 BitmapFactory 来实现。 如 View 的 setBackgroundResource(int resid)
的源码以下:
查阅全部加载图片的 api,都会经历 Resources 调用 getDrawable 的过程。会调用到 Drawable 的相关方法,而后经过 BitmapFactory 去解析不一样的资源类型(File\ByteArray\Stream\FileDescriptory)为 Bitmap。由此能够推断出,BitmapFactory 是 Android 系统经过不一样的资源类型加载成 Bitmap 的统一接口,这一点从 BitmapFactory 的类注释中也能看出:
因为系统加载解析 Bitmap 的过程已经足够收敛,都是经过 BitmapFactory 来实现,所以 BitmapFactory 是一个很是不错的 hook 点。
有了稳定的 Hook 方案和足够收敛的 Hook 点,方案的实现起来就手到擒来了,利用 dexposed 对 BitmapFactory 里的关键方法进行替换就能够了。
Android 为了适配各类不一样分辨率或者模式的设备,为开发者设计了同一资源多个配置的资源路径,app 经过 resource 获取图片资源时,自动根据设备配置加载适配的资源,但这些配置伴随着的问题就是高分辨率的设备包含低分辨率的无用图片或者低分辨率的设备包含高分辨率的无用图片。
通常状况下,针对国内应用市场,App 为了减小包大小,会选用市场占有率最高的一套 dpi(google 推荐 xxhdpi)兼容全部设备。 而针对海外应用市场的 APP,大多会经过 AppBundle 打包上传至 Google Play,可以享受动态分发 dpi 这一功能,不一样分辨率手机能够下载不一样 dpi 的图片资源,所以咱们须要提供多套 dpi 来知足全部设备。 在项目中,咱们的图片有的只有一套 dpi,有的有多套 dpi,针对上述两种场景,咱们分别在打包时合并资源、复制资源,减小了包大小。
在国内项目中,为了减小图片的占用,通常都会对市场占用率高的 dpi 进行适配,好比只保留 xxhdpi 分辨率的图片。这样就致使了两个问题,一个是市场上 2k 分辨率手机愈来愈多,若是之后手机主流分辨率是 xxxhdpi,那么项目中几千张图片修改为本会很是高。 另外一个问题是,公司很多海外产品是经过 AppBundle 打包上传到 Google Play 的,可以给不一样设备用户下发不一样 dpi 的资源。但项目中只有 xxhdpi,仍然下发 xxhdpi 的图片,没法经过下降 dpi 减少包大小。在巴西,咱们 80%用户都使用 xhdpi 和 hdpi 手机,xxhdpi 图片相比 hdpi 占用多了一倍,这部分收益至关高。
所以,咱们经过压缩分辨率的方式将高分辨率的图片下降到低分辨,项目业务只存放最高 dpi 图片,在打包的时候按需求复制筛选。 咱们在 hook 了图片压缩的 task,在图片压缩前,获取到包括依赖库在内的全部 PNG 图片,利用 Graphics2D 下降图片分辨率,放在对应分辨率文件夹中。以后再执行图片压缩 task,防止一些图片重采样后大小增长。
咱们仅对图片的分辨率进行缩放,并不下降图片采样率,所以在显示效果上没有区别。 不一样 dpi 具体应该调整到多少分辨率,咱们根据 Google 的定义制做了一个表格:
- | LDPI | MDPI | HDPI | XHDPI | XXHDPI | XXXHDPI |
---|---|---|---|---|---|---|
分辨率(广泛) | 240x320 | 320x480 | 480x800 | 720x1280 | 1080x1920 | 2k |
倍率 | 3 | 4 | 6 | 8 | 12 | 16 |
咱们复制一张 xxhdpi 的默认 logo 到全部 dpi,流程以下图,xhdpi 和 mdpi 文件夹下没有对应图片,复制;在 hdpi 中有对应图片,跳过;xxxhdpi 也没有对应图片,但为了不下降图片精度,不能向更高分辨率文件夹复制,跳过。
最终收益如图,公司内海外产品 TikTok 研发团队在使用该方案优化时,ldpi 相比 xxhdpi 减小了 2.5M 包大小。同时,低分辨率手机加载图片时直接加载对应 dpi 图片资源,再也不须要对高分辨率图片进行缩放处理,提升了性能。
在复制时须要注意这些问题: 为了处理包括依赖库中的全部图片,在资源合并阶段进行了复制,这样会致使.cache 目录的不少路径下会多出大量图片资源,所以这个插件咱们在 CI 上开启,避免本地打包新增大量图片,提交到代码仓库。同时,因为.cache 中被复制了多份图片,须要在 assemble 打包流程中进行多 dpi 去重。 在 CI 上会有并发场景,同时复制和压缩会致使.cache 目录下同时存在 a.png 和 a.webp,出现 Duplicated 错误,所以最后须要扫描删除同名的.png 文件。
针对普通打包模式(直接产出 apk,好比抖音包),咱们能够选择只保留一份分辨率偏高的的图片,这样高分辨率设备能够拿到合适的图片、低分辨率设备经过 Resource 获取时会自动进行缩放,依然能够保证合理的运行内存。
多 dpi 图片能够经过 Android 自带的 resConfig 去重,但这个配置只对资源的 qualifier 去重,好比对像素密度和屏幕尺寸不会同时作去重,抖音使用基于 AndResguard 修改的方式对 drawable 去重,能够定义不一样配置的优先级和做用范围。 根据优化配置确保留一份资源,优化方式以下图(灰色数据表示会被删除):
随着项目的迭代,项目中不免会出现相同的资源被重复添加到资源路径中,对于这类文件,人工处理确定是不可行的,能够在打包阶段自动去重。
抖音选择在 AndResguard 阶段对全部的资源进行分析,对 md5 相同的资源文件保留一份,删除其他的重复的文件,而后在 AndResguard 写入 arsc 文件时进行将删除的资源文件对应的资源路径指向惟一保留的一份资源文件。 优化方式以下图:
下图是抖音 511 版本接入多 dpi 去重与重复资源合并功能的优化结果:
MD5 文件去重 |
DPI 图片去重 |
MD5 文件去重减小文件数量 |
MD5 文件去重减小文件整体积 |
DPI 图片文件去重减小文件数量 |
DPI 图片文件去重减小文件整体积 |
apk 大小 |
相比于原始包减小 |
---|---|---|---|---|---|---|---|
false |
false |
- |
- |
- |
- |
85,030,636 |
- |
true |
false |
171 |
156.6KB |
- |
- |
84,883,829 |
143KB |
true |
true |
171 |
156.6KB |
391 |
312.9KB |
84,507,008 |
511KB |
false |
true |
- |
- |
422 |
434.5KB |
84,523,236 |
495KB |
true |
true(配置全开) |
171 |
156.6KB |
463 |
465.4KB |
84,352,272 |
662KB |
随着项目的开发迭代,咱们会有许多资源已经再也不使用了,但仍然存在于项目中。虽然咱们可使用公司开源的字节码插件开发平台 ByteX 开发插件在 ProGuard 以前扫描出一些无用资源,但由于这一步没有通过无用代码删除,所以扫描出的结果并不全。而 shrinkResources 是 google 官方提供的优化此类无用资源的方法,它运行在 Proguard 以后,能标记全部无用资源并将其优化。
抖音 Android 在开启 shrinkResources 严格模式后,shrink 资源数 600+,收益大小 0.57MB。
shrinkResources 是由 Google 官方提供的工具,所以详细的接入方式参考 Google Developer 上的文档便可。
默认状况下,Resource shrink 是 safe 模式的,即其会帮助咱们识别相似 val name = String.format("img_%1d", angle + 1)
val res = resources.getIdentifier(name, "drawable", packageName)
这样模式的代码,从而保证咱们在反射调用资源文件的时候,也是可以安全返回资源的。 从源码来看,Resource shrink 时会帮助咱们识别如下五种状况:
而 Resource shrink 使用了一种最笨但却最安全的方法去获取匹配的前缀/后缀字符串,那就是将应用中全部的字符串都认为是可能的前缀/后缀匹配字符串。
因此这就形成了在安全模式下,不当心被某个字符串所匹配到的资源,即便没有被使用也会被保留下来。以咱们的项目为例,在 com.ss.android.ugc.aweme.utils.PatternUtils
中,咱们有如下代码:
在安全模式下,这就形成了全部以 tt
开头的无用资源都不会被 shrink 掉(这也就是为何严格模式一开,ttlive_
开头的无用资源那么多的缘由)。
而严格模式打开后,其做用即是强行关闭这一段的字符匹配的过程:
固然这也就形成了咱们在使用 getIdentifier()
的时候是不安全的,由于严格模式下是不会匹配任何字符串的,因此在开启严格模式以后,必定要严格检查全部被 shrink 的资源,是否有本身须要反射的资源!
AppBundle 是 Google 近年来力推的一个功能,它可以让咱们的 apk 按照不一样的维度生成下发,也提供了一个动态下发功能的方式,Dynamic Feature。可是若是咱们在开启 Dynamic Feature 以后使用 shrinkResources,则提示如下错误:
由此看来 Google 官方并不支持 App Bundle 使用 Dynamic Feature 时使用 shrink resource。在 Google Issue Tracker 上发现已经有人对此提交过 Issue 了,相关 Issue。而 Google 的回复也是简单粗暴----计划中,可是没有时间:
可是正常来讲,若是作的好的话,咱们的 App Bundle 的 Dynamic Feature 模块是不多会引用 Master 的资源的,即便有,使用 keep.xml 的方式也能将这种资源给保留下来。所以,理论上来讲,单独对 Master 模块进行 shrinkResource 并注意反射调用的话,是没多大问题的。 Dynamic Feature 下检查 shrinkResources 配置是在 Configuring 阶段
所以咱们的想法即是在配置阶段不开启 shrinkResources 开关,而在后面执行资源处理任务的时候自行插入 shrinkResources 的 Task:
这样就能在 Dynamic Feature 下开启 shrinkResources 的 Task 了,整个代码编写十分简单,不到 50 行就能完成:
资源 id 与资源全路径的映射关系记录在 arsc 文件中,app 经过资源 id 再经过 Resource 获取对应的资源,因此对于映射关系中的资源路径作名字混淆能够达到减小包体积的效果。
抖音启用了微信开源的 AndResguard 进行资源混淆,在开源的基础上进行了增长了 MD5 去、多 DPI 只保留一份资源等优化。 因为公司内部有不少海外产品,在上架 Google Play 时须要走 aab,所以团队作了资源混淆的 aab 兼容-- aabResguard,已开源。
resources.arsc 这个文件在不少项目中都占用了至关多的空间。常见的优化方法是使用 AndResGuard 混淆减小文件名及目录长度,7z 压缩,若是有海外产品的话能够动态下发语言。 咱们在作完这些优化后,因为公司内部有不少海外产品,涉及到多语言的关系,ARSC 依然很大,咱们决定尝试进一步优化。
通过调研,最终咱们对 3 个方面作了优化,分别是删除无用 Name、合并字符串池中重复字符串、删除无用文案,最终带来的收益是 1.6MB。 在此以前,咱们还在 AndResGuard 的基础上完成了重复 MD5 文件图片合并,原理是同样的。
先贴一张 arsc 结构的图,这个二进制文件的数据结构至关复杂,AndResGuard 其实只修改了这个文件的一小部分,至于更多的修改就无能为力了,因而咱们本身解析了这个文件进行分析。 网上也有很多关于这个文件格式的说明,这里就不赘述了。推荐老罗和尼古拉斯的博客以及 aapt2 源码。google 提供的 android-arscblamer 和 apktool 的代码也值得一看。
下面用一张图简单描述一下修改过程:
如图,字符串实际上是经过索引的方式来获取的,全部字符串都保存在两个字符串池中(单个 package),一个是全局字符串池,一个是 package 下的字符串池,咱们只须要修改指向全局字符串的偏移值就好了。name 和 value 所在二进制位置以下图。
AndResGuard 在今年的 7 月也增长了这个功能,咱们来看一下实现原理。 Name 对应的字符串池是 package 字符串池,因为这个字符串池中只包含全部 Name,咱们操做能够稍微暴力一点,先作一份备份,而后清空字符串池,添加一个用于替换的字符串,赋值为 [name_removed]。
首先要肯定哪些 name 是经过 getIdentifier 调用,配置成白名单。 遍历 name 项,若是不在白名单,那么把这一个 name 的偏移替换成 0,使其指向[name_removed]。 若是 name 在白名单,那么不该该删除,咱们经过备份的字符串池找到这个 name 对应的字符串,添加到字符串池中,把偏移指向对应下标便可。
抖音经过这个优化减小了包大小 70k。
value 所对应的是全局字符串池,虽然名字听起来不会有重复值,但在咱们扫描排序后发现其实有不少重复字符串(用 AppBundle 打包就不会存在这个问题) 在抖音项目中,这个字符串池里有 1k+个重复字符串,合并这些字符串是很是必要的。
咱们先遍历全部数据,而后把字符串池的重复字符串合并,记录偏移的修改,最后把须要修改的 value 的引用指向新的偏移。这个过程须要操做 arsc 数据结构的 ResValuel 和 ResTableMap,以保证全部 string 类型的值都能获得替换。
抖音经过这个优化减小了包大小 30k。
在打包过程当中,其实全部 strings.xml 中保存的字符串都是不会被优化的,随着项目逐渐变大,一些废弃文案或者下个版本才有用的文案被引入了 apk 中,咱们在 Proguard 后再次扫描,发现了 3000+个无用字符串。在公司内部的一些海外项目中,有的文案被翻译成 100 多个国家的语言,占用了极大的空间。
删除的方法和上面相似,都是指向替换的字符串所在偏移。 如图可能会存在两个不一样 name 指向同一个字符串,须要判断待删除的字符串是否还有其余引用。
不一样项目收益可能不太同样,公司内部海外项目对这些无用文案进行了替换,减小了 1.5M 包大小左右。
若是是普通的 assemble 打包,直接在 ProcessResources 过程当中获取 ap_文件中的 arsc 文件,利用咱们的工具修改便可。
若是是 AppBundle 方式打包,修改 ap_是没有用的,由于最后产物是用 aapt 以 proto 格式生成的 resources.pb 文件,要修改只能 hook aapt 过程。这个文件和 arsc 文件结构不太同样,好在咱们可使用官方提供的 Resources 类解析、生成 pb 文件,使用类似的方法修改便可。
修改效果如图:
arsc 中的偏移数组是有优化空间的,咱们会在将来尝试进行优化。 用二进制编辑器打开 arsc 文件能够发现,这样的 FF 值在文件中大量存在。
是什么致使了这样的空间浪费? 咱们能够看到下图中框选的空白,每个都表明了其字符串所在的偏移值,这里并无值,赋值 FF FF FF FF 做为默认偏移值,浪费了 4 字节空间。 某些列(configuration)可能就只有几个格子有值,如图抖音中 drawable 有 4k+张图片,有 24 列,大多数 configuration 只有几张图片,所以浪费了 4k*23*4≈380k。大体估算,抖音能够减小 1M 体积。(压缩前)
以下图 facebook 针对 arsc 文件的处理,咱们能够把一行只有一个值的 id 抽出来,单独放到一个 Resource Type 中,每个 id 只有一个值,避免了上述空间浪费状况。 但这样作修改了 ID,所以对应的代码中的 ID 也要修改,涉及了逆向 xml 以及 dex,提升了修改为本。还有一种思路是修改 aapt 源码,没有直接改 arsc 灵活。
上述就是咱们抖音 Android 端在包大小优化方面针对资源作的一些尝试和积累,力求追求极致。
咱们针对包大小优化,在其余方面还作了不少优化措施:针对 so 优化,作了 so 合并、stl 版本统1、精简导出符号表和 so 压缩等措施;针对代码优化,细化混淆规则,开发 bytex 插件进行无用代码扫描、acess 方法内联、getter/setter 方法内联、删除行号等优化措施。
除了优化措施,良好的包大小监控系统是防止包大小劣化最重要的工具,不然包大小优化措施取得的收益抵不过业务快速迭代带来的包大小增加。抖音 Android 端结合 CI、Cony 平台,开发出了一套代码合入前置检查系统,每一个分支增量超过阈值不许合入;还开发了分业务线监控包大小的工具,便于监控每一个业务线包大小增加和给各个业务线定包大小指标。
最后,抖音 Android 诚招对技术有无限热情的小伙伴。感兴趣的小伙伴均可以经过 字节跳动招聘官网查询抖音 Android 相关职位 或简历发送至 shipeiqing@bytedance.com。
抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减小80%(二)
抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减小80%(一)
欢迎关注字节跳动技术团队