Android Bitmap内存模型、属性、压缩、优化

摘自
https://blog.csdn.net/zengfenliang/article/details/83116638
https://www.cnblogs.com/shakinghead/p/11025805.html
https://www.jianshu.com/p/3f6f6e4f1c88

先说优化内存方案:
ARGB_8888-》RGB_565;
采样率;
webP格式;
内存复用。

背景

在Android开发中,任何一个APP都离不开图片的加载和显示问题。这里的图片来源分为三种:项目图片资源文件(一般为res/drawable目录下的图片文件)、手机本地图片文件、网络图片资源等。图片的显示我们一般采用ImageView作为载体,通过ImageView的相应API即可设置其显示的图片内容。

我们知道:如果是需要展示项目中的图片资源文件,我们只需要调用ImageView的setImageResource(int id)方法并传入该图片资源的id(一般为R.drawable.xxx)即可。但是如果是需要展示手机本地的某张图片或者网络上的某个图片资源,又该怎么办呢?——问题A

为了回答问题A,我们先思考一个更深的问题B:Android中是如何将某一张图片的内容加载到内存中继而由ImageView显示的呢?

我们知道:如果我们想通过TextView展示一个本地txt文件的内容,我们只需要由该文件创建并包装一个输入流对象。通过该输入流对象即可得到一个代表该文件内容的字符串对象,再将该字符串对象交由TextView展示即可。换句话说,这个txt文件的内容在内存中的表达形式就是这个字符串对象。

类推一下,虽然图片文件也是文件,但是我们显然不可能对图片文件也采用这种方式:即通过该图片建立并包装一个输入流对象再获取一个字符串对象。毕竟无论如何我们都无法将某个图片的内容表示为一个字符串对象(细想一下就知道了,你能通过一段话100%准确地描述一张图片吗?显然不现实)。那么,这就引入了问题C:既然字符串对象不行,那么我们该以哪种对象来在内存中表示某个图片的内容呢?答案就是:Bitmap对象!

基本概述

Bitmap,即位图。它本质上就是一张图片的内容在内存中的表达形式。那么,Bitmap是通过什么方式表示一张图片的内容呢?

Bitmap原理:从纯数学的角度,任何一个面都由无数个点组成。但是对于图片而言,我们没必要用无数个点来表示这个图片,毕竟单独一个微小的点人类肉眼是看不清的。换句话说,由于人类肉眼的能力有限,我们只需要将一张图片表示为 有限但足够多的点即可。点的数量不能无限,因为无限的点信息量太大无法存储;但是点的数量也必须足够多,否则视觉上无法形成连贯性。这里的点就是像素。比如说,某个1080*640的图片,这里的像素总数即为1080X640个。

将图片内容表示为有限但足够多的像素的集合,这个“无限→有限”的思想极其迷人。所以,我们只需要将每个像素的信息存储起来,就意味着将整个图片的内容进行了表达。

像素信息:每个像素的信息,无非就是ARGB四个通道的值。其中,A代表透明度,RGB代表红绿蓝三种颜色通道值。每个通道的值范围在0~255之间,即有256个值,刚好可以通过一个字节(8bit)进行表示。所以,每个通道值由一个字节表示,四个字节表示一个像素信息,这似乎是最好的像素信息表示方案。

但是这里忽略了两个现实的需求问题:

①在实际需求中,我们真的需要这么多数量的颜色吗?上述方案是256X256X256种。有的时候,我们并不需要这么丰富的颜色数量,所以可以适当减少表示每个颜色通道的bit位数。这么做的好处是节省空间。也就是说,每个颜色通道都采用8bit来表示是代表全部颜色值的集合;而我们可以采用少于8bit的表示方式,尽管这会缺失一部分颜色值,但是只要颜色够用即可,并且这还可以节省内存空间。

②我们真的需要透明度值吗?如果我们需要某个图片作为背景或者图标,这个图片透明度A通道值是必要的。但是如果我们只是普通的图片展示,比如拍摄的照片,透明度值毫无意义。细想一下,你希望你手机自拍的照片透明或者半透明吗?hell no! 因此,透明度这个通道值是否有必要表示也是根据需求自由变化的。

具体每个像素点存储ARGB值的方案介绍,后面会详细介绍。

总结:Bitmap对象本质是一张图片的内容在内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。

现在回答一下问题A和问题B:Android就是将所有的图片资源(无论是何种来源)的内容以Bitmap对象的形式加载到内存中,再通过ImageView的setImageBitmap(Bitmap b)方法即可展示该Bitmap对象所表示的图片内容。

Bitmap详细信息

1、Bitmap.Config
Config是Bitmap的一个枚举内部类,它表示的就是每个像素点对ARGB通道值的存储方案。取值有以下四种:
ARGB_8888:这种方案就是上面所说的每个通道值采8bit来表示,每个像素点需要4字节的内存空间来存储数据。该方案图片质量是最高的,但是占用的内存也是最大的
ARGB_4444:这种方案每个通道都是4位,每个像素占用2个字节,图片的失真比较严重。一般不用这种方案。
RGB_565:这种方案RGB通道值分别占5、6、5位,但是没有存储A通道值,所以不支持透明度。每个像素点占用2字节,是ARGB_8888方案的一半。
ALPHA_8:这种方案不支持颜色值,只存储透明度A通道值,使用场景特殊,比如设置遮盖效果等。

