Bitmap这个“内存刺客”你要小心~

写在前面

雪糕刺客是最近被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。

而在Android中,也有这么一个内存刺客,其作为我们日常开发中经常接触的对象之一,却常常因为使用方式的不当,时不时地就会给我们有限的内存来上一个背刺,甚至毫不留情地就给我们抛出一个OOM,它,就是Bitmap

为了讲好Bitmap这个话题,本系列文章将分为上下两篇,上篇从图像基础知识出发,结合源码讲解Bitmap内存的计算方式;下篇则基于Android系统提供的API,讲解在实际开发中如何管理好Bitmap的内存,包括缩放、缓存、内存复用等,敬请期待。

本文为上篇,开始之前,先奉上的思维导图一张,方便后续复习:

从一个问题出发

假设有这么一张PNG格式的图片,其大小为15.3KB,尺寸为96x96,色深为32 bit,放到xhdpi目录下,并加载到一台dpi为480的Android设备上显示,那么请问,该图片实际会占用多大的内存?

如果你回答不了这个问题,那你就有必要深入往下读了。

压缩格式大小≠占用内存大小

首先我们要明确的是,无论是JPEG还是PNG,它们本质上都是一种压缩格式,压缩的目的是为了降低存储和传输的成本

区别就在于:

JPEG是一种有损压缩格式,压缩比大,压缩后的体积比较小,但其高压缩率是通过去除冗余的图像数据进行的,因此解压后无法还原出完整的原始图像数据。

PNG则是一种无损压缩格式,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大。

开篇问题中所特意强调的图片大小,实际指的就是压缩格式文件的大小。而问题最后所问的图片实际占用的内存,指的则是解压缩后显示在设备屏幕上的原始图像数据所占用的内存

在实际的Android开发中,我们经常直接接触到的原始图像数据,就是通过各种decode方法解码出的Bitmap对象

Bitmap即位图,它还有另外一个名称叫做点阵图,相对来说,点阵图这个名称更能表述Bitmap的特征。

指的是像素点指的是阵列。点阵图,就是以像素为最小单位构成的图,缩放会失真。每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像

那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深。

色深是什么?

色深,又叫色彩深度(Color Depth)。假设色深的数值为n,代表每个像素会采用n个二进制位来存储颜色信息**,也即2的n次方,表示的是每个像素能显示2^n种颜色

常见的色深有:

  • 1 bit:只能显示黑与白两个中的一个。因为在色深为1的情况下,每个像素只能存储2^1=2种颜色。

  • 8 bit:可以存储2^8=256种的颜色,典型的如GIF图像的色深就为8 bit。

  • 24 bit:可以存储2^24=16,777,216种的颜色。每个像素的颜色由红(Red)、绿(Green)、蓝(Blue)3个颜色通道合成,每个颜色通道用8bit来表示,其取值范围是:

    • 二进制:00000000~11111111
    • 十进制:0~255
    • 十六进制:00~FF

    这里很自然地就让人联想起Android中常用于表示颜色两种形式,即:

    • Color.rgb(float red,float green,float blue),对应十进制
    • Color.parceColor(String colorString),对应十六进制
  • 32 bit:在24位的基础上,增加多8个位的透明通道。

色深会影响图片的整体质量,我们可以来看同一张图片在不同色深下的表现:

可以看出,色深越大,能表示的颜色越丰富,图片也就越鲜艳,颜色过渡就越平滑。但相对的,图片的体积也会增加,因为每个像素必须存储更多的颜色信息

Android中与色深配置相关的类是Bitmap.Config,其取值会直接影响位图的质量(色彩深度)以及显示透明/半透明颜色的能力。在Android 2.3(API 级别 9)及更高版本中的默认配置是ARGB_8888,也即32 bit的色深,1 byte = 8 bit,因此该配置下每个像素的大小为4 byte。

位图内存 = 像素数量(分辨率) * 每个像素的大小,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率。

分辨率是什么?

如果说,色深决定了位图颜色的丰富程度,那么分辨率决定的则是位图图像细节的精细程度图像的分辨率越高,所包含的像素就越多,图像也就越清晰,同样的,它也会相应增加图片的体积

通常,我们用每一个方向上的像素数量来表示分辨率,也即水平像素数×垂直像素数,比如320×240,640×480,1280×1024等。

一张分辨率为640x480的图片,其像素数量就达到了307200,也就是我们常说的30万像素。

现在,我们明白了公式中2个变量的含义,就可以代入开篇问题中的例子来计算位图内存:

96 * 96 * 4 byte = 36864 bytes = 36KB

Bitmap提供了两个方法用于获取系统为该Bitmap存储像素所分配的内存大小,分别为:

