抖音 Android 端图片优化实践

本文从抖音 Android 端图片优化历程着笔,主要介绍字节自研 BDFresco 图片框架及其在抖音的最佳实践、经验沉淀、业务价值。通过分享业务视角遇到的一些问题和我们的解决思路,希望能抛砖引玉,为遇到类似困扰的伙伴们提供有价值的参考。

背景介绍

抖音为什么要持续优化图片能力

图片能力作为抖音最基础的能力之一,服务于抖音各个业务。随着抖音图文、电商、IM 等多图业务体量的增长,图片加载量级越来越大,对应的图片带宽成本也在日益增加。为了降低图片成本、提升用户浏览图片体验,需要持续不断的探索和优化图片能力,在保证图片展示质量的前提下,提升图片加载速度,降低图片整体成本,实现图片的 “好快省”

BDFresco 简介

BDFresco 是火山引擎 veImageX 团队基于开源 Fresco 拓展优化的 Android 端通用基础的网络图片框架,主要提供图片网络加载、图像解码、图片基础处理与变换、图片服务质量监控上报、自研 HEIF 软解、内存缓存策略、云控配置下发等能力,目前已覆盖到字节几乎所有 App。

下面将从抖音视角出发,介绍抖音基于 BDFresco 在图片方向做了哪些优化。

优化思路

一张网络图片完整的加载流程如下:

客户端通过网络获取业务数据,响应内容包括对应的图片数据,通过将图片 Url 数据交给 BDFresco 加载,正式开始图片的加载流程。BDFresco 会判断当前图片是否在内存缓存及磁盘缓存,若存在则执行对应解码或渲染操作,若不存在则直接走 veImageX-CDN 下载,将图片资源下载到本地后再进行解码和渲染操作。

图片加载过程不仅占用了客户端内存、存储和 CPU 等资源,也消耗了网络流量和服务端资源。

图片的加载流程本质上是一个多级缓存逻辑,可以将图片加载流程拆分成 4 大核心阶段,内存缓存、图片解码、磁盘缓存、网络加载,结合指标监控体系,分别针对各阶段进行优化:

  • 内存缓存优化:当前 Android 内存缓存命中率高达 50%,内存缓存以占用 App 宝贵的内存为代价,使得我们可以快速地访问图片;但内存缓存的存在并不会直接导致 App 的 OOM 或者卡顿情况变严重,相反,根据特定场景配置合理的内存缓存配置能够减少图片频繁的解码和内存申请,甚至可以带来 OOM 和 ANR 的优化。

  • 图片解码优化:当内存缓存失败后,图片文件会进行解码,最终以 bitmap 形式在内存中存在,目前解码后的 bitmap 平均大小为 800KB,90 分位为 5MB,99 分位更是高达夸张的 11MB,解码流程需要频繁申请内存,同时有超过 15%的图片存在一倍尺寸的浪费,对客户端的性能影响非常大,因此如何减少解码阶段的内存申请是我们需要重点解决的问题。

  • 磁盘缓存优化:尽管对比内存缓存命中率,磁盘缓存命中率只有 10%,但理论上内存中的 bitmap 在磁盘中都有对应的原始文件存在,因此想要整体缓存命中率,我们更关注磁盘缓存的优化,需要通过合理的磁盘配置,让存储空间利用率更高。

  • 网络加载优化:虽然网络阶段失败率高达 2.5%,但经过数据排查和修复,实际失败率< 0.1%,优化空间不多,考虑到网络加载是整体流程耗时最长的,耗时占了近 90%,其中主要影响为文件过大导致的加载耗时长,因此需要重点解决下发大文件问题,优化网络加载耗时。

优化过程

指标建设

在进行图片优化之前,需要对图片整体质量完成一次数据盘点,指标建设是至关重要的一步。通过建立指标系统,能够帮助我们了解图片现状、确定优化方向和评估优化后的效果。