比较分析:一般我们在ARGB_8888方式和RGB_565方式中进行选取:不需要设置透明度时,比如拍摄的照片等,RGB_565是个节省内存空间的不错的选择;既要设置透明度,对图片质量要求又高,就用ARGB_8888。

2、Bitmap的压缩存储

Bitmap是图片内容在内存中的表示形式,那么如果想要将Bitmap对象进行持久化存储为一张本地图片,需要对Bitmap对象表示的内容进行压缩存储。根据不同的压缩算法可以得到不同的图片压缩格式(简称为图片格式),比如GIF、JPEG、BMP、PNG和WebP等。这些图片的(压缩)格式可以通过图片文件的后缀名看出。

换句话说:Bitmap是图片在内存中的表示,GIF、JPEG、BMP、PNG和WebP等格式图片是持久化存储后的图片。内存中的Bitmap到磁盘上的GIF、JPEG、BMP、PNG和WebP等格式图片经过了”压缩”过程,磁盘上的GIF、JPEG、BMP、PNG和WebP等格式图片到内存中的Bitmap经过了“解压缩”的过程。

那么,为什么不直接将Bitmap对象进行持久化存储而是要对Bitmap对象进行压缩存储呢?这么做依据的思想是:当图片持久化保存在磁盘上时,我们应该尽可能以最小的体积来保存同一张图片的内容,这样有利于节省磁盘空间;而当图片加载到内存中以显示的时候,应该将磁盘上压缩存储的图片内容完整地展开。前者即为压缩过程,目的是节省磁盘空间;后者即为解压缩过程,目的是在内存中展示图片的完整内容。

3、有损压缩和无损压缩

Bitmap压缩存储时的算法有很多种,但是整体可分为两类:有损压缩和无损压缩。

①有损压缩
有损压缩的基本依据是:人的眼睛对光线的敏感度远高于对颜色的敏感度,光线对景物的作用比颜色的作用更为重要。有损压缩的原理是:保持颜色的逐渐变化,删除图像中颜色的突然变化。生物学中的大量实验证明,人类大脑会自发地利用与附近最接近的颜色来填补所丢失的颜色。有损压缩的具体实现方法就是删除图像中景物边缘的某些颜色部分。当在屏幕上看这幅图时,大脑会利用在景物上看到的颜色填补所丢失的颜色部分。利用有损压缩技术,某些数据被有意地删除了,并且在图片重新加载至内存中时这些数据也不会还原,因此被称为是“有损”的。有损压缩技术可以灵活地设置压缩率。
无可否认,利用有损压缩技术可以在位图持久化存储的过程中大大地压缩图片的存储大小,但是会影响图像质量,这一点在压缩率很高时尤其明显。所以需要选择恰当的压缩率。

②无损压缩
无损压缩的基本原理是:相同的颜色信息只需保存一次。具体过程是:首先会确定图像中哪些区域是相同的,哪些是不同的。包括了重复数据的区域就可以被压缩,只需要记录该区域的起始点即可
从本质上看,无损压缩的方法通过删除一些重复数据,也能在位图持久化存储的过程中减少要在磁盘上保存的图片大小。但是,如果将该图片重新读取到内存中,重复数据会被还原。因此,无损压缩的方法并不能减少图片的内存占用量,如果要减少图片占用内存的容量,就必须使用有损压缩方法。
无损压缩方法的优点是能够比较好地保存图像的质量,但是相对来说这种方法的压缩率比较低。

对比分析:有损压缩压缩率高而且可以灵活设置压缩率,并且删除的数据不可还原,因此可以减少图片的内存占用,但是对图片质量会有一定程度的影响;无损压缩可以很好地保存图片质量,也能保证一定的压缩率虽然没有有损压缩那么高,并且无损压缩删除的数据在重新加载至内存时会被还原,因此不可以减少图片的内存占用。

4、位深与色深
我们知道了图片在内存中和在磁盘上的两种不同的表示形式:前者为Bitmap,后者为各种压缩格式。这里介绍一下位深与色深的概念:
①色深
色深指的是每一个像素点用多少bit来存储ARGB值,属于图片自身的一种属性。色深可以用来衡量一张图片的色彩处理能力(即色彩丰富程度)。
典型的色深是8-bit、16-bit、24-bit和32-bit等。
上述的Bitmap.Config参数的值指的就是色深。比如ARGB_8888方式的色深为32位,RGB_565方式的色深是16位。
②位深
位深指的是在对Bitmap进行压缩存储时存储每个像素所用的bit数,主要用于存储。由于是“压缩”存储,所以位深一般小于或等于色深 。
举个例子:某张图片100像素*100像素 色深32位(ARGB_8888),保存时位深度为24位,那么:
该图片在内存中所占大小为:100 * 100 * (32 / 8) Byte
在文件中所占大小为 100 * 100 * ( 24/ 8 ) * 压缩率 Byte

