写在前面
文件前面部分涉及大量证明与探索相对枯燥(但干货满满),若只关注最佳实践可跳至最后一节阅读。
Andorid 客户端项目中不可避免地使用各种图片资源,按来源分:
- 本地原始资源:
assets
、内/外部存储中的图片(如sdcard
) - 本地编译资源:
drawable
、mipmap
- 网络图片资源:通常是使用图片框架(如
Fresco
、Glide
、Picasso
等)将其下载到本地再使用
按图片类型分:
- 矢量图(
VectorDrawable
):从 PSD、SVG 转换而来根节点为<vector />
的 XML 文件 - 位图(
BitmapDrawable
):*.png/*.9.png
、*.webp
、*.gif
、*.jpg/*.jpeg
本次探讨的主题则是 APK 打包过程中需要编译处理的位图资源。
显然,这个议题是极具探讨价值的:
- 编译的图片资源会打进 APK 包中,而 APK 包的大小直接影响 APP 投放成本(这也是在头部 APP 中插件化技术经久不衰的原因之一)。
- 图片资源最终会被系统解析成
Bitmap
,而不论 Android 将 Bitmap 保存位置是放在 ART 的 Heap 还是 Native,移动端的内存始终是及其有限的,那 OOM 和 Bitmap 就总会拉扯不清。
而且,Android 最原始的方式加载编译后的图片资源,有一套自己的缩放处理规则(包括 ImageView 的 ScaleType),若处理不当则会有各种模糊与锯齿的不良表现。
注:
- 2.3及以下的 Android 版本,Bitmap 在 Native上,但生命周期管理没做好
- 之后一直到8.0之前,Bitmap 保存在虚拟机堆中,自此 Bitmap 和 OOM 结缘
- 8.0上优化了 Bitmap 内存管理重新将其移回 Native 内存
图片内存计算
我们在硬盘中看到的某个图片文件的大小是该图片的像素信息保存在某种格式容器(如 PNG )压缩后的大小,而 Android 中的图片要上屏渲染,还需将图片容器中的像素信息解析成 Bitmap
,Bitmap 所占用的内存大小和图片容器在硬盘上的大小并没有绝对的线性关联关系。
和 Bitmap 占用内存大小有直接关系的因素主要有两点:
- 图片分辨率,即宽/高方向上的像素数
- RAM 中保存每个像素点的需要用到的二进制位数
在 Android 的编译后的 Drawable 资源(即drawable-Xdpi
),系统在执行BitmapFactory#decodeResourceStream()
时,若发现图片所在目录dpi高于当前设备dpi,则会根据设备像素密度(density
)和资源所在的密度文件夹位置做一次换算,进而得到转换后的分辨率。其公式为:
举个栗子:如果有一张 72*72 的 PNG 图放在 drawble-xxhdpi
文件夹,在像素密度为 320 的设备上加载时,则其对应的 Bitmap 占用内存为:
with = 72 * (320 / (160 * 3)) = 48
height = 72 * (320 / (160 * 3)) = 48
# Android默认会使用Bitmap.Config.ARGB_8888解析,即一个像素点占4B。
size = 48 * 48 * 4B = 73728(bit)
附:
- Android 在查找资源时会使用“最近”匹配原则: 优先使用和设备 dpi 最近的文件资源(如果存在的话,这里忽略
any-dpi
)。 ImageView#setImageResource()
与android:src=@drawable/xxx
都会走到Context#getDrawable()
和BitmapFactory#decodeResourceStream()
Bitmap.Config.ARGB_8888
支持透明通道,若图片非透明可显式指定Bitmap.Config.RGB_565
解析- 可使用
adb shell wm density
查看与设置设备像素密度(一些手机支持智能分辨率,也可在设置中手动切换) - 4.4+ 设备可使用
bitmap.getAllocationByteCount();
Dump Bitmap所占内存 - 特别的,以上公式在使用图片加载框架时不生效,因为 Fresco 不论来源都不转换分辨率,Glide 会根据 ImageView 组件尺寸调整分辨率
- 根据上述公式,可推导得出结论:
- 若将低分辨率图,放入高像素密度文件夹,该图若被解析会因位图拉伸导致图片模糊(特别是在低像素密度设备上)
- 若将高分辨率图,放入低像素密度文件夹,该图若被解析会需要较大内存[注1]
注:
- [注1]:这个显然是个边界值BUG,经查询其在 AOSP 项目里 2017-04-22 04:25 的提交中被处理( Commit Id:
50954d2b4ea938d787ef5021d75f6bc02826607a
),Commit Message 为:Propagate density through AdaptiveIconDrawable and BitmapDrawable
Mipmap VS Drawable
高版本的 Android Studio 中,默认的 App 应用图标 ic_launcher.webp
是放在mipmap
文件夹下,且放了多份。
那是不是意味着 应用开发时应在 APP 中使用多套图,并将其放在 mipmap 文件夹下呢?
要探讨这个问题,首先需要弄清楚 mipmap 和 drawable 区别(参考YouTube)
提炼下结论:在 Google AppStore 中打 App Bundle 下发时,mipmap 资源会被完整下发,而drawable 只会挑选对应设备和兜底资源下发。
Google 的开发者文档中也有仅将图标置于 mipmap 的建议。
另外Google在Android4.3文档中关于 Mipmap 的描述:
这里几句虽然含糊隐晦的描述,并不容易 GET 到点,而以笔者粗浅的图形学知识理解起来:系统会对 mipmap
中的图片做纹理映射和抗锯齿优化,适用于有缩放动画场景使用(同时这也意味着会有额外开销)。
图片压缩
根据上述讨论,内存上的问题基本分析透了,而包大小的问题则更易于解决 —— 没错,正是图片压缩。
Android Studio 中提供的 Webp 转换工具,可将 *.png
图片以一种较大的压缩率转为 *.webp
格式。但除此之外,业内大名鼎鼎的TinyPNG提供了表现更为优异的图片压缩工具。TinyPNG 使用了多种压缩算法混合压缩,其中出力较大的显然是降低颜色深度,在其平台上压缩的图片颜色深度只有8bit。
然而这样需要将切图上传到 PNG 站点,内部资料外传,这无疑是不安全的,而且在一些团队可能还是高危操作涉及违规。
还好,Google Chrome 实验室基于 Node 开源了一套图片压缩工具:Squoosh,也部署了官方体验站点点我跳转。可以将其部署在本地或内网的机器中安全使用。
最佳实践
- 对于简单的几何图形,可优先考虑使用
Vector Asset
制作矢量图片,如将 SVG 转成 XML - 必须使用位图场景,可考虑使用网络加载云端图片,图片 URL 走 APP 配置下发
- 不得不放在包体中的场景:
特别的:关于第3步,通常 UI 会提供多套切图,如何准确选择你所需要的切图呢?
一、首先确定组件的逻辑像素值。
即在正确的设计稿中找到图片显示组件的逻辑尺寸。什么是正确的设计稿?当前行业 UI 画稿通常是基于 iPhoneX 的屏幕尺寸,其逻辑宽度为 375。若开发中 ImageView 的大小为24dp*24dp
,切图资源大小最大为48px*48px
,若将 48*48 的图,放到 drawable-xhdpi
下会怎样?在设备 ppi=320 的情况下,根据分辨率转换公式:
这意味着系统无需将图片缩放处理也能表现良好(即老外说的 As you See
的效果),无疑这是我们所期望的。
简言之:这一步是 找到切图尺寸是组件尺寸2倍或3倍的切图,下载下来。
二、切图下载下来后,使用 Squoosh 或 TinyPNG 等工具压缩,建议将图片颜色深度调整为8位(通常 PNG 是 32 位),再将压缩产物放入对应的文件夹
- 2倍图放入
drawble-xhdpi
- 3倍图放入
drawble-xxhdpi
注:据不完全统计,现今市场上像素密度480以上的设备已经占比较大了,为此我们可优先考虑仅保留drawble-xxhdpi
文件夹,即优先使用 3 倍切图。
以上。
2023-07-19.长沙