BDFresco 提供日志上报能力,上报的图片日志经过 veImageX 云端数据清洗,最终可以在 veImageX 云端控制台查看图片质量相关指标。从触发图片加载,到内存、解码、磁盘、网络各个阶段都建立了完备的数据监控体系,覆盖各阶段加载耗时、成功率、客户端和 CDN 缓存命中率、文件大小、内存占用、大图异常监控等几百项指标。

具体举措

1 内存缓存优化

1.1 内存查找优化

内存缓存原理

BDFresco 是通过 Producer/Consumer 接口来实现图片加载的流程,例如网络数据获取、缓存数据获取、图片解码等多种工作,不同阶段由不同 Producer 实现类处理,所有的 Producer 都是一层嵌套一层,产生的结果由 Consumer 进行消费。一个简化后的图片内存缓存逻辑如下:

其中,读取内存或磁盘缓存是通过缓存 key 来进行匹配,缓存 key 是通过 Uri 做转换的,可以简单理解成 cacheKey==uri,抖音在之前上线过一个缓存 key 优化的实验:对于同个资源的不同域名,会剔除 host 和 query 参数,即 cacheKey 被简化为 scheme://path/name

优化方案

业务在进行图片加载时,BDFresco 支持传入 Uri 数组,Uri 均是同一资源,指向的是不同 veImageX-CDN 地址,实际上内部会将该批 Uri(A-B-C)识别成同一个缓存 key。

如下图所示,ABC3 个 Uri 并不完全是按照【A 全流程查找->B 全流程查找->C 全流程查找】的顺序执行,而是会先对 ABC 各进行一次内存缓存查找,再按顺序进行 ABC 的全流程查找。

由于 ABC 为同一资源,只是域名不同,在端上生成的缓存 key 一致,实际上的 ABC 各自的内存缓存查找为无效操作,由于该环节在 UI 线程执行,且抖音存在多图场景,一次滑动会触发多次图片加载逻辑,因此部分场景会导致卡顿丢帧等情况发生。

通过将多余的内存查找流程去除,对大盘帧率有明显提升。

1.2 动静图缓存拆分

抖音图片的内存缓存大小,是根据 java 堆内存大小来进行配置,默认大小为 1/8,即 32M 或者 64M。由于 Android 8 后,图片内存数据不再存储在 java 堆上,而是存在 native 堆,如果继续使用堆内存大小来进行图片内存缓存大小的配置是不合理的,因此通过将内存缓存大小*2,希望能减少解码操作,优化 OOM 和 ANR 指标。

实验后的稳定性指标显示,OOM 虽然减少了,但是问题转换成了 native 崩溃和 ANR 都显著劣化,实验并不符合预期。

图片的缓存命中率和缓存大小成正相关,缓存大小越大,命中率越高,但随着缓存大小的增大,命中率提升空间会越来越小。

结合实验结果来看,单纯增大缓存大小会导致内存水位上升,引发 ANR 和 native 崩溃问题,方案并不可行。

目前动图和静图的内存缓存使用同一块缓存块,BDFresco 的缓存管理是 LRU 的淘汰策略,如果播放动图帧数过多,很容易把静图缓存给替换掉,重新切换回来静图就需要重新解码,重新解码势必带来性能的损耗和用户体验的降低,抖音上存在较多此类场景,如 IM、个人页动静图混搭场景。

同时,考虑到直接增大内存缓存大小,命中率提升的空间不高,所以尝试将动图和静图缓存做隔离,动静图各使用一块内存缓存,能够有效地提升命中率,减少解码操作

最终实验收益:

  • 抖音通过拆分动静图缓存,单块缓存大小不变,整体缓存增大,日活显著提升,OOM 显著降低,大盘帧率显著正向。

  • 抖极通过拆分动静图缓存,单块缓存大小变为 1/2,整体缓存不变,日活显著提升,人均使用时长显著正向,OOM 显著降低,大盘帧率显著正向。

2 图片解码优化

2.1 解码格式优化