5、常见的压缩格式
Bitmap的压缩格式就是最终持久化存储得到的图片格式,一般由后缀名即可看出该图片采用了何种压缩方式。不同的压缩方式的压缩算法不一样。常见的主要有:
①Gif
Gif是一种基于LZW算法的无损压缩格式,其压缩率一般在50%左右。Gif可插入多帧,从而实现动画效果。因此Gif图片分为静态GIF和动画GIF两种GIF格式。
由于Gif以8位颜色压缩存储单个位图,所以它最多只能用256种颜色来表现物体,对于色彩复杂的物体它就力不从心了。因此Gif不适合用于色彩非常丰富的图片的压缩存储,比如拍摄的真彩图片等。
②BMP
BMP是标准图形格式,它是包括Windows在内多种操作系统图像展现的终极形式。其本质就是Bitmap对象直接持久化保存的位图文件格式,由于没有进行压缩存储,因此体积非常大,故而不适合在网络上传输。同时也是因为这种格式是对Bitmap对象的直接存储而没有进行压缩,因此我们在讨论压缩格式时往往忽略这一种。
③PNG
PNG格式本身的设计目的是替代GIF格式,所以它与GIF 有更多相似的地方。PNG格式也属于无损压缩,其位深为32位,也就是说它支持所有的颜色类型。
同样是无损压缩,PNG的压缩率高于Gif格式,而且PNG支持的颜色数量也远高于Gif,因此:如果是对静态图片进行无损压缩,优先使用PNG取代Gif,因为PNG压缩率高、色彩好;但是PNG不支持动画效果。所以Gif仍然有用武之地。
PNG缺点是:由于是无损压缩,因此PNG文件的体积往往比较大。如果在项目中多处使用PNG图片文件,那么在APP瘦身时需要对PNG文件进行优化以减少APP体积大小。具体做法后面会详细介绍。
④JPEG
JPEG是一种有损压缩格式,JPEG图片以24位颜色压缩存储单个位图。也就是说,JPEG不支持透明通道。JPEG也不支持多帧动画。
因为是有损压缩,所以需要注意控制压缩率以免图片质量太差。
JPG和JPEG没有区别,全名、正式扩展名是JPEG。但因DOS、Windows95等早期系统采用的8.3命名规则只支持最长3字符的扩展名,为了兼容采用了.jpg。也因历史习惯和兼容性的考虑,.jpg目前更流行。

JPEG2000作为JPEG的升级版,其压缩率比JPEG高约30%左右,同时支持有损和无损压缩。JPEG2000格式有一个极其重要的特征在于它能实现渐进传输,即先传输图像的轮廓,然后逐步传输数据,不断提高图像质量,让图像由朦胧到清晰显示。此外,JPEG2000还支持所谓的“感兴趣区域”特性,也就是可以任意指定影像上感兴趣区域的压缩质量;另外,JPEG2000还可以选择指定的部分先解压缩来加载到内存中。JPEG2000和JPEG相比优势明显,且向下兼容,因此可取代传统的JPEG格式。
⑤WebP
WebP 是 Google 在 2010 年发布的图片格式,希望以更高的压缩率替代 JPEG。它用 VP8 视频帧内编码作为其算法基础,取得了不错的压缩效果。WebP支持有损和无损压缩、支持完整的透明通道、也支持多帧动画,并且没有版权问题,是一种非常理想的图片格式。WebP支持动图,基本取代gif。

WebP不仅集成了PNG、JPEG和Gif的所有功能,而且相同质量的无损压缩WebP图片体积比PNG小大约26%;如果是有损压缩,相同质量的WebP图片体积比JPEG小25%-34%。

很多人会认为,既然WebP功能完善、压缩率更高,那直接用WebP取代上述所有的图片压缩格式不就行了吗?其实不然,WebP也有其缺点:我们知道JPEG是有损压缩而PNG是无损压缩,所以JPEG的压缩率高于PNG;但是有损压缩的算法决定了其压缩时间一定是高于无损压缩的,也就是说JPEG的压缩时间高于PNG。而WebP无论是无损还是有损压缩,压缩率都分别高于PNG和JPEG;与其相对应的是其压缩时间也比它们长的多。经测试,WebP图片的编码时间比JPEG长8倍。可以看出,时间和空间是一对矛盾;如果想要节省更多的空间,必然要付出额外的时间;如果想要节省时间,那么必然要付出空间的代价。这取决于我们在实际中对于时空不同的需求程度来做出选择。

不管怎么说,WebP还是一种强大的、理想的图片压缩格式,并且借由 Google 在网络世界的影响力,WebP 在几年的时间内已经得到了广泛的应用。看看你手机里的 App:微博、微信、QQ、淘宝等等,每个 App 里都有 WebP 的身影。

另外,WebP是Android4.0才引入的一种图片压缩格式,如果想要在Android4.0以前的版本支持WebP格式的图片,那么需要借助于第三方库来支持WebP格式图片,例如:webp-android-backport函数库,该开源项目在GitHub地址为:https://github.com/alexey-pelykh/webp-android-backport 当然考虑到一般的Android开发中只需要向下兼容到Android4.0即可,所以也可以忽略这个问题。

目前来说,以上所述的五种压缩格式,Android操作系统都提供了原生支持;但是在上层能直接调用的编码方式只有 JPEG、PNG、WebP 这三种。具体的,可以查看Bitmap类的枚举内部类CompressFormat类的枚举值来获取上层能调用的图片编码方式。你会发现枚举值也是JPEG、PNG和WEBP三种。
如果我们想要在应用层使用Gif格式图片,需要自行引入第三方函数库来提供对Gif格式图片的支持。不过一般我们用WebP取代Gif。
因此,我们只需要比较分析PNG、JPEG、WebP这三种压缩格式即可。

