写点什么

Android 系统 Bitmap 内存分配原理与优化

发布于: 2021 年 07 月 07 日

一、前言


笔者最近致力于 vivo 游戏中心稳定性维护,在分析线上异常时,发现有相当一部分是由 OutOfMemory 引起。谈及 OOM,我们一般都会想到内存泄漏,其实,往往还有另外一个因素——图片,如果对图片使用不当的话,很容易吃掉大量内存,从而导致异常。


尤其是游戏中心在 2020 末~2021 初的几个重要版本,上线了很多内容相关的 feature,引入大量图片、视频列表,从而导致线上 OOM 占比上升。


在这篇文章中,笔者将讲解一张看似普通的 Bitmap 对内存的占用,介绍 Android Studio 中帮助我们分析图片占用内存的工具,举例说明流行的两大图片加载框架:Glide、Picasso 在加载图片时使用内存的不同方式,接着分析不同 drawable 目录下图片的显示策略,最后基于手机内存、版本,提出一种优化内存分配的方案。

二、查看图片内存占用


一张图片在内存占用的空间究竟有多少,普遍存在的一个误解是,图片本身在磁盘上/从网络下载下来是多大,就会占用多少的内存。这种说法是不正确的,图片占用内存的大小不取决于它本身的大小,而取决于图片库所采用的展示方式所申请的内存。


拿钢铁侠这张图片举例,它的尺寸是 350*350,可以看到在电脑磁盘上,它只占 36KB 的空间。



我们创建一个简单的 Demo,页面正中央是一个 ImageView,用于显示这张钢铁侠图片。



通过 Android Studio 进行 heap dump,从而看图片所占用的内存。首先我们将显示图片时的内存快照保存下来。操作路径为 Profiler -> Memory -> Heap Dump,这会生成一个 dump 文件,在其中可以看到当前堆的使用情况。



在下面这张图可以看到,程序运行时,“钢铁侠”这张图片占用的内存(Retained Size)是 2560000bytes,约等于 2.4MB 内存。与它在磁盘上 36KB 的大小,相差了整整 70 倍!



小技巧:如何查看 dump 文件中的图片


在调试时,如果我们手头只有一个 dump 文件,往往需要还原图片内容,以帮助定位问题。有两种方式可以从 dump 文件里提取原图片。


方式一:通过 Android Studio 直接查看


如果 dump 文件来源自 Android 版本为 7.1.1(Android N,API=25)及以下的设备,可以使用这种方法。选中 Bitmap 对象,直接在窗口的 Bitmap Preview 中查看图片内容(如上图),非常方便。


方式二:通过 MAT+GIMP 查看



这种方法适用于全部 Android 版本的设备,首先用MAT打开 dump 文件,有时会发生下图的错误:



原因是 Android Studio 的 Profiler 生成的 dump 文件不是标准格式,我们可以使用位于路径 SDK/platform-tools/hprof-conv.exe 的工具将其转换为标准格式,转换命令为:

hprof-conv.exe <in-file> <out-file>
复制代码


将转换后的 dump 文件通过 MAT 打开,在其中找到 Bitmap 对象的 byte[]属性,将其复制为 image01.data 文件。



Tip: 可以看到这里 image01.data 文件的尺寸是 2.44MB,也正是在运行时图片所占用的内存。


然后用GIMP工具打开该文件,在格式那里选择 RGBA(大部分 Bitmap 都使用这种格式),宽与高可以在 MAT 中看到,笔者这里是 800 * 800。设置好格式和宽高后,就可以看到图片的真实面目了。



二、图片内存占用计算公式


在上一章节我们知道一个通过网络下载的 36KB 图片,在被加载到内存中时,需要 2.4MB 的空间。接下来解释这其中的换算关系,让我们记住一个公式:


图片占用内存 = 图片质量 * 宽 * 高


这里面有“图片质量”、“宽”、“高”三个因素,它涉及到图片加载框架的实现,不同的框架,对于这三者的默认取值是不一样的,我们以当前最流行的 Picasso 和 Glide 为例。


Picasso