Bitmap 的内存大小 = 图片长度 * 图片宽度 * 单位像素点占用的字节数

单位像素占用的字节数由颜色模式 Bitmap.Config 决定,即 ARGB 颜色通道,主要有 6 种类型:

  • ALPHA_8:只有一个 alpha 通道,8bit,每个像素占 1Byte;

  • ARGB_4444:包含红绿蓝 alpha4 个通道,每个通道 4bit,每个像素占 2Byte;

  • ARGB_8888:包含红绿蓝 alpha4 个通道,每个通道 8bit,每个像素占 4Byte;

  • RGB_565:包含红绿蓝 3 个通道,其中红色占 5bit,绿色占 6bit,蓝色占 5bit,每个像素占 2Byte;

  • RGBA_F16:包含红绿蓝 alpha4 个通道,每个通道 8bit,每个像素占 4Byte;

  • HARDWARE:ARGB_8888 的特殊配置,Bitmap 会直接存储在显存中。

目前抖音主要使用 ARGB_8888 和 RGB_565 两种配置,ARGB_8888 支持透明通道,且颜色质量更高,RGB_565 不支持透明通道,但整体内存占用少了一半,抖音的优化思路如下:

  • 低端机默认使用 RGB_565 进行解码,减少内存占用。

  • 抖音部分图片不携带透明通道,如所有的 heic 图,但业务指定为 ARGB_8888,导致透明通道做无效占用,在内存上造成浪费,因此可以在解码阶段将不携带透明通道的图片强制降级为 RGB_565,在牺牲一定程度的颜色质量下减少近一半的内存占用和解码性能损耗。

    由于部分 bitmap 的操作如圆角、高斯模糊等依赖透明通道的渲染,若强制将无透明通道图片降级成 565,可能会导致部分业务无法正常展示,因此需要针对这类业务进行加白处理。

2.2 heif 解码内存优化

优化原理:

BDFresco 中 heic 图解码原逻辑是通过 jni 调用解码器的解码接口,返回解码后像素数据,返回到 java 层再转换成 Bitmap 对象展示。原逻辑中存在使用超大临时对象问题,会导致 java 内存开销以及 GC,优化后减少大对象创建,直接在 native 层完成 Bitmap 对象构建,预期减少 heif 图片解码耗时,提升一定流畅度。

将原有 heif 图片解码流程从:

优化为流程:

修复前:每个 heic 图片解码时使用两个大数组:

  • 图片原始数据,大小为图片文件大小,一般在 40K-700K 之间

  • 图片解码后数据:大小为图片宽*高*4,一般在 1-11M 之间

修复后:无 java 层大数组使用,只使用一个 40K-700K 的 native 层的 DirectByteBuffer 数组。减少两个 java 层大数组创建,减少 GC 发生概率以及因为大数组创建导致的 OOM 问题,从而带来流畅度以及 ANR 收益。

在抖音上开实验,性能相关指标均有显著提升:java 内存占用减少,heic 解码耗时减少,Android ANR 减少,从而显著提升图文的消费市场,带动了整体使用时长收益。

2.3 自适应控件解码

在前面,我们提到有超过 15%的图片存在一倍尺寸的浪费,导致解码阶段需要申请大量的内存,最终展示在控件上并不需要这么大的 bitmap,我们通过将图片尺寸 resize 至控件大小后进行解码,最终解码出小分辨率的 Bitmap,能够将解码内存申请极致化。

但考虑到图片浪费主要是服务端下发过大的图片,单纯在解码阶段限制大小,无法解决网络阶段的大图片问题,带宽浪费和网络加载耗时长问题仍然没有解决,因此我们将该阶段做了前置迁移,在网络加载阶段进行优化,具体方案可看 4.2 节按需缩放方案。

3 磁盘缓存优化

通过优化客户端的磁盘缓存配置来提升缓存命中率,减少图片请求量级,在提升图片加载速度的情况下,也能降低图片带宽成本。