比较分析:
①对于摄影类等真彩图片:因为我们对这类色彩丰富的图片的透明度没有要求(一般默认为不透明),可以采用JPEG有损压缩格式,因为JPEG本身就不支持透明度,而且因为是有损压缩,所以尽管会牺牲一丢丢照片的质量但是可以大大减少体积。如果非要采用PNG格式,那么首先因为PNG支持透明度通道,所以明明不必要的透明度值却会被存储;其次因为是无损压缩,所以压缩率不会很高从而导致保存的图片非常大!综上比较,建议采用JPEG格式,不要用PNG格式。

JPEG格式可以与Bitmap.Config参数值为RGB_565搭配使用,这是一个理想的设置。

②对于logo图标、背景图等图片:这类图片的特点是往往是有大块的颜色相同的区域,这与无损压缩的思路不谋而合(即删除重复数据)。而且这类图片对透明度是有要求的,因此可以采用PNG无损压缩格式;尽管使用PNG格式会让图片有点大,但是可以在后续进行PNG图片优化以对APP体积进行瘦身。如果非要采用JPEG格式,那么由于有损压缩的原理(利用人脑的自动补全机制),可能会随机地丢失一些线条导致最终的图片完全不是想要的效果。综上比较,建议使用PNG格式,不要用JPEG格式。

PNG格式可以与Bitmap.Config参数值为ARGB_8888搭配使用,这是一个理想的设置。

当然,以上两种情况,我们都可以使用WebP取代PNG或JPEG,如果我们想要这么做的话。如果你的项目中对空间的需求程度更高,你完全有理由这么做。但是如果你对空间需求程度还OK,你也可以选择分情况使用PNG或JPEG格式。

6、图片优化
图片优化属于Android性能优化的一种,这里主要是针对PNG图片的大小进行优化,毕竟PNG这种无损压缩格式往往会导致图片都比较大。除非你的项目已经全面支持了WebP格式,否则对PNG格式图片的优化都会是你必须考虑的一点,这有利于减少APP的体积大小。
对PNG图片进行优化的思想是:减少PNG图片的体积,常用方式有:
①无损压缩工具ImageOptim
ImageOptim是一种无损压缩工具,所以你不用担心利用该工具对PNG图片进行压缩后图片质量会受影响。它的压缩原理是:优化PNG压缩参数,移除冗余元数据以及非必需的颜色配置文件等,在不牺牲图片质量的前提下,既减少了PNG图片的大小,又提高了其加载的速度。
ImageOptim工具的网址为:https://imageoptim.com

②有损压缩工具ImageAlpha
ImageAlpha与ImageOptim是同一个作者,不过ImageAlpha属于有损压缩,因此图片质量会受到影响。所以使用ImageAlpha对PNG图片进行压缩后,必须让设计师检视一下优化后的PNG图片,以免影响APP的视觉效果。但是ImageAlpha的优点是可以极大减少PNG图片的体积大小。
ImageAlpha工具的网址为:https://pngmini.com

③有损压缩TinyPNG
前面两个工具是应用程序,TinyPNG是一个Web站点。你可以上传原PNG图片,它对PNG图片压缩后你就可以下载优化后的结果了。因为TinyPNG也是有损压缩,所以优缺点同②
TinyPNG的网址为:https://tinypng.com

以上方案都属于对PNG图片进行二次压缩(有的是有损有的是无损),我们需要在图片质量和图片大小这对矛盾中根据实际情况进行选择。

④PNG/JPEG转换为WebP
如果不想对PNG图片进行二次压缩,可以考虑直接将其替换为WebP格式的图片。另外,我们对JPEG格式的图片也可以这么替换。毕竟WebP无论是与PNG还是与JPEG格式想比,压缩后体积大小都小很多。WebP转换工具有:
智图,这是一个图片优化平台,地址为:https://zhitu.isux.us
iSparta,这是一个针对PNG图片的二次压缩和格式转换工具,地址为:https://isparta.github.io

⑤使用NinePatch格式的PNG图
.9.png图片格式简称为NinaPatch图,本质上仍然是PNG格式图片。不过它的优点是体积小、拉伸不变形,能够很好地适配Android各种机型。我们可以利用Android Studio提供的功能,右键一张PNG图片点击“create 9=Patch File”即可完成转换。
总结:无论是二次压缩还是格式转换,无论是有损二次压缩还是无损二次压缩,我们都需要根据实际需求进行方案和工具的选择。
我们已经知道了Android中图片内存中的表示形式(Bitmap)和磁盘上的表示形式(各种压缩格式),以及二者的关系(压缩和解压缩的过程)。下面具体看看Bitmap的使用方式和注意事项,毕竟磁盘上存储的图片终究还是要加载到内存中以Bitmap的形式进行展示的。

Bitmap内存模型

Android设备的内存包括本机Native内存和Dalvik(类似于JVM虚拟机)堆内存两部分。在Android 2.3.3(API级别10)及更低版本中,位图的支持像素数据存储在Native内存中。它与位图本身是分开的,Bitmap对象本身存储在Dalvik堆中。Native内存中的像素数据不会以可预测的方式释放,可能导致应用程序短暂超出其内存限制并崩溃。从Android 3.0(API级别11)到Android 7.1(API级别25),像素数据与相关Bitmap对象一起存储在Dalvik堆上,一起交由Dalvik虚拟机的垃圾收集器来进行回收,因此比较安全。