public int getByteCount ()

public int getAllocationByteCount ()

一般情况下,两个方法返回的值是相同的。但如果我们手动重新配置了Bitmap的属性(宽、高、Bitmap.Config等),或者将BitmapFactory.Options.inBitmap属性设为true以支持其他更小的Bitmap复用其内存时,那么getAllocationByteCount ()返回的值就有可能会大于getByteCount()。

我们暂时不考虑以上两种场景,所以直接选择调用getByteCount方法 ()来获取为Bitmap分配的字节数,得到的结果是:82944 bytes = 81KB。

可以看到,getByteCount方法返回的值与我们的计算结果有差异,是我们的计算公式有问题吗?

探究getByteCount()的计算公式

为了验证我们的计算公式是否准确,我们需要深入getByteCount()方法的源码进行探究。

public final int getByteCount() {
    if (mRecycled) {
        Log.w(TAG,"Called getByteCount() on a recycle()'d bitmap! "
                + "This is undefined behavior!");
        return 0;
    }
    // int result permits bitmaps up to 46,340 x 46,340
    return getRowBytes() * getHeight();
}

可以看到,getByteCount()方法的返回值是每一行的字节数 * 高度,那么每一行的字节数又是怎么计算的呢?

public final int getRowBytes() {
   if (mRecycled) {
          Log.w(TAG,"Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!");
   }
   return nativeRowBytes(mFinalizer.mNativeBitmap);
}

正如你所见,getRowBytes()方法的实现是在Native层。先别灰心,接下来坐好扶稳了,我们省去一些不重要的步骤,乘坐飞船一路跨越Bitmap.cpp、SkBitmap.h,途径SkBitmap.cpp时稍微停下:

size_t SkBitmap::ComputeRowBytes(Config c,int width) {
    return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c),width);
}

并最终到达SkImageInfo.h:

static int SkColorTypeBytesPerPixel(SkColorType ct) {
   static const uint8_t gSize[] = {
    0,// Unknown
    1,// Alpha_8
    2,// RGB_565
    2,// ARGB_4444
    4,// RGBA_8888
    4,// BGRA_8888
    1,// kIndex_8
  };
   SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),size_mismatch_with_SkColorType_enum);

   SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
   return gSize[ct];
}

static inline size_t SkColorTypeMinRowBytes(SkColorType ct,int width) {
    return width * SkColorTypeBytesPerPixel(ct);
}

都说正确清晰的函数名有替代注释的作用,这就是优秀的典范。

让我们把目光停留在width * SkColorTypeBytesPerPixel(ct)这一行,不难看出,其计算方式是先根据颜色类型获取每个像素对应的字节数,再去乘以其宽度

那么,结合Bitmap.java的getByteCount()方法的实现,我们最终得出,系统为Bitmap存储像素所分配的内存大小 = 宽度 * 每个像素的大小 * 高度,与我们上面的计算公式一致。

公式没错,那问题究竟出在哪里呢?

其实,如果我们的图片是从磁盘、网络等地方获取的,理论上确实是按照上面的公式那样计算没错。但你还记得吗?我们在开篇的问题中,还特意强调了图片是放在xhdpi目录下的。在Android设备上,这种情况下计算位图内存,还有一个维度要考虑进来,那就是像素密度

像素密度是什么?

像素密度指的是屏幕单位面积内的像素数,称为dpi(dots per inch,每英寸点数)。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:

是不是感觉跟分辨率的概念有点像?区别就在于,前者是屏幕单位面积内的像素数,后者是屏幕上的总像素数

由于Android是开源的,任何硬件制造商都可以制造搭载Android系统的设备,因此从手表、手机到平板电脑再到电视,各种屏幕尺寸和屏幕像素密度的设备层出不穷。

为了优化不同屏幕配置下的用户体验,确保图像能在所有屏幕上显示最佳效果,Android建议应针对常见的不同的屏幕尺寸和屏幕像素密度,提供对应的图片资源。于是就有了Android工程res目录下,加上各种配置限定符的drawable/mipmap文件夹。

为了简化不同的配置,Android针对不同像素密度范围进行了归纳分组,如下:

我们通常选取中密度 (mdpi) 作为基准密度(1倍图),并保持ldpi~xxxhdpi这六种主要密度之间 3:4:6:8:12:16 的缩放比,来放置相应尺寸的图片资源。