磁盘缓存分为 3 种:主磁盘、small 磁盘、独立磁盘;各磁盘空间存在上限,采用 LRU 替换算法,目前抖音主要使用主磁盘和独立磁盘,整体流程如下:图片默认存储在主磁盘,图片被替换概率较高;若业务指定独立磁盘 cacheName,则指定图片会单独使用一个磁盘,被替换概率低。

  • 主磁盘存储空间增大:抖音 Android 端存储空间上限为 40M,考虑到该值为 fresco 的默认值,配置值主要参考当年设备的存储空间,因此可以针对存储空间较多的设备,增加图片存储配置,提升磁盘缓存命中率。

    实验结果表明:随着存储空间的增大,磁盘缓存命中率显著上涨,进一步带来图片量级的减少,当图片存储上限提升至 80M 时,Android 大盘量级-5%

  • 独立磁盘推广:针对复用率高的图片场景,推荐接入独立磁盘缓存,可以减少被其他业务图片 LRU 替换的几率,提升图片的磁盘缓存命中率。

    以 IM 表情包为例,我们拉取 IM 业务的图片缓存命中率数据分析,表情包命中率仅有 7%,对比同样使用独立磁盘的IM普通图片的 28%和个人页主态的 31%,表情包磁盘命中率偏低。

    将 IM 表情包接入独立磁盘后,表情包请求量减少 27%

4 网络加载优化

4.1 图片格式优化

常见图片格式

  • image:原图,未经过 veImageX 压缩处理。

  • JPEG:全称为 Joint Photographic Experts Group(联合图像专家组),于 1992 发布,是一种有损压缩的光栅图像文件格式,压缩率越高图片质量越差,同时不支持透明通道。

  • PNG:全称为 Portable Network Graphics(便携式网络图形),在 1997 年 3 月作为知识性 RFC 2083 发布,于 2004 年作为 ISO/IEC 标准发布,PNG 也是一种栅格图形格式,但支持无损压缩,同时也支持携带透明通道信息。

  • WebP:是一种由谷歌开发的图片格式,于 2010 年发布,支持有损压缩和无损压缩图片文件格式,提供更高的压缩率和更快的加载速度。对比 jpeg 和 png 格式,在相同图片质量的情况下,文件体积能减少 30%+,同时 WebP 图片格式还支持透明通道和动画,目前抖音 Android 所有版本均支持 Webp 格式。

  • HEIC(BVC1):基于火山引擎自研 BVC 算法进行封装的图片(17项第一,火山自研编码器在MSU大赛多项夺冠),通常的文件后缀名为 heic,对比 Webp 格式,在相同图片质量的情况下,文件体积能再减少 30%+,带宽收益更加明显。但 heic 格式也存在缺点:由于高效编码会导致解码性能损耗略有增加,但体积较小也会带来网络耗时的降低,最终总的加载耗时基本打平或略有降低,目前抖音 Android 端已全量使用自研 BVC 软解实现解码。

  • vvic:字节基于 BVC2 算法自研的图片格式,采用的是 VVC 的图片编码格式,又称 BVC2 编码格式,对比 heic 的 BVC1 压缩率更高。

heic 格式推广

当前 veImageX 平台支持最好的是 heic 编码格式,但到 22 年初,抖音 Android 端覆盖率不足 50%,直接通过提升业务的 heic 占比能够大幅减少带宽成本,提升图片加载速度。

  • JPEG->heic,大幅减少带宽成本 80%以上,加载速度提升 30%+

  • webp->heif,个人页动图平均文件大小-25.33%,加载速度提升 30%+

在做 heif 动图实验推广时,发现个人页 UI 帧率存在大幅劣化,在高低端设备均有 6-8 帧的帧率下降,实验无法上线,针对该问题,我们对 heif 动图的解码缓存逻辑进行一次优化,提出了 heif 动图独立缓存优化方案。

heif 动图独立缓存

动图原理