API级别----------------------- API 10- -----------API 11 ~ API 25------------API 26 +
Bitmap对象存放-------------Java heap-----------Java heap---------------Java heap
像素(pixel data)数据存放--native heap--------Java heap----------------native heap

1.在Android 2.2(API8)之前,当GC工作时,应用的线程会暂停工作,同步的GC会影响性能。而Android2.3之后,GC变成了并发的,意味着Bitmap没有引用的时候其占有的内存会很快被回收。

2.在Android 2.3.3(API10)之前,Bitmap的像素数据存放在Native内存,而Bitmap对象本身则存放在Dalvik Heap中。Native内存中的像素数据并不会以可预测的方式进行同步回收,有可能会导致内存升高甚至OOM。而在Android3.0之后,Bitmap的像素数据也被放在了Dalvik Heap中。

在Android 2.3.3(API10)之前,在Bitmap对象不再使用并希望将其销毁时,Bitmap对象自身由于保存在Dalvik堆中,所以其自身会由GC自动回收;但是由于Bitmap的像素数据保存在native内存中,所以必须由开发者手动调用Bitmap的recycle()方法来回收这些像素数据占用的内存空间。

3.在Android 2.3.3(API10)之后
由于Bitmap对象和其像素数据一起保存在Dalvik堆上,所以在其需要回收时只要将Bitmap引用置为null 就行了,不需要如此麻烦的手动释放内存操作。

当然,一般我们在实际开发中往往向下兼容到Android4.0版本,所以你懂得。

4、可以看到,最新的Android O之后,谷歌又把像素存放的位置,从java 堆改回到了 native堆。API 11的那次改动,是源于native的内存释放不及时,会导致OOM,因此才将像素数据保存到Java堆,从而保证Bitmap对象释放时,能够同时把像素数据内存也释放掉。
至于为什么Google 在8.0上改变了Bitmap像素数据的存放方式,我猜想和8.0中的GC算法调整有关系。GC算法的优化,使得Bitmap占用的大内存区域,在GC后也能够比较快速的回收、压缩,重新使用。

在Android3.0以后的版本,还提供了一个很好用的参数,叫options.inBitmap。如果你使用了这个属性,那么在调用decodeXXXX方法时会直接复用 inBitmap 所引用的那块内存。大家都知道,很多时候ui卡顿是因为gc 操作过多而造成的。使用这个属性能避免频繁的内存的申请和释放。带来的好处就是gc操作的数量减少,这样cpu会有更多的时间执行ui线程,界面会流畅很多,同时还能节省大量内存。简单地说,就是内存空间被各个Bitmap对象复用以避免频繁的内存申请和释放操作。

需要注意的是,如果要使用这个属性,必须将BitmapFactory.Options的isMutable属性值设置为true,否则无法使用这个属性。(后面也有例子)

final BitmapFactory.Options options = new BitmapFactory.Options();
        //size必须为1 否则是使用inBitmap属性会报异常
        options.inSampleSize = 1;
        //这个属性一定要在用在src Bitmap decode的时候 不然你再使用哪个inBitmap属性去decode时候会在c++层面报异常
        //BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target.
        options.inMutable = true;
        inBitmap2 = BitmapFactory.decodeFile(path1,options);
        iv.setImageBitmap(inBitmap2);
        //将inBitmap属性代表的引用指向inBitmap2对象所在的内存空间,即可复用这块内存区域
        options.inBitmap = inBitmap2;
        //由于启用了inBitmap属性,所以后续的Bitmap加载不会申请新的内存空间而是直接复用inBitmap属性值指向的内存空间
        iv2.setImageBitmap(BitmapFactory.decodeFile(path2,options));
        iv3.setImageBitmap(BitmapFactory.decodeFile(path3,options));
        iv4.setImageBitmap(BitmapFactory.decodeFile(path4,options));

补充:Android4.4以前,你要使用这个属性,那么要求复用内存空间的Bitmap对象大小必须一样;但是Android4.4 以后只要求后续复用内存空间的Bitmap对象大小比inBitmap指向的内存空间要小就可以使用这个属性了。另外,如果你不同的imageview 使用的scaletype 不同,但是你这些不同的imageview的bitmap在加载是如果都是引用的同一个inBitmap的话,这些图片会相互影响。综上,使用inBitmap这个属性的时候 一定要小心小心再小心。

2. Bitmap的内存回收

2.1 Android2.3.3之前
在Android2.3.3之前推荐使用Bitmap.recycle()方法进行Bitmap的内存回收。

备注:只有当确定这个Bitmap不被引用的时候才能调用此方法,否则会有“Canvas: trying to use a recycled bitmap”这个错误。
官方提供了一个使用Recycle的实例:使用引用计数来判断Bitmap是否被展示或缓存,判断能否被回收。

2.2 Android3.0之后
Android3.0之后,并没有强调Bitmap.recycle();而是强调Bitmap的复用:
2.2.1 Save a bitmap for later use
使用LruCache对Bitmap进行缓存,当再次使用到这个Bitmap的时候直接获取,而不用重走编码流程。
2.2.2 Use an existing bitmap

