一、前言

笔者最近致力于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时进行这样的配置。需要留意的是不要对含透明区域的图片采用这种优化方案。

@GlideModule
class 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

Android系统Bitmap内存分配原理与优化的更多相关文章

  1. Android O Bitmap 内存分配

      我们知道,一般认为在Android进程的内存模型中,heap分为两部分,一部分是native heap,一部分是Dalvik heap(实际上也是native heap的一部分).   Andro ...

  2. 图片系列(6)不同版本上 Bitmap 内存分配与回收原理对比

    请点赞关注,你的支持对我意义重大. Hi,我是小彭.本文已收录到 GitHub · AndroidFamily 中.这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] ...

  3. JVM内存分配原理

    堆栈常量池等内存分配原理详解 存储的方式: 寄存器 栈(stack) 堆(heap) 静态域 常量池 非RAM存储 JAVA寄存器 最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制.  ...

  4. 深入理解golang:内存分配原理

    一.Linux系统内存 在说明golang内存分配之前,先了解下Linux系统内存相关的基础知识,有助于理解golang内存分配原理. 1.1 虚拟内存技术 在早期内存管理中,如果程序太大,超过了空闲 ...

  5. 深入Java核心 Java内存分配原理精讲

    深入Java核心 Java内存分配原理精讲 栈.堆.常量池虽同属Java内存分配时操作的区域,但其适用范围和功用却大不相同.本文将深入Java核心,详细讲解Java内存分配方面的知识. Java内存分 ...

  6. android 管理Bitmap内存 - 开发文档翻译

    由于本人英文能力实在有限,不足之初敬请谅解 本博客只要没有注明“转”,那么均为原创,转贴请注明本博客链接链接   Managing Bitmap Memory 管理Bitmap内存 In additi ...

  7. linux环境内存分配原理 mallocinfo

    Linux的虚拟内存管理有几个关键概念: Linux 虚拟地址空间如何分布?malloc和free是如何分配和释放内存?如何查看堆内内存的碎片情况?既然堆内内存brk和sbrk不能直接释放,为什么不全 ...

  8. 【转】linux环境内存分配原理 malloc info

    Linux的虚拟内存管理有几个关键概念: Linux 虚拟地址空间如何分布?malloc和free是如何分配和释放内存?如何查看堆内内存的碎片情况?既然堆内内存brk和sbrk不能直接释放,为什么不全 ...

  9. linux环境内存分配原理 mallocinfo【转】

    转自:http://www.cnblogs.com/dongzhiquan/p/5621906.html Linux的虚拟内存管理有几个关键概念: Linux 虚拟地址空间如何分布?malloc和fr ...

随机推荐

  1. [tools] 工具

    代码编辑 notepad++ 文档对比 Beyond Compare 代码阅读 source insight 代码分析 Scitools 下载 http://www.cr173.com/soft/29 ...

  2. 【转载】kvm迁移

    https://www.jianshu.com/p/60132085a3c9 kvm分静态和动态迁移,静态就是关机迁移,比较简单,动态迁移就是不关闭服务器进行迁移.静态迁移:确定虚拟机关闭 https ...

  3. 二进制部署K8S-3核心插件部署

    二进制部署K8S-3核心插件部署 5.1. CNI网络插件 kubernetes设计了网络模型,但是pod之间通信的具体实现交给了CNI往插件.常用的CNI网络插件有:Flannel .Calico. ...

  4. IT菜鸟之虚拟机VMware的使用

    虚拟机安装完成了,以下是虚拟机的使用. 双击快捷方式,打开vmware虚拟机. 点击创建新虚拟机,这里可以选择创建方式,可以点击典型并一路下一步创建,我们这里讲自定义创建. 这里选择兼容版本,大家可以 ...

  5. 串口配合DMA接收不定长数据(空闲中断+DMA接收)-(转载)

    1.空闲中断和别的接收完成(一个字节)中断,发送完成(发送寄存器控)中断的一样是串口中断: 2.空闲中断是接收到一个数据以后,接收停顿超过一字节时间  认为桢收完,总线空闲中断是在检测到在接收数据后, ...

  6. mysql基础之数据库备份和恢复实操

    一.基于二进制文件的恢复*** 1.算好要恢复数据的时间段,重定向输入到bin.sql文件中 [root@ren7 mysql]# mysqlbinlog --start-datetime=" ...

  7. HTTP状态 500 - 内部服务器错误之Could not open ServletContext resource [/db.properties]或者 [/mybatis.xml]

    报错原因是因为找不到db.properties或者mybatis.xml,但是我明明写了有.找了一下,才发现spring-dao.xml里面这两个配置文件地址有问题 Maven项目,applicati ...

  8. K8S集群etcd备份与恢复

    参考链接: K8S集群多master:Etcd v3备份与恢复 K8S集群单master:Kubernetes Etcd 数据备份与恢复 ETCD系列之一:简介:https://developer.a ...

  9. Java 程序 关于Properties 类使用Store方法时不能会覆盖以前Properties 文件的内容

    F:\\Demo.properties 文件内容: #\u65B0\u589E\u4FE1\u606F#Wed Sep 14 11:16:24 CST 2016province=广东tt=近蛋city ...

  10. 巧用Reflections库实现包扫描

    1.需求 需要扫描某个包中的某个接口的实现类的需求 2.maven 依赖引入 <dependency> <groupId>org.reflections</groupId ...