在图片文件下载完成解析成字节流,动图正式播放之前,BDFresco 会进行预解码,当动图正式播放时,会根据动图调度器的播放顺序将 Bitmap 渲染到屏幕上,并且在播放过程中会主动预解码下一帧,如当前需要播放第 5 帧,会同步解码第 6 帧率。其中预解码操作均在子线程中进行。

不同调度器的核心区别为:当子线程预解码速度过慢,下一帧需要播放的 Bitmap 不存在时,是继续返回当前帧重复播放,等待子线程进行解码,还是返回下一帧,直接在主线程进行解码渲染。

  • SmoothSlidingFrameScheduler:默认调度器,在子线程预解码速度跟不上播放速度时,会降低动图的播放速度,如重复播放当前帧,保证不在主线程进行解码,会导致动图播放不流程,但对页面性能非常好,不会引起卡顿。

  • DropFramesFrameScheduler:严格按照图片的时间标准进行播放,若预解解码速度太慢,则直接在主线程进行解码,以保证对应帧能够在对应时间内进行解码并且渲染到屏幕上,缺点是会在主线程进行解码,可能会引起页面的卡顿。

  • 自定义调度器:业务自定义实现 getFrameNumberToRender 接口,支持倒序播放、跳帧播放等特殊逻辑。

独立缓存

heif 动图掉帧问题经过排查,发现 heif 动图采用了一个新的播放调度逻辑 FixedSlidingHeifFrameScheduler:动图无任何预解码逻辑,在需要播放对应帧时,直接在主线程进行解码,即播放一帧解码一帧,这也导致了 Heif 动图在播放过程中需要在主线程占用大量 CPU 资源进行解码。

为什么 heif 动图必须在主线程解码呢?

对比其他动图支持任意帧解码,heif 动图采用了帧间压缩的方式,引入了 I 帧 P 帧的概念,I 帧为关键帧,包含了当前图像的完整信息,能够独立解码;P 帧为差别帧,没有完整的画面数据,只有与前一帧的画面差别的数据,无法独立进行解码,解码需要依赖前一帧数据。

由于 AndroidBDFresco 的内存缓存为 LRU 替换,Bitmap 随时有可能被回收,因此针对 Heif 动图的解码,必须严格按照动图顺序进行解码,否则会导致 Heif 动图播放过程中出现花屏绿屏等问题。

方案思考:

  • 从源头解决,优化 heif 动图的编码解码逻辑,但目前 Heif 的帧结构就决定了解码器的解码逻辑,如果需要支持指定帧解码,就得改造 Heif 编码格式,方案不可行。

  • 不在主线程进行解码,专门开一个子线程做 heif 动图的解码,主线程需要渲染某一帧的时候,就切到子线程去解码,解码完成通知主线程做渲染,但方案对 BDFresco 的解码流程改造较大,且不支持内存缓存,方案待定。

  • 抖音 Android&iOS 双端共用一个解码器,但 iOS 实验并无帧率劣化,原因在于 iOS 的图片内存缓存是可控的,不会有不符合预期的缓存释放,因此 Android 端可以尝试借鉴该思路

    给 heif 动图单独开辟一个新的内存缓存块,且对解码后的 Bitmap 进行强引用,即不会被动释放内容,也不会被其他图片 LRU 替换。方案优点在于能够完美复用老的解码逻辑,也支持子线程预解码,只需要将 Bitmap 单独缓存即可实现。

    由于 Bitmap 是强引用,缓存块也无上限,方案存在内存无限增长的可能,因此需要有一个主动释放时机,即能减少内存占用,也能保证解码顺序不被影响。因此我们尝试关联 view 的 detach 方法,当动图控件在快速滑动时,会主动释放不可见 View 上对应的 Bitmap。

经过实验,最终采取了独立缓存方案,在取得带宽收益的同时,个人页帧率无明显劣化。

4.2 按需缩放

背景