Android3.0(API 11之后)引入了BitmapFactory.Options.inBitmap字段,设置此字段之后解码方法会尝试复用一张存在的Bitmap。这意味着Bitmap的内存被复用,避免了内存的回收及申请过程,显然性能表现更佳。不过,使用这个字段有几点限制:

  • 声明可被复用的Bitmap必须设置inMutable为true;
  • Android4.4(API 19)之前只有格式为jpg、png,同等宽高(要求苛刻),inSampleSize为1的Bitmap才可以复用;
  • Android4.4(API 19)之前被复用的Bitmap的inPreferredConfig会覆盖待分配内存的Bitmap设置的inPreferredConfig;
  • Android4.4(API 19)之后被复用的Bitmap的内存必须大于需要申请内存的Bitmap的内存;
  • Android4.4(API 19)之前待加载Bitmap的Options.inSampleSize必须明确指定为1。

3. Bitmap占有多少内存?

3.1 getByteCount()
getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。

3.2 getAllocationByteCount()

API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。

public final int getAllocationByteCount() {
    if (mBuffer == null) {
        //mBuffer代表存储Bitmap像素数据的字节数组。
        return getByteCount();
    }
    return mBuffer.length;
}

3.3 getByteCount()与getAllocationByteCount()的区别

一般情况下两者是相等的;
通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。(见第5节的示例)。

4. 如何计算Bitmap占用的内存?

还记得之前我曾言之凿凿的说:不考虑压缩,只是加载一张Bitmap,那么它占用的内存 = width * height * 一个像素所占的内存。
现在想来实在惭愧:说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。

4.1 BitmapFactory.decodeResource()


    BitmapFactory.java
    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) {
                //inDensity默认为图片所在文件夹对应的密度
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }
        if (opts.inTargetDensity == 0 && res != null) {
            //inTargetDensity为当前系统密度。
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
        return decodeStream(is, pad, opts);
    }
 
    BitmapFactory.cpp 此处只列出主要代码。
    static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, 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;
            }
        }
        //原始解码出来的Bitmap;
        SkBitmap decodingBitmap;
        if (decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode)
                != SkImageDecoder::kSuccess) {
            return nullObjectReturn("decoder->decode returned false");
        }
        //原始解码出来的Bitmap的宽高;
        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);
        }    
        //源码解释为因为历史原因;sx、sy基本等于scale。
        const float sx = scaledWidth / float(decodingBitmap.width());
        const float sy = scaledHeight / float(decodingBitmap.height());
        canvas.scale(sx, sy);
        canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
        canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
        // now create the java bitmap
        return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),
            bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
    }

此处可以看出:加载一张本地资源图片,那么它占用的内存 = width * height * nTargetDensity/inDensity * nTargetDensity/inDensity * 一个像素所占的内存。

实验:将长为1024、宽为594的一张图片放在xhdpi的文件夹下,使用魅族MX3手机加载。


        // 不做处理,默认缩放。
        BitmapFactory.Options options = new BitmapFactory.Options();
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.resbitmap, options);
        Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
        Log.i(TAG, "width:" + bitmap.getWidth() + ":::height:" + bitmap.getHeight());
        Log.i(TAG, "inDensity:" + options.inDensity + ":::inTargetDensity:" + options.inTargetDensity);
 
        Log.i(TAG,"===========================================================================");
 
        // 手动设置inDensity与inTargetDensity,影响缩放比例。
        BitmapFactory.Options options_setParams = new BitmapFactory.Options();
        options_setParams.inDensity = 320;
        options_setParams.inTargetDensity = 320;
        Bitmap bitmap_setParams = BitmapFactory.decodeResource(getResources(), R.mipmap.resbitmap, options_setParams);
        Log.i(TAG, "bitmap_setParams:ByteCount = " + bitmap_setParams.getByteCount() + ":::bitmap_setParams:AllocationByteCount = " + bitmap_setParams.getAllocationByteCount());
        Log.i(TAG, "width:" + bitmap_setParams.getWidth() + ":::height:" + bitmap_setParams.getHeight());
        Log.i(TAG, "inDensity:" + options_setParams.inDensity + ":::inTargetDensity:" + options_setParams.inTargetDensity);
 
        输出:
        I/lz: bitmap:ByteCount = 4601344:::bitmap:AllocationByteCount = 4601344
        I/lz: width:1408:::height:817 // 可以看到此处:Bitmap的宽高被缩放了440/320=1.375倍
        I/lz: inDensity:320:::inTargetDensity:440 // 默认资源文件所处文件夹密度与手机系统密度
        I/lz: ===========================================================================
        I/lz: bitmap:ByteCount = 2433024:::bitmap:AllocationByteCount = 2433024
        I/lz: width:1024:::height:594 // 手动设置了缩放系数为1,Bitmap的宽高都不变
        I/lz: inDensity:320:::inTargetDensity:320

可以看出:

  1. 不使用Bitmap复用时,getByteCount()与getAllocationByteCount()的值是一致的;
  2. 默认情况下使用魅族MX3、在xhdpi的文件夹下,inDensity为320,inTargetDensity为440,内存大小为4601344;而4601344 = 1024 * 594 * (440 / 320) (440 / 320) 4。**
  3. 手动设置inDensity与inTargetDensity,使其比例为1,内存大小为2433024;2433024 = 1024 * 594 * 1 * 1 * 4。

