一、Bitmap的内存占用检测
Bitmap 一直以来都是 Android App 的内存消耗大户,很多 Java 甚至 native 内存问题的背后都是不当持有了大量大小很大的 Bitmap,我们可以使用Android Studio自带的Profile进行检测,由于Bitmap不会持有Context,所以,Profile无法检测出Bitmap导致的内存泄漏问题,但是重复创建Bitmap而没有及时回收,则会导致大量的内存占用,需要我们注意。对于Bitmap的内存监控,具体步骤如下:
1、进行内存Dump
在怀疑应用内存占用不正常的时间点内,点击Profile左侧的Capture heap dump,Android Studio会记录当前状态下应用中正在存活在内存中的实例对象
2、过滤Bitmap
在搜索框中过滤Bitmap,结果中会显示当前进程中所有存活的Bitmap对象,我们可以按照大小进行排序,然后一次点击其中的某一个Bitmap,从右侧的References中定位到此Bitmap的创建位置,需要注意的是,由我们调用系统API创建的Bitmap也会显示在这里,这里我们可以优先排查检测我们自己创建的Bitmap
- Shallow size就是对象本身占用内存的大小,不包含其引用的对象;
- Retained size是该对象自己的shallow size,加上从该对象能直接或间接访问到对象的shallow size之和。换句话说,retained size是该对象被GC之后所能回收到内存的总和
二、Bitmap的内存占用分析
通过上面的分析定位,可以找到内存占用较大的Bitmap,我们通常采用的方案,是对图片进行压缩,减少其内存占用情况,常用的压缩方案有以下几种,我们逐一来进行分析
我们这里采用的是一张分辨率为 4400 * 2475 的图片
1、默认大小
放到Assets文件夹下,读取后加载到ImageView中:
打印得到的内存大小为:41.54MB
private fun loadAssets() {
val bytes = assets.open("big_picture.jpg").readBytes()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
list.add(bitmap)
Log.d(TAG, "原始图片大小:${bitmap.allocationByteCount / 1024.0 / 1024.0}")
}
2、Drawable大小
把文件放到drawable文件夹下,通过R.drawable.xxx的形式来读取:
打印得到的内存大小为:280.82MB
private fun loadDefault() {
val decodeResource = BitmapFactory.decodeResource(resources, R.drawable.big_picture_default)
val byteCount = decodeResource.allocationByteCount / 1024.0 / 1024.0
Log.d(TAG, "Drawable大小:$byteCount")
}
3、Dwawable-XX大小
把文件放在drawable-xxhidp文件夹下,通过R.drawable.xxx的形式来读取:
打印得到的内存大小为:31.19MB
private fun loadXX() {
val decodeResource = BitmapFactory.decodeResource(resources, R.drawable.big_picture_xx)
val byteCount = decodeResource.allocationByteCount / 1024.0 / 1024.0
Log.d(TAG, "DrawableXX大小:$byteCount")
}
4、采样率压缩
图片宽高分别压缩为原来的一半:
打印得到的内存大小为:10.38MB
/**
* 尺寸压缩
*/
private fun sizeCompress() {
val bytes = assets.open("big_picture.jpg").readBytes()
val options = BitmapFactory.Options()
options.inSampleSize = 2
val outBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
Log.d(TAG, "尺寸压缩1/2:${outBitmap.allocationByteCount / 1024.0 / 1024.0}")
}
5、质量压缩为原来的一半:
打印得到的内存大小为:41.54MB
/**
* 质量压缩
* 它其实只能实现对file的影响,对加载这个图片出来的bitmap内存是无法节省的,还是那么大。
* 因为bitmap在内存中的大小是按照像素计算的,也就是width*height,对于质量压缩,并不会改变图片的真实的像素
*/
private fun qualityCompress() {
val bytes = assets.open("big_picture.jpg").readBytes()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, stream)
val byteArray = stream.toByteArray()
val outBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
Log.d(TAG, "质量压缩50%:${outBitmap.allocationByteCount / 1024.0 / 1024.0}")
}
6、像素压缩
RGB通道设置为RGB_565后:
打印得到的内存大小为:20.77MB
/**
* RGB通道压缩
*/
private fun rgbCompress() {
val bytes = assets.open("big_picture.jpg").readBytes()
val options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
Log.d(TAG, "RGB通道压缩RGB_565:${bitmap.allocationByteCount / 1024.0 / 1024.0}")
}
7、使用Glide设置图片:
打印得到的内存大小为:1.25MB
private fun glideCompress(){
val s = object :RequestListener<Drawable>{
override fun onLoadFailed(e: GlideException?, model: Any, target: Target<Drawable>, isFirstResource: Boolean): Boolean {
return false
}
override fun onResourceReady(resource: Drawable, model: Any?, target: Target<Drawable>, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource.intrinsicWidth
val height = resource.intrinsicHeight
resource.setBounds(0,0,width,height)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
Log.d(TAG, "Glide加载:${bitmap.allocationByteCount / 1024.0 / 1024.0}")
return false
}
}
val bytes = assets.open("big_picture.jpg").readBytes()
Glide.with(this).load(bytes).listener(s).into(img_view)
}
三、分析与总结:
1、Bitmap的计算方式:
Bitmap文件在Android设备中的计算方式为:
Bitmap内存占用 ≈ 像素数据总大小 = 横向像素数量 × 纵向像素数量 × 每个像素的字节大小
2、BitmapFactory.Options中几个关于分辨率的属性参数:
- inDensity:Bitmap位图自身的密度、分辨率
- inTargetDensity: Bitmap最终绘制的目标位置的分辨率
- inScreenDensity: 设备屏幕分辨率
其中inDensity和图片存放的资源文件的目录有关,同一张图片放置在不同目录下会有不同的值:
density | 0.75 | 1 | 1.5 | 2 | 3 | 3.5 | 4 |
densityDpi | 120 | 160 | 240 | 320 | 480 | 560 | 640 |
DpiFolder | ldpi | mdpi | hdpi | xhdpi | xxhdpi | xxxhdpi | xxxxhdpi |
3、不同分辨率文件夹的缩放:
基于以上分析,可以得到以下几个结论:
- 同一张图片,放在不同资源目录下,其分辨率会有变化,
- Bitmap存放文件夹分辨率越高,其解析后的宽高越小,甚至会小于图片原有的尺寸(即缩放),从而内存占用也相应减少
- 图片不特别放置任何资源目录时,其默认使用mdpi分辨率:160
- 资源目录分辨率和设备分辨率一致时,图片尺寸不会缩放
4、结论验证
根据第三点的结论,结合上面的实验代码进行验证处理,文件放到drawable文件夹下,系统会默认当做mdpi来进行处理,而放到xx-hdpi下,则系统会当做二倍图来处理,根据这个计算规则,UI通常给的都是二倍图,我们把图片放到drawable-xxhdpi文件夹中,则会降低图片在系统中的内存占用,而如果把二倍图片放到drawable-xxxhdpi下,则会由于缩放原因图片变得模糊不清
5、质量压缩分析
质量压缩后图片在系统中的内存大小没有变化,这是因为质量压缩只能实现对文件存储的影响,对加载这个图片出来的bitmap内存是无法节省的,还是那么大。因为bitmap在内存中的大小是按照像素计算的,也就是width*height,对于质量压缩,并不会改变图片的真实的像素
6、Glide加载分析
使用Glide加载图片占用的内存最小,这是因为Glide在加载图片时,会根据ImageView来实际计算控件真实需要的图片大小,并对原始图片做大小缩放,保证清晰度的情况下尽可能的降低图片占用的内存大小
四、Bitmap的内存优化方案
1、采用第三方加载库:
当需要匹配多种分辨率设备时,尽量采用Gilde等第三方图片加载框架进行图片加载,而不是直接使用img_view.setImageResource(R.drawable.big_picture_xx)的形式
2、图片检测分析库:
在开发阶段可以使用一些第三方的图片监控库,用来检测我们是否使用了超过实际使用宽高的图片,比如:BitmapCanary,在引用后,可以直接在图片控件中显示控件中加载的图片的宽高比例,如下图所示,上面的文字大小分别表明当前控件加载的图片宽高超过控件宽高的倍数
3、Bitmap的复用
采用Bitmap的缓存池,避免重复创建,由于Bitmap的创建不依赖于Context,因此Bitmap不会持有Context的引用,也就不会导致页面内存泄漏的问题出现,我们可以把需要经常用到的Bitmap添加到缓存池中,避免重复创建Bitmap带来的内存消耗,
注意:复用 Bitmap 之前,需要手动判断Bitmap是否可以被复用。这是因为 Bitmap 的复用有一定的限制,在Android 4.4版本之前,只能复用相同大小Bitmap的内存区域,在Android4.4以后的版本,可以重用任何 Bitmap 的内存区域,只要这块内存比将要分配内存的 bitmap 大就可以(参考:Android Develop),示例代码如下:
/**
* candidate:旧的图片,targetOptions:新的图片的Options
*/
private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
// From Android 4.4 (KitKat) onward we can re-use if the byte size of
// the new bitmap is smaller than the reusable bitmap candidate
// allocation byte count.
val width: Int = targetOptions.outWidth / targetOptions.inSampleSize
val height: Int = targetOptions.outHeight / targetOptions.inSampleSize
val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
byteCount <= candidate.allocationByteCount
} else {
// On earlier versions, the dimensions must match exactly and the inSampleSize must be 1
candidate.width == targetOptions.outWidth
&& candidate.height == targetOptions.outHeight
&& targetOptions.inSampleSize == 1
}
}
4. 合理管理图片的内存缓存
为了提高图片的加载速度,我们通常会对图片做适当的缓存,但是缓存占用的内存过大时,会导致系统频繁GC从而引发卡顿,这里,系统提供了API告诉我们可以在合理的时机释放内存,当系统回调onLowMemory()时,我们可以尝试释放缓存中的图片资源,用来释放内存
class MainApplication : Application() {
override fun onLowMemory() {
super.onLowMemory()
}
}
5、采用设备分级策略:
比如某些设备的RAM为2G,某些设备的RAM为4G,
当我们检测到设备的内存为2GB时,一般为低端机型,我们可以采用一些手段降低应用的内存占用,比如设置图片的色彩通道为RGB_565,图片内存上限为10MB等,
当我们检测到设备的内存为4G甚至6G以上时,这类设备通常为高端机型,为了用户体验,提高图片的加载速度,我们可以在此类设备中设置色彩通道为RGB_888,内存上限设置为20MB或者更大
这里为判断设备RAM的代码:
public static String getRAMInfo(Context context) {
ActivityManager activityManager = (ActivityManager) context
.getSystemService(context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memoryInfo);
long totalSize = memoryInfo.totalMem;
return Formatter.formatFileSize(context, totalSize);
}
6、合理选择像素解析:
Bitmap.Config参数:
- ALPHA_8:只有透明度,没有任何RGB值,一个像素占用1个字节
- ARGB_4444:每一个像素点由4个,A(Alpha)、R(Red)、G(Green)、B(Blue)值表示。每一个占用4位(bit),所以总共占用16bit=16/8(byte)=2byte。也即每一个像素占用2字节
- ARGB_8888:Android手机上默认的格式,同时也是电脑上通用的格式。也是由4个值表示的,但每一个值占用8bit,所以总共是32bit=4byte。也即每一个像素占用4个字节
- RGB_565:没有透明度(alpha),所以,没法支持半透明或是全透明。只有R(占用5bit),G(占用6bit),B(占用5bit),总共16bit=2byte。也即,每一个像素占用2字节
Android默认是按照ARGB_8888来解析的,当我们采用RGB_565解析时,每个像素的占用字节从4个降低为两个,可以降低内存占用,但是需要注意的是RGB_565不包含透明度,因此如果你的图片包含透明度时,优先采用ARGB_8888来解析,如果设备比较低端而内存吃紧的话,也可以采用ARGB_4444来解析,但是显示效果就会受到影响
7、分片加载图片:
有时候我们想要加载显示的图片很大或者很长,比如手机滚动截图功能生成的图片。针对这种情况,在不压缩图片的前提下,不建议一次性将整张图加载到内存,而是采用分片加载的方式来显示图片部分内容,然后根据手势操作,放大缩小或者移动图片显示区域。然后根据用户的滑动方向来继续渲染剩余的图片部分
Android SDK 中的 BitmapRegionDecoder 来实现分片加载的策略,示例代码如下:
/**
* 只渲染前200*200像素的内容
*/
private fun showRegionImage(){
val stream = assets.open("big_picture.jpg")
val decoder = BitmapRegionDecoder.newInstance(stream, false)
val options = BitmapFactory.Options()
val bitmap = decoder.decodeRegion(Rect(0, 0, 200, 200), options)
img_view.setImageBitmap(bitmap)
}
8. 合理存放资源文件的位置
当UI图片的实际分辨率与展示分辨率相同时,图片不会进行缩放处理,此时的图片大小近似的相当于Android设备实际渲染大小
9. 及时释放Bitmap内存
下表是不同SDK版本中Bitmap对象和像素存放位置的区别(来源于Android Develop),
API | API 10+ | API 11~API 25 | API 26+ |
Bitmap对象存放 | Java Heap | Java Heap | Java Heap |
像素(pixel data)数据存放 | native heap | Java Heap | native heap |
不同Android版本中的Java堆内存个Native内存存放地址也不一样,其回收方式也不一样,下表中为不同存放位置中释放内存的时机(来源于:Android中Bitmap内存优化)
Native Heap 存放 | 退出Activity | 退出App |
手动调用recycler() | 不释放 | 释放 |
无调用 | 不释放 | 不释放 |
Java Heap 存放 | 退出Activity | 退出App |
手动调用recycler() | 释放 | 释放 |
无调用 | 释放 | 释放 |
五、线上Bitmap内存监控上报
这里根据微信Matrix的内存监控策略,我们可以设计一个简单的内存统计工具,封装全局图片加载框架,通过WeakHashMap在图片加载前,记录下每个Bitmap的内存大小和唯一id,唯一id用来追溯Bitmap的创建来源,当系统调用onLowMemory()或者OOM时(OOM时,因为主进程已经崩溃,因此需要在新的进程中上报结果),把内存统计结果上报到服务器,开发人员就可以根据这个上报结果定位占用内存较大的图片资源,从而分析解决,示例代码如下:
class BitmapMemoryMonitor {
/**
* 用来记录所有的Bitmap内存占用情况
* 弱引用的形式,避免引用Bitmap导致无法释放
*/
private val bitmapHashMap = WeakHashMap<Bitmap, String>()
fun recordBitmap(bitmap: Bitmap, id: String) {
bitmapHashMap[bitmap] = id
}
/**
* 上报当前bitmap的内存占用情况
*/
fun reportBitmapMemory() {
val map = mutableMapOf<String, Int>()
bitmapHashMap.filter { it.key != null }
.forEach {
val bitmap = it.key
val id = it.value
map[id] = bitmap.allocationByteCount
}
}
}