图片加载流程最终会将解码后的 bitmap 渲染在控件上,当 bitmap 大小大于控件时,实际对用户感官并无影响,图片最终展示的像素值不会超过控件占据的空间,当图片大小 >> 控件大小时:

  • 造成一定程度的带宽浪费;

  • 图片过大,客户端性能损耗严重;

  • 不同业务对同一张图片进行图片裁剪,没有考虑图片尺寸碎片化问题,导致 veImageX-CDN 缓存命中率显著下降,最终造成回源成本的暴涨。

解决方案

在图片展示时上报对应的 bitmap 和控件大小,从上报的数据来看,存在大量业务请求的图片大小远大于控件。因此,需要采用一种通用的方案,在满足图片质量的前提下,客户端提供一套控件规范,根据控件大小将图片收敛至固定大小,保证图片尺寸和展示控件基本一致,同时减少图片碎片化问题。

个人页、同城、推荐等多个业务均存在双列封面场景,这里以双列封面为例子:

收益

  • 视觉搜索场景文件大小 -83.39%,内存大小 -66.57%

  • veImageX-CDN 缓存命中率提升 + 6.99%,回源请求数减少 -23.79%

5 异常恢复

尽管前面我们对图片的加载流程做了一系列优化,但因为抖音本身图片量级大,部分业务如电商、IM 等对图片清晰度有较高的要求,且存在图片放大和长图展示等操作,业务会进行超大图加载,直接将图片直接加载进内存,单张图片内存甚至高达 100M+,无论在磁盘 IO 阶段,还是内存解码或者 Bitmap 拷贝过程中均会申请大量内存,最终导致卡顿、ANR 甚至 OOM 崩溃,因此需要一套兜底方案来解决图片 OOM 频发问题,提升图片加载的可靠性。

抖音在系统内存触顶时,会通过释放图片内存来缓解压力:监听系统内存的告警回调,根据不同级别释放不同大小的图片内存缓存,降低发生 OOM 和 ANR 的几率,但因大图存在,仍然存在大量 OOM。

OOM 兜底

内存是一个全局指标,并不能直接通过 OOM 堆栈确定异常原因,因为 OOM 发生的时候内存可能处于高水位状态,有可能申请了一个小对象就直接触发异常。但关注到崩溃中 Top5 的堆栈大部分和图片堆栈有关系,可以合理怀疑是 App 内图片频繁申请大内存导致。

因此针对高频的图片解码和内存拷贝逻辑,增加兜底逻辑,当代码发生 OOM,主动 catch,并通过清除图片占用的内存缓存来释放部分内存,降低内存水位:

  • 清除两级内存缓存,解码内存缓存+未解码内存缓存

  • 清除接入层缓存的动图预览帧

实验结果表明,尽管部分 OOM 转换成 native 崩溃,但整体影响用户大幅下降,实验符合预期。

总结

总体来看,抖音在建设了图片的全链路监控后,根据数据分析对图片加载流程做了不少优化。

  1. 提升了图片加载速度和性能

  2. 减少了图片的总成本

从收益角度来看,大致可以分为成本优化和客户端体验优化两方面。成本收益主要是图片带宽成本的降低,体验收益体现在日活和 OOM 指标上,并且随着各种优化方案推广到更多的业务线,收益也在持续增加。

本文简要介绍了抖音基于 BDFresco 的图片优化最佳实践、经验沉淀、业务收益。由于篇幅所限,本文对探索历程、具体实现等细节内容有所省略,但仍希望能给业内同仁们一点启发或者参考借鉴。目前 BDFresco 已集成到火山引擎 veImageX 产品,对行业开放使用中,如需体验抖音同款图片优化能力,可以到火山引擎 veImageX 官网申请使用。

参考:火山引擎 veImageX 提供端到端一站式的整体图片解决方案,包含图片及素材托管、图像处理与压缩、分发、客户端编解码及图片加载 SDK 全链路能力,官网地址:https://www.volcengine.com/product/imagex

本文文字及图片出自 InfoQ

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注