4.2 BitmapFactory.decodeFile()

与BitmapFactory.decodeResource()的调用链基本一致,但是少了默认设置density和inTargetDensity(与缩放比例相关)的步骤,也就没有了缩放比例这一说。

除了加载本地资源文件的解码方法会默认使用资源所处文件夹对应密度和手机系统密度进行缩放之外,别的解码方法默认都不会。此时Bitmap默认占用的内存 = width * height * 一个像素所占的内存。这也就是上面4.1开头讲的需要注意场景。

4.3 一个像素占用多大内存?

Bitmap.Config用来描述图片的像素是怎么被存储的?
ARGB_8888: 每个像素4字节. 共32位,默认设置。
Alpha_8: 只保存透明度,共8位,1字节。
ARGB_4444: 共16位,2字节。
RGB_565:共16位,2字节,只存储RGB值。

5. Bitmap如何复用?

在上述我们谈到了Bitmap的复用,以及复用的限制,Google在《Managing Bitmap Memory》中给出了详细的复用Demo:

  1. 使用LruCache和DiskLruCache做内存和磁盘缓存;
  2. 使用Bitmap复用,同时针对版本进行兼容。
    此处我写一个简单的demo,机型魅族MX3,系统版本API21;图片宽1024、高594,进行Bitmap复用的实验;
BitmapFactory.Options options = new BitmapFactory.Options();
// 图片复用,这个属性必须设置;
options.inMutable = true;
// 手动设置缩放比例,使其取整数,方便计算、观察数据;
options.inDensity = 320;
options.inTargetDensity = 320;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.resbitmap, options);
// 对象内存地址;
Log.i(TAG, "bitmap = " + bitmap);
Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
 
// 使用inBitmap属性,这个属性必须设置;
options.inBitmap = bitmap;
options.inDensity = 320;
// 设置缩放宽高为原始宽高一半;
options.inTargetDensity = 160;
options.inMutable = true;
Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.resbitmap_reuse, options);
// 复用对象的内存地址;
Log.i(TAG, "bitmapReuse = " + bitmapReuse);
Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
Log.i(TAG, "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount());
 
输出:
I/lz: bitmap = android.graphics.Bitmap@35ac9dd4
I/lz: width:1024:::height:594
I/lz: bitmap:ByteCount = 2433024:::bitmap:AllocationByteCount = 2433024
I/lz: bitmapReuse = android.graphics.Bitmap@35ac9dd4 // 两个对象的内存地址一致
I/lz: width:512:::height:297
I/lz: bitmap:ByteCount = 608256:::bitmap:AllocationByteCount = 2433024
I/lz: bitmapReuse:ByteCount = 608256:::bitmapReuse:AllocationByteCount = 2433024 // ByteCount比AllocationByteCount小

可以看出:

  1. 从内存地址的打印可以看出,两个对象其实是一个对象,Bitmap复用成功;
  2. bitmapReuse占用的内存(608256)正好是bitmap占用内存(2433024)的四分之一;
  3. getByteCount()获取到的是当前图片应当所占内存大小,getAllocationByteCount()获取到的是被复用Bitmap真实占用内存大小。虽然bitmapReuse的内存只有608256,但是因为是复用的bitmap的内存,因而其真实占用的内存大小是被复用的bitmap的内存大小(2433024)。这也是getAllocationByteCount()可能比getByteCount()大的原因。

6. Bitmap如何压缩?

1、Bitmap的压缩存储与Bitmap的加载是相反的过程,通过compress()方法来实现,该方法原型为:

compress(Bitmap.CompressFormat format, int quality, OutputStream stream)

format参数表示压缩存储的格式,可选为PNG、JPEG和WEBP;quality表示压缩率,取值在0~100之间,100表示未压缩,30表示压缩为原大小的30%,但是该参数在format值为PNG时无效,因为PNG属于无损压缩无法设置压缩率;stream就是希望输出到某个位置的输出流对象,比如某个文件的输出流对象。
通过compress()方法可以将Bitmap按照指定的格式和压缩率(非PNG格式时)压缩存储到指定的位置。

质量压缩:
它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。

2 BitmapFactory.Options.inSampleSize

用这采样率属性来创建一个原Bitmap的子采样版本。这也是官方推荐的对于大位图加载的OOM问题的解决方案。其具体思想为:比如还是那张尺寸为2048像素X1024像素图片,在inSample值默认为1的情况下,我们现在已经知道它加载到内存中默认是一个2048像素X1024像素大位图了。我们可以将inSample设置为2,那么该图片加载到内存中的位图宽高都会变成原宽高的1/2,即1024像素X512像素。进一步,如果inSample值设置为4,那么位图尺寸会变成512像素X256像素,这个时候该位图所消耗的内存(假设还是ARGB_8888方式)为512X256X4/1024/1024=0.5M,可以看出从8M到0.5M,这极大的节省了内存资源从而避免了OOM错误。

内存压缩:

  • 解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。
  • 设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。

备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。
以下是设置inSampleSize值的一个示例:

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
        int reqWidth, int reqHeight) {
    // 设置inJustDecodeBounds属性为true,只获取Bitmap原始宽高,不分配内存;
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    // 计算inSampleSize值;
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    // 真实加载Bitmap;
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}
 