例如,在创建Android工程时IDE默认为我们添加的ic_launcher图标,就遵循了这个规则。该图标在中密度 (mdpi)目录下的大小为48x48,在其他各种密度的目录下的大小则分别为:

  • 36x36 (0.75x) - 低密度 (ldpi)
  • 48x48(1.0x 基准)- 中密度 (mdpi)
  • 72x72 (1.5x) - 高密度 (hdpi)
  • 96x96 (2.0x) - 超高密度 (xhdpi)
  • 144x144 (3.0x) - 超超高密度 (xxhdpi)
  • 192x192 (4.0x) - 超超超高密度 (xxxhdpi)

当我们引用该图标时,系统就会根据所运行设备屏幕的dpi,与不同密度目录名称中的限定符进行比较,来选取最符合当前设备的图片资源。如果在该密度目录下没有找到合适的图片资源,系统会有对应的规则查找另外一个可能的匹配资源,并对其进行相应的缩放,以适配屏幕,由此可能造成图片有明显的模糊失真

那么,具体的查找规则是怎样的呢?

Android查找最佳匹配资源的规则

一般来说,Android会更倾向于缩小较大的原始图像,而非放大较小的原始图像。在此前提下:

  • 假设最接近设备屏幕密度的目录选项为xhdpi,如果图片资源存在,则匹配成功;
  • 如果不存在,系统就会从更高密度的资源目录下查找,依次为xxhdpi、xxxhdpi;
  • 如果还不存在,系统就会从像素密度无关的资源目录nodpi下查找;
  • 如果还不存在,系统就会向更低密度的资源目录下查找,依次为hdpi、mdpi、ldpi。

那么,当匹配到其他密度目录下的图片资源后,对于原始图像的放大或缩小,Android是怎么实现的呢?又会对加载位图所需要的内存有什么影响呢?

想解决这些疑惑,我们还是得从源码中找寻答案。

decode*方法的猫腻

众所周知,在Android中要读取drawable/mipmap目录下的图片资源,需要用到的是BitmapFactory类下的decodeResource方法:

    public static Bitmap decodeResource(Resources res,int id,Options opts) {
        ...
        final TypedValue value = new TypedValue();
        is = res.openRawResource(id,value);

        bm = decodeResourceStream(res,value,is,null,opts);
        ...
    }

decodeResource方法的主要工作,就只是调用Resource#openRawResource方法读取原始图片资源,同时传递一个TypedValue对象用于持有图片资源的相关信息,并返回一个输入流作为内部继续调用decodeResourceStream方法的参数。

    public static Bitmap decodeResourceStream(Resources res,TypedValue value,InputStream is,Rect pad,Options opts) {
        if (opts == null) {
            opts = new Options();
        }

        if (opts.inDensity == 0 && value != null) {
            final int density = value.density;
            if (density == TypedValue.DENSITY_DEFAULT) {
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }

        if (opts.inTargetDensity == 0 && res != null) {
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }

        return decodeStream(is,pad,opts);
    }

decodeResourceStream方法的主要工作,则是负责Options(解码选项)类2个重要参数inDensity和inTargetDensity的初始化,其中:

  • inDensity代表的是Bitmap的像素密度,取决于原始图片资源所存放的密度目录。
  • inTargetDensity代表的是Bitmap将绘制到的目标的像素密度,通常就是指屏幕的像素密度。

这两个参数起什么作用呢,让我们继续往下看:

public static Bitmap decodeStream(InputStream is,Rect outPadding,Options opts) {
    ···
    if (is instanceof AssetManager.AssetInputStream) {
        final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
        bm = nativeDecodeAsset(asset,outPadding,opts);
    } else {
        bm = decodeStreamInternal(is,opts);
    }
    ···
}

private static Bitmap decodeStreamInternal(InputStream is,Options opts) {
    byte [] tempStorage = null;
    if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
    return nativeDecodeStream(is,tempStorage,opts);
}

又见到熟悉的Native层方法了,让我们重新开动星际飞船再次跨越到BitmapFactory.cpp下查看:

static jobject nativeDecodeStream(JNIEnv* env,jobject clazz,jobject is,jbyteArray storage,jobject padding,jobject options) {
    ···
    bitmap = doDecode(env,bufferedStream,padding,options);
    ···
}

static jobject doDecode(JNIEnv* env,SkStreamRewindable* stream,jobject options) {
    ····
    float scale = 1.0f;
    ···
    if (env->GetBooleanField(options,gOptions_scaledFieldID)) {
        const int density = env->GetIntField(options,gOptions_densityFieldID);
        const int targetDensity = env->GetIntField(options,gOptions_targetDensityFieldID);
        const int screenDensity = env->GetIntField(options,gOptions_screenDensityFieldID);
        if (density != 0 && targetDensity != 0 && density != screenDensity) {
            scale = (float) targetDensity / density;
        }
    }
    ···
    const bool willScale = scale != 1.0f;
    ···
    int scaledWidth = decodingBitmap.width();
    int scaledHeight = decodingBitmap.height();

    if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
        scaledWidth = int(scaledWidth * scale + 0.5f);
        scaledHeight = int(scaledHeight * scale + 0.5f);
    }

    if (options != NULL) {
       env->SetIntField(options,gOptions_widthFieldID,scaledWidth);
       env->SetIntField(options,gOptions_heightFieldID,scaledHeight);
       env->SetObjectField(options,gOptions_mimeFieldID,getMimeTypeString(env,decoder->getFormat()));
    }
    ...
}

