图片,一直是android应用最重要的一部分,它是信息的载体,也是app给人视觉体验最直接的区域。
但是它又不像文字那样轻量级,越是美观,越是内容丰富的图片往往占用的内存资源、网络资源越多。当开发者处理各种图片加载的需求时,很容易碰到图片加载卡顿、OOM的问题。本篇将介绍图片加载缓存相关内容,以提供android开发有关图片处理的各种思路。当然,图片相关开源的成熟框架已经有不少了,比如最著名的glide,那我这篇文章还有什么意义呢?我觉得总是生吞别人设计好的框架,容易吸收不好,本文可以当作一盘开胃菜。让你了解一些图片缓存加载的思想,对于glide的使用就会有更深刻的体会。而且一般的应用环境,本篇所介绍的图片加载缓存方式就够用了。
一、图片加载
Bitmap:
android中谈到图片的加载基本上离不开Bitmap,我在本文中有关于图片的处理将通过Bitmap来进行操作。那么BItmap是什么呢?其实从字面意思就可以了解这个概念了:位图。上一次接触相关概念的时候,是处理8583报文,它的位图有8个字节(也就是8 × 8 = 64byte)用来描述64个区域是否存在:1表示存在,0表示不存在。对于图片性质的Bitmap也是同样的道理,8583中Bitmap的每一“位”是由一个byte来组成,而图片Bitmap的每一“位”则是由像素点够成。根据不同的图片格式,一个像素点的信息可能是RGB构成(红绿蓝三原色)亦或者ARGB(多了透明度)或者别的更为复杂的信息。而在android中通过Bitmap对象不仅可以获得图片的各种信息,还可以对图片进行修改等。
下面的表格是不同格式的图片消耗的内存:
Bitmap.Config | 描述 | 内存消耗(字节/像素) |
---|---|---|
ARGB_8888 | 32位的ARGB位图 | 4 |
ARGB_4444 | 16位的ARGB位图 | 2 |
RGB_565 | 16位的RGB位图 | 2 |
ALPHA_8 | 8位的Alpha位图 | 1 |
那么如何创建Bitmap呢?可能是Bitmap占用的内存过多。所以android中Bitmap并没有公开的构造方法,而是提供了BitmapFactory工厂类进行加载Bitmap。
通过BitmapFactory进行加载Bitmap有以下四种常用的方法:
BitmapFactory.decodeFile() //将图片文件解码得到Bitmap对象
BitmapFactory.decodeByteArray() //通过字节数组来获得Bitmap对象
BitmapFactory.decodeResource() //通过Resource资源来获得Bitmap对象
BitmapFactory.decodeStream() //将Stream流解码得到Bitmap对象
优化加载Bitmap:
现在顶配手机像素好像已经达到了1亿像素。我们不说这么高清的图片了,就拿1千万像素为例:如果是ARGB_8888格式的照片,那么内存大小将达到 4千万字节,也就是40 * 1000 * 1000 ≈ 40M。瓦的天!一般的图片没有这么高清也要消耗几M的内存,图片加载多了,即使没有OOM也会占用过多的内存,导致app运行不流畅。
所以优化加载Bitmap刻不容缓。
如何优化加载呢?可以从两条思路出发:
- 降低单位像素所占的内存:就如上图表格所示,如果当前图片格式是ARGB_8888格式,则在不需要如此高清的情况下,我们可以转换成RGB_565格式的图片来展示,这样内存就相当于缩小到了一半。(开源库Glided的默认解码格式是RGB565,Picasso是ARGB8888 ,所以同一个图片,Glide消耗内存更少,但清晰度会有所牺牲)
- 降低图片采样率:图片采样率,顾名思义就是单位图片区域选择像素样本的数量,有时候由于界面的限制我们不需要展示原图大小的图片(假设,我们原图为1000 × 1000像素,而我们的ImageView大小只有500 × 500,如果我们将原图全部加载,岂不是很浪费内存?这时候我们就需要采样并获取合适的大小)。修改图片采样率是项耗时复杂的处理,一般来说不会在我们应用层去执行而是通过底层编码进行高效处理。当然素点的操作并不需要我们自己来实现,有关采样率的修改BitmapFactory早已有了封装好的处理流程。
第一种思路是降低了单位像素的内存消耗,而第二种种思路则是降低采样率,减少像总素点的数量。通过这两种手段有效的结合,足够适应大多数图片的加载。下面我们就来讲讲代码中是如何来实现这些功能的。
上面提到的BitmapFactory4个加载Bitmap的方法都是重载的,他们的参数除了表明来源的参数(第一个参数),还有第另一个参数Options。Options是BitmapFactory的内部类,用来控制采样的选项,以及图像是否应该完全解码,或者只是返回图片大小。Options可以说是高效加载Bitmap的核心控制器。
BitmapFactory.Options:
Options的构造方法是公开的,我们可以直接new一个Options对象。使用Options关键的操作是设置它里面一些重要属性。Options中的属性真的是非常的多,加上几个@Deprecated的属性差不多20来种吧,这里将选择几个最常见也是最有用的属性来介绍。如果有需求可以通过阅读源码的注释来了解每一种属性的作用。
- inPreferredConfig (Bitmap.Config类型)这个属性便是上边讲的图片格式,它的默认值为ARGB_8888。当如果将其设置为null,则图片解码器将会通过适配系统的屏幕和根据原始图片的分辨率,设置最为接近的图片格式。
- outHeight&outWidth (int类型)这两个属性可以用来获取图片的宽和高。注释中还说明了如果inJustDecodeBounds被设置为false则返回压缩后的宽和高,如果inJustDecodeBounds被设置为true则不考虑压缩比例,返回来源图片的实际宽高。
- inJustDecodeBounds (boolean类型)显而易见,设置了这个属性,BitmapFactory将不会加载真正的Bitmap对象,而只是获取了图片的Bounds(宽和高)。
- inSampleSize(int类型)采样率,这就是实现降低图片采样率的关键属性。如果我们将它设置为>1的情况将对图片进行压缩,反之则不进行压缩。举个例子,inSampleSize = 2,则加载的图片的长和宽均为原始图片的 1 / 2,这样,整张图的大小则为原图的 (1 / 2) × (1 / 2) = 1 / 4。同理如果是inSampleSize = 4的话则片大小将为原图的1 / 16。注意:inSampleSize只能是基于2的幂,如果不是,最后的将向下取与之最接近的2的幂。
这三个便是和本文最相关的三个属性。下面将通过代码来完整的实现图片的压缩:
/**
* 压缩加载Bitmap
*
* @param resources 以Resources为图片来源加载Bitmap
* @param pixWidth 需要显示的宽
* @param pixHeight 需要显示的高
* @return 压缩后的Bitmap
*/
public static Bitmap ratioBitmap(Resources resources, int ResId, int pixWidth, int pixHeight) {
BitmapFactory.Options options = new BitmapFactory.Options();
/*
inJustDecodeBounds设置为true,只加载原始图片的宽和高,
我们先获取原始图片的高和宽,从而计算缩放比例
*/
options.inJustDecodeBounds = true;
options.inPreferredConfig = Bitmap.Config.RGB_565;
BitmapFactory.decodeResource(resources, ResId, options);
int originalWidth = options.outWidth;
int originalHeight = options.outHeight;
options.inSampleSize = getSimpleSize(originalWidth, originalHeight, pixWidth, pixHeight);
/*
inJustDecodeBounds设置为false, 真正的去加载Bitmap
*/
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(resources, ResId, options);
}
/**
* 获取压缩比例,如果原图宽比高长,则按照宽来压缩,反之则按照高来压缩
*
* @return 压缩比例,原图和压缩后图的比例
*/
private static int getSimpleSize(int originalWidth, int originalHeight, int pixWidth, int pixHeight) {
int simpleSize = 1;
if (originalWidth > originalHeight && originalWidth > pixWidth) {
simpleSize = originalWidth / pixWidth;
} else if (originalHeight > originalWidth && originalHeight > pixHeight) {
simpleSize = originalHeight / pixHeight;
}
if (simpleSize <= 0) {
simpleSize = 1;
}
return simpleSize;
}
二、图片缓存
加载是为了让我们节约使用内存空间,而缓存则可以节约我们的网络资源,增加我们应用的流畅性。比如说打开某个app每次翻到首页,上面的图片如果每次都需要从服务器获取,那么我们的app用户体验将会变得非常糟糕。但是我们如果将同样的图片缓存起来,使用时直接从缓存中取出来,那么页面加载将会变的更加流畅。
android图片缓存可以分为两种方式:
- 将图片保存到内存中。
- 将图片保存到本地磁盘中。
第一种,无论是保存还是读取的速度都更快,但是占用了更加珍贵的内存资源,所以一般会限制内存缓存大小,而且在应用退出或者内存清空后,缓存的图片也就不见了,需要重新从服务器获取。第二种,相较第一种,由于是保存在磁盘中所以更加持久,能使用的空间也就更大。但是相应的速度没有内存缓存快,而且如果不做定期清理,可能会生成过多的垃圾资源占用我们的储存空间。所以一般情况,我们会根据需求,两者配合使用。
图片缓存的策略:LRU策略
图片缓存的方式我们清楚了,那如何制定图片缓存的策略呢?总不能将所有的图片都缓存下来,满了之后再清理,那么我们需要多大的内存和磁盘空间才能满足需求啊!那么什么样的图片值得缓存呢?当然是之后越可能再次用到的图片越值得缓存。
LRU策略便是为了估计出可能被重复使用的资源。它的全称是“Least recently used”,也就是最近最少使用:根据资源的历史访问记录来进行淘汰数据,其核心思想是“如果资源最近被访问过,那么将来被访问的几率也更高”。
下面将通过LRU策略去实现两种方式(内存和磁盘)的缓存,如果对LRU算法感兴趣,可以移步到LRU算法(如果这几个字不是链接,那说明我还没有写相关博客,当然网上相关介绍也不少,大家可以自行搜素了解学习),如果没时间看也不影响下面内容的阅读。
内存缓存:LruCache
LruCache是android提供了内存缓存的类,它实现了LRU操作。我们只需要设置其大小,存放数据,读取数据。
构造方法的参数便是设置缓存空间的最大值: public LruCache(int maxSize),在之后还可以通过LruCache.resize(int maxSize)方法进行修改最大缓存空间。
往cache存放Bitmap的方法是:LruCache.put(@NonNull String key, @NonNull Bitmap value)(key和value的类型是泛型,我们在声明LruCache对象时规定为String和Bitmap类型)。
从cache读取Bitmap的方法是:LruCache.get(@NonNull String key)。
还有手动从LruCache中移除Bitmap的方法是remove(@NonNull String key),当你判断某一图片确定不会再加载时可以主动移除。磁盘缓存:DiskLruCache
磁盘缓存android SDK并没有提供相关的类,但是square团队(Retrofit,OkHttp等开源库的制作团队)提供了DiskLruCache开源库,可以方便的帮我实现该功能。
使用它需要先在gradle中导入:
implementation 'com.jakewharton:disklrucache:2.0.2'
DiskLruCache私有化了构造方法,它通过open方法进行创建:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
第一个参数是保存的文件地址,一般保存在context.getCacheDir()这个目录下,改目录的文件会随着应用的卸载被一同删除。
第二个是app版本号(在版本号更新后缓存的内容将被清空,如果不考虑版本问题,这里可以写个1不再修改)。
第三个是每个可以所保存的value的个数,必须是正数,根据项目功能需求决定填入多少,一般填入1即可。
最后一个参数是保存文件的最大大小,以byte为单位。
保存数据是通过Editor进行的:DiskLruCache.edit(key)通过传递参数的方法使得key与资源进行绑定。最后通过Editor对象获取输出流,来将数据保存到文件中:Editor.newOutputStream(0)(参数是value的下标,由于我们valueCount设置为1,所以这里直接填入0)它会返回一个OutputStream对象,我们通过该对象将Bitmap的byte数组写入文件。最后调用editor.commit()保存成功。
获取的方式也不复杂:diskLruCache.get(key)会返回一个DiskLruCache.Snapshot对象。我们通过Snapshot的getInputStream(0)来获取输入流,最后调用文章开头讲过的BitmapFactory.decodeByteArray()获得Bitmap对象。
代码实现缓存逻辑,相关代码结合网络请求效果更佳。
package com.example.demojava;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import androidx.collection.LruCache;
import com.jakewharton.disklrucache.DiskLruCache;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
/**
* @author yapple
* @date 2019/12/21
* # Description bitmap 缓存、加载类
*/
public class BitmapLoader {
private static BitmapLoader mBitmapLoader;
private LruCache<String, Bitmap> mCache;
private DiskLruCache mDiskLruCache;
/**
* 将DISK_FILE_PATH字符串中的<application package>替换成自己的包名
*/
private static final String DISK_FILE_PATH = "/data/data/Android/<application package>/cache/bitmapCache";
private static final long DISK_MAX_SIZE = 100 * 1024 * 1024;
/**
* 内存缓存的大小
* 上面说了内存资源很珍贵,这里我们规定好内存资源的大小以kb为单位
*/
private int mCacheSize;
private BitmapLoader() {
long maxSize = Runtime.getRuntime().maxMemory();
mCacheSize = (int) (maxSize / 8);
mCache = new LruCache<>(mCacheSize);
try {
File file = new File(DISK_FILE_PATH);
if (!file.exists()) {
boolean mkdirs = file.mkdirs();
if (!mkdirs) {
throw new IOException("yapple.e " + DISK_FILE_PATH + " cant be create");
}
}
mDiskLruCache = DiskLruCache.open(file, 1, 1, DISK_MAX_SIZE);
} catch (IOException e) {
e.printStackTrace();
}
}
public static BitmapLoader getInstance() {
if (mBitmapLoader == null) {
synchronized (BitmapLoader.class) {
if (mBitmapLoader == null) {
mBitmapLoader = new BitmapLoader();
}
}
}
return mBitmapLoader;
}
public int getmCacheSize() {
return mCacheSize;
}
/**
* 修改内存缓存的大小
*/
public void setmCacheSize(int mCacheSize) {
this.mCacheSize = mCacheSize;
mCache.resize(mCacheSize);
}
/**
* 将bitmap保存到缓存中, 由于我这里并没有写网络相关的环节,所以直接将bitmap作为参数进行保存,
* 实际通过上流的方式来保存会更加方便,也比较接近项目需求。
* @param key 通过key value形式保存bitmap,key可以是URL等
*/
public void putBitmapToCache(String key, Bitmap bitmap) {
if (key != null && bitmap != null) {
mCache.put(key, bitmap);
try {
int bytes = bitmap.getByteCount();
ByteBuffer buffer = ByteBuffer.allocate(bytes);
bitmap.copyPixelsToBuffer(buffer);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
OutputStream outputStream = editor.newOutputStream(0);
outputStream.write(buffer.array());
outputStream.flush();
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 从本地获取图片
* 当内存中存在时,直接取内存中的bitmap,当内存中不存在时,则会从磁盘中获取。
* 如果都不存在,则返回null;请从网络中加载
*/
public Bitmap getBitmapFromLocal(String key) {
Bitmap bitmap = mCache.get(key);
if (bitmap == null) {
bitmap = getBitmapFromDisk(key);
}
return bitmap;
}
private Bitmap getBitmapFromDisk(String key) {
try {
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
InputStream inputStream = snapshot.getInputStream(1);
return BitmapFactory.decodeStream(inputStream);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
有错误欢迎指出!!!
之后会根据相关知识点写一个图片加载的demo,展示还没有时间完成,后续会补上。