在 Picasso 中,图片默认显示的宽高与原始图片宽高一致。仍然以这张钢铁侠为例,图片本身是 350px * 350px,当我们把它加载到 200px * 200px 的 ImageView 当中时,占用空间是 0.49MB



因此,在目标 ImageView 小于图片尺寸的情况下,好的做法是使用不超过 ImageView 尺寸的图片源,一方面可以缩短图片下载时间,另一方面有助于优化内存占用。


Glide


Glide 则采用截然不同的处理方式,它最终使用的宽高是目标 ImageView 的宽高。如果我们把同样一张图片加载到 200px * 200px 的 ImageView 中,占用空间只有 0.16MB。



使 Picasso 达到与 Glide 同样的效果


Picasso 的设计者也发现了这一缺点,提供一系列方法用来调整最终加载出来的图片尺寸,其一就是 fit(),通过这个方法可以达到与 Glide 同样的效果。

Picasso().get().load(IMAGE_URL).fit().into(imageVIEW)
复制代码


相反场景:小图加载到大 ImageView 中


通常为了提供更清晰的界面,防止图片拉伸后失真模糊,设计师提供的图片都是高分辨率的,我们所面临的场景是将大图加载到小 ImageView 中。但也不排除相反的可能:将小图加载到大 ImageView 里面。这时 Glide 默认采用的内存策略是存在不足的:它采用目标 ImageView 的尺寸作为最终的宽和高。


举例说明,当把 350 * 350 的钢铁侠图片加载到 600 * 600 的 ImageView 中时,占用的内存高达 1.41MB。


600 * 600 * 4bytes = 1.41MB


有没有一种方法,可以兼顾原图片与目标 ImageView 不同的大小关系呢?——有的,这就是 centerInside()。

Glide.with(this).load(IMAGE_URL).centerInside().into(imageView)
复制代码


借助 centerInside()方法,可以达到“在原图片和目标 ImageView 中取最小宽高作为最终加载图片的尺寸”这样的效果。


三、图片质量


什么是“图片质量”?简单说就是用多少字节来表示一个像素点的颜色,它的学名叫做“位深度”,在图片属性当中可以看到。


图片位深度通常有 1 位、8 位、16 位、24 位、32 位。



PNG 格式有 8 位、24 位、32 位三种形式,其中 8 位 PNG 支持两种不同 的透明形式(索引透明和 alpha 透明),24 位 PNG 不支持透明,32 位 PNG 在 24 位基础上增加了 8 位透明通道,因此可展现 256 级透明程度。



Glide 和 Picasso 默认采用的图片质量都是 ARGB_8888、也就是带透明度的 32 位深度,一个像素点需要占用 4bytes 的内存,这也解释了为什么上文中的计算都是采用宽*高*4bytes 的公式。


注:v4 开始,Glide 将 ARGB_8888 作为默认配置。在那之前它一直默认使用 RGB_565。


对客户端使用的大部分图片来说,32 位深度、16 位深度的显示质量是肉眼较难分辨的,但它们在占用内存上相差了整整一倍。因此,笔者建议在大部分场景下,使用 RGB_565 作为加载图片的模式。以下两种场景除外:



1)含透明部分的图片:如果采用 RGB_565 图片格式来显示图片,是无法正常展现透明区域的。比如上方这个钢铁侠图片,原本透明的部分会被显示为黑色。


2)含渐变色并且对显示质量要求高的图片:32 位比 16 位可以支持更多的颜色,在渐变的显示上呈现更加自然的过渡(如下图)。这时我们应当在显示质量和应用性能之间作取舍。对于低端设备,应用的稳定性比显示质量更加重要,笔者强烈建议采用 16 位深度来显示。



四、drawable 目录下图片加载方式


项目的资源目录下,一般都有 drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxhdpi 目录,它们是用来匹配不同显示密度的设备的,对应表格如下。



通过 adb shell wm density 可以获取当前设备的 dpi,对 Nexus 6P 模拟器执行后,可以读取到它的 dpi 是 560,属于 xxxhdpi。

$ adb shell wm densityPhysical density: 560
复制代码