以上节选的doDecode方法的部分源码,就是Android系统如何对其他密度目录下的原始图像进行缩放的具体实现,我们来梳理一下它的执行逻辑:

  1. 首先,设置scale值也即初始的缩放比为1。
  2. 取出关键的density值以及targetDensity值,以目标像素密度/位图像素密度重新计算缩放比。
  3. 如果缩放比不再为1,则说明原始图像需要进行缩放。
  4. 取出待解码的位图的宽度,按int(scaledWidth * scale + 0.5f)计算缩放后的宽度,高度同理。
  5. 重新填充缩放后的宽高回Options。

基于以上内容,我们重新调整下我们的计算公式:

位图内存 = (位图宽度 * 缩放比) * 每个像素的大小 * (位图高度 * 缩放比) = (96 * 1.5) * 4 * (96 * 1.5) = 82944 bytes = 81KB

可以看到,这样计算得出来的结果则与Bitmap#getByteCount()返回的值一致。

总结

汇总上述的所有内容后,我们可以得出结论,即:

Android系统为Bitmap存储像素所分配的内存大小,取决于以下几个因素:

  • 色深,也即每个像素的大小,对应的是Bitmap.Config的配置。
  • 分辨率,也即像素的总数量,对应的是Bitmap的高度和宽度
  • 像素密度,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度

由此我们还衍生出其他的结论,即:

  • 图片资源放到正确的密度目录很重要,否则可能对会较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用。
  • 如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应优先提供更高密度目录下的图片资源,可以避免图片失真。

下面是我闲暇时刻整理的一些精品资料。可以有效的帮助大家掌握知识、理解原理。当然你也可以拿去查漏补缺,提升自身的竞争力。有需要的小伙伴可以点击这里查看获取方式 传送门直达 !!!里面记录许多Android 相关学习知识点。

原文地址:https://blog.csdn.net/weixin_61845324

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