public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;
    if (height > reqHeight || width > reqWidth) {
        final int halfHeight = height / 2;
        final int halfWidth = width / 2;
        // 宽和高比需要的宽高大的前提下最大的inSampleSize
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }
    return inSampleSize;
}

这样使用:mImageView.setImageBitmap(
decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

备注:

  • inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。

Bitmap加载时的异步问题

如果采用AsyncTask作为我们的异步处理方案,当我们退出当前活动时,由于异步任务只依赖于UI线程所以BitmapWorkerTask任务会继续执行。正常的操作是遍历当前活动实例的对象图来释放各对象的内存以销毁该活动,但是由于当前活动实例的ImageView引用被BitmapWorkerTask对象持有,而且还是强引用关系。这会导致Activity实例无法被销毁,引发内存泄露问题。内存泄露问题会进一步导致内存溢出错误。
为了解决这个问题,我们只需要让BitmapWorkerTask类持有ImageView的弱引用即可。这样当活动退出时,BitmapWorkerTask对象由于持有的是ImageView的弱引用,所以ImageView对象会被回收,继而Activity实例得到销毁,从而避免了内存泄露问题。具体代码如下:

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
        private final WeakReference<ImageView> imageViewReference;
        private int data = 0;

        public BitmapWorkerTask(ImageView imageView) {
            // 用弱引用来关联这个imageview!弱引用是避免android 在各种callback回调里发生内存泄露的最佳方法!
            //而软引用则是做缓存的最佳方法 两者不要搞混了!
            imageViewReference = new WeakReference<ImageView>(imageView);
        }

        // Decode image in background.
        @Override
        protected Bitmap doInBackground(Integer... params) {
            data = params[0];
            return decodeSampledBitmapFromResource(getResources(), data, 100, 100);
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            //当后台线程结束后 先看看ImageView对象是否被回收:如果被回收就什么也不做,等着系统回收他的资源
            //如果ImageView对象没被回收的话,设置其显示内容即可
            if (imageViewReference != null && bitmap != null) {
                final ImageView imageView = imageViewReference.get();
                if (imageView != null) {
                    imageView.setImageBitmap(bitmap);
                }
            }
        }
    }

列表加载Bitmap时的图片显示错乱问题

我们已经知道了如何高效地加载位图以避免OOM错误,还知道了如何合理地利用异步机制来避免Bitmap加载时的ANR问题和内存泄露问题。现在考虑另一种常见的Bitmap加载问题:当我们使用列表,如ListView、GridView和RecyclerView等来加载多个Bitmap时,可能会产生图片显示错乱的问题。先看一下该问题产生的原因。以ListView为例:

①ListView为了提高列表展示内容在滚动时的流畅性,使用了一种item复用机制,即:在屏幕中显示的每个ListView的item对应的布局只有在第一次的时候被加载,然后缓存在convertView里面,之后滑动改变ListView时调用的getView就会复用缓存在converView中的布局和控件,所以可以使得ListView变得流畅(因为不用重复加载布局)。

②每个Item中的ImageView加载图片时往往都是异步操作,比如在子线程中进行图片资源的网络请求再加载为一个Bitmap对象最后回到UI线程设置该item的ImageView的显示内容。

③ 听上去①是一种非常合理有效的提高列表展示流畅性的机制,②看起来也是图片加载时很常见的一个异步操作啊。其实①和②本身都没有问题,但是①+②+用户滑动列表=图片显示错乱!具体而言:当我们在其中一个itemA加载图片A的时候,由于加载过程是异步操作需要耗费一定的时间,那么有可能图片A未被加载完该itemA就“滚出去了”,这个itemA可能被当做缓存应用到另一个列表项itemB中,这个时候刚好图片A加载完成显示在itemB中(因为ImageView对象在缓存中被复用了),原本itemB该显示图片B,现在显示图片A。这只是最简单的一种情况,当滑动频繁时这种图片显示错乱问题会愈加严重,甚至让人毫无头绪。

那么如何解决这种图片显示错乱问题呢?解决思路其实非常简单:在图片A被加载到ImageView之前做一个判断,判断该ImageView对象是否还是对应的是itemA,如果是则将图片加载到ImageView当中;如果不是则放弃加载(因为itemB已经启动了图片B的加载,所以不用担心控件出现空白的情况)。

那么新的问题出现了,如何判断ImageView对象对应的item已经改变了?我们可以采取下面的方式:

①在每次getView的复用布局控件时,对会被复用的控件设置一个标签(在这里就是对ImageView设置标签)。标签内容必须可以标识不同的item!这里使用图片的url作为标签内容,然后再异步加载图片。

②在图片下载完成后要加载到ImageView之前做判断,判断该ImageView的标签内容是否和图片的url一样:如果一样说明ImageView没有被复用,可以将图片加载到ImageView当中;如果不一样,说明ListView发生了滑动,导致其他item调用了getView从而将该ImageView的标签改变,此时放弃图片的加载(尽管图片已经被下载成功了)。

总结:解决ListView异步加载Bitmap时的图片错乱问题的方式是:为被复用的控件对象(即ImageView对象)设置标签来标识item,异步任务结束后要将图片加载到ImageView时取出标签值进行比对是否一致:如果一致意味着没有发生滑动,正常加载图片;如果不一样意味着发生了滑动,取消加载。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容