那么同一个图片放在不同目录下,对分配内存是否有影响呢?答案是有的,基于两步简单的推导:


  • 图片所在资源目录、设备密度两者决定图片最终显示在屏幕上的像素尺寸;

  • 像素尺寸、图片质量共同决定分配内存。


其中第 2 点已经在上文讲解过,这里主要分析第 1 点。使用图片编辑软件,将原本是 350 * 350 的钢铁侠图片放大至 700 * 700,并分别放入 xhdpi、xxxhdpi 两个目录下。


为什么使用这样的组合呢?因为从上表得知,xhdpi 与 xxxhdpi 的显示密度是 1:2,意味着一台 xxxhdpi 的设备在显示 drawable-xhdpi 目录下的图片时,会将其放大为 2 倍进行展示。因此我们将 350 * 350 的骨片放入 drawable-xhdpi,将 700 * 700 的图片放入 drawable-xxxhdpi,预期它们最终在屏幕上显示的尺寸相同。



在布局里创建两个 ImageView,观察这两张图片最终的显示效果,以及分配内存情况。


<FrameLayout    xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:background="#000000">    <!-- 350 * 350,位于drawable-xhdpi -->    <ImageView        android:id="@+id/iv_image_1"        android:layout_width="wrap_content"        android:layout_height="wrap_content"        android:padding="40dp"        android:src="@drawable/iron_man_350_square_xhdpi"        />    <!-- 700 * 700,位于drawable-xxxhdpi -->    <ImageView        android:id="@+id/iv_image_2"        android:layout_width="wrap_content"        android:layout_height="wrap_content"        android:padding="40dp"        android:layout_gravity="bottom"        android:src="@drawable/iron_man_700_square_xxxhdpi"        /></FrameLayout>
复制代码


显示效果以及内存分配如下:



可以分析得出以下结论:


对于显示尺寸 613 * 613 的图片,其占据内存为 613 * 613 * 4 = 1,503,076B ≈ 1.5MB,符合上文中我们对图片内存的分析;

决定图片占用内存的是其最终显示在屏幕上的尺寸,与图片本身分辨率、在哪个 drawable 目录下没有直接关系;

由于 xxxhdpi 密度是 xhdpi 密度的两倍,故在屏幕密度属于 xxxhdpi 的 Nexus 6P 设备上,drawable-xxxhdpi 目录下的图片被以近似于原像素尺寸(700px)进行显示(显示为 613px),而位于 drawable-xhdpi 目录下的图片被放大至 2 倍显示,最终显示尺寸同样是 613px。


五、优化策略


在实际的开发中,我们希望中高端机型加载更清晰的图片(ARGB_8888),以提升用户体验,对于低端机型则希望加载占用内存更小的图片(RGB_565),以降低 OOM 发生的概率。可以在初始化 Glide 时进行这样的配置。需要留意的是不要对含透明区域的图片采用这种优化方案。


@GlideModuleclass MyGlideModule : AppGlideModule() {    override fun applyOptions(context: Context, builder: GlideBuilder) {        builder.setDefaultRequestOptions(RequestOptions().format(getBitmapQuality()))    }    private fun getBitmapQuality(): DecodeFormat {        return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || hasLowRam()) {            // 低端机型采用RGB_565以节约内存            DecodeFormat.PREFER_RGB_565        } else {            DecodeFormat.PREFER_ARGB_8888        }    }}
复制代码


六、小结


借助一些开源工具,我们可以便捷地定位大图,如滴滴开源的DoKit,篇幅原因不进行详细介绍。最后,对于我们日常开发总结几点建议,希望大家的应用稳定性节节攀升。


  • 在多图的场景(比如 RecyclerView)注意及时释放图片资源;

  • 使用占据内存更小的图片格式;

  • 图片源文件尺寸应当与目标 ImageView 相近;

  • 优先满足 xxhdpi、xxxhdpi 的图片资源需求;

  • 根据设备性能,采用不同的图片加载策略。


作者:vivo 互联网客户端团队-Li Lei

发布于: 2021 年 07 月 07 日阅读数: 14
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Android系统 Bitmap 内存分配原理与优化