更新Android SDK到3.0版本时,遇到Failed to rename directory E:\android\tools to E:\android\temp\ToolPackage.old01问题,导致无法更新,出现该问题的原因是由于3.0版本与较早的sdk版本之间文件结构有冲突,解决
Android 如何解决dialog弹出时无法捕捉Activity的back事件 在一些情况下,我们需要捕捉back键事件,然后在捕捉到的事件里写入我们需要进行的处理,通常可以采用下面三种办法捕捉到back事件: 1)重写onKeyDown或者onKeyUp方法 2)重写onBackPressed方
Android实现自定义带文字和图片的Button 在Android开发中经常会需要用到带文字和图片的button,下面来讲解一下常用的实现办法。一.用系统自带的Button实现 最简单的一种办法就是利用系统自带的Button来实现,这种方式代码量最小。在Button的属性中有一个是drawable
Android中的&quot;Unable to start activity ComponentInfo&quot;的错误 最近在做一款音乐播放器的时候,然后在调试的过程中发现一直报这个错误&quot;Unable to start activity ComponentInfo&quot;,从字面
Android 关于长按back键退出应用程序的实现最近在做一个Android上的应用,碰到一个问题就是如何实现长按back键退出应用程序。在网上查找了很多资料,发现几乎没有这样的实现,大部分在处理时是双击back键来退出应用程序。参考了一下双击back键退出应用程序的代码,网上主流的一种方法是下面
android自带的时间选择器只能精确到分,但是对于某些应用要求选择的时间精确到秒级,此时只有自定义去实现这样的时间选择器了。下面介绍一个可以精确到秒级的时间选择器。 先上效果图: 下面是工程目录: 这个控件我也是用的别人的,好像是一个老外写的,com.wheel中的WheelView是滑动控件的主
Android平台下利用zxing实现二维码开发 现在走在大街小巷都能看到二维码,而且最近由于项目需要,所以研究了下二维码开发的东西,开源的二维码扫描库主要有zxing和zbar,zbar在iPos平台上应用比较成熟,而在Android平台上主流还是用zxing库,因此这里主要讲述如何利用zxing
Android ListView的item背景色设置以及item点击无响应等相关问题 在Android开发中,listview控件是非常常用的控件,在大多数情况下,大家都会改掉listview的item默认的外观,下面讲解以下在使用listview时最常见的几个问题。1.如何改变item的背景色和按
如何向Android模拟器中导入含有中文名称的文件在进行Android开发的时候,如果需要向Android模拟器中导入文件进行测试,通过DDMS下手动导入或者在命令行下通过adb push命令是无法导入含有中文文件名的文件的。后来发现借用其他工具可以向模拟器中导入中文名称的文件,这个工具就是Ultr
Windows 下搭建Android开发环境一.下载并安装JDK版本要求JDK1.6+,下载JDK成功后进行安装,安装好后进行环境变量的配置【我的电脑】-——&gt;【属性】——&gt;【高级】 ——&gt;【环境变量】——&gt;【系统变量】中点击【新建】:变量名:CLASSPATH变量值:……
如何利用PopupWindow实现弹出菜单并解决焦点获取以及与软键盘冲突问题 在android中有时候可能要实现一个底部弹出菜单,此时可以考虑用PopupWindow来实现。下面就来介绍一下如何使用PopupWindow实现一个弹出窗。 主Activity代码:public void onCreat
解决Android中的ERROR: the user data image is used by another emulator. aborting的方法 今天调试代码的时候,突然出现这个错误,折腾了很久没有解决。最后在google上找到了大家给出的两种解决方案,下面给出这两种方法的链接博客:ht
AdvserView.java package com.earen.viewflipper; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory;
ImageView的scaleType的属性有好几种,分别是matrix(默认)、center、centerCrop、centerInside、fitCenter、fitEnd、fitStart、fitXY。 |值|说明| |:--:|:--| |center|保持原图的大小,显示在ImageVie
文章浏览阅读8.8k次,点赞9次,收藏20次。本文操作环境:win10/Android studio 3.21.环境配置 在SDK Tools里选择 CMAKE/LLDB/NDK点击OK 安装这些插件. 2.创建CMakeLists.txt文件 在Project 目录下,右键app,点击新建File文件,命名为CMakeLists.txt点击OK,创建完毕! 3.配置文件 在CMa..._link c++ project with gradle
文章浏览阅读1.2w次,点赞15次,收藏69次。实现目的:由mainActivity界面跳转到otherActivity界面1.写好两个layout文件,activity_main.xml和otherxml.xmlactivity_main.xml&lt;?xml version="1.0" encoding="utf-8"?&gt;&lt;RelativeLayout ="http://schemas..._android studio 界面跳转
文章浏览阅读3.8w次。前言:最近在找Android上的全局代理软件来用,然后发现了这两款神作,都是外国的软件,而且都是开源的软件,因此把源码下载了下来,给有需要研究代理这方面的童鞋看看。不得不说,国外的开源精神十分浓,大家相互使用当前基础的开源软件,然后组合成一个更大更强的大开源软件。好吧,废话不多说,下面简单介绍一下这两款开源项目。一、ProxyDroid:ProxyDroid功能比较强大,用到的技术也比较多,源码也_proxydroid
文章浏览阅读2.5w次,点赞17次,收藏6次。创建项目后,运行项目时Gradle Build 窗口却显示错误:程序包R不存在通常情况下是不会出现这个错误的。我是怎么遇到这个错误的呢?第一次创建项目,company Domain我使用的是:aven.com,但是创建过程在卡在了Building 'Calculator' Gradle Project info这个过程中,于是我选择了“Cancel”第二次创建项目,我还是使用相同的项目名称和项目路_r不存在
文章浏览阅读8.9w次,点赞4次,收藏43次。前言:在Android上使用系统自带的代理,限制灰常大,仅支持系统自带的浏览器。这样像QQ、飞信、微博等这些单独的App都不能使用系统的代理。如何让所有软件都能正常代理呢?ProxyDroid这个软件能帮你解决!使用方法及步骤如下:一、推荐从Google Play下载ProxyDroid,目前最新版本是v2.6.6。二、对ProxyDroid进行配置(基本配置:) (1) Auto S_proxydroid使用教程
文章浏览阅读1.1w次,点赞4次,收藏17次。Android Studio提供了一个很实用的工具Android设备监视器(Android device monitor),该监视器中最常用的一个工具就是DDMS(Dalvik Debug Monitor Service),是 Android 开发环境中的Dalvik虚拟机调试监控服务。可以进行的操作有:为测试设备截屏,查看特定进程中正在运行的线程以及堆栈信息、Logcat、广播状态信息、模拟电话_安卓摄像头调试工具