前言
开发中图片加载、选择、压缩,一般都使用第三方库如Glide、PictureSelector、Luban,使用起来简单便捷又安全,不会出现莫名Bug。虽说不大可能去解读源码,解读了也不可能完全记住,但至少要知道图片加载、相册选择、图片压缩这些最基本的功能是怎么实现,也就是说自己写要怎么实现。
图片相关的问题很多,如下:
1.图片是如何加载的?
2.三级缓存是怎么实现的?
3.图片压缩是怎么实现的?
4.图片保存、通知图库更新是怎么实现?
5.打开相册选择图片、拍照怎么实现?
6.圆形图片、圆角图片怎么实现?
...
Android中图片先从了解Bitmap 和 BitmapFactory开始。
相关知识
1.Bitmap
Bitmap 在 Android 中指的是一张图片,可以是 png,也可以是 jpg等其他图片格式,它的作用是可以获取图像文件信息,对图像进行剪切、旋转、缩放、压缩等操作,并可以指定格式保存图像文件。
Bitmap类是final类,bitmap可以通过Bitmap.createBitmap(...)、BitmapDrawable.getBitmap()、和BitmapFactory.decodeXXX(...)得到。 Bitmap.createBitmap用于创建Bitmap,比如可以创建一定宽高的空Bitmap;BitmapDrawable一般用在得到画布上的Bitmap;BitmapFactory是解析Bitmap,常见在图片加载、压缩上。
常见如下:
//1.创建一定宽高的空Bimtap
Bitmap result = Bitmap.createBitmap(width, heigth, Bitmap.Config);
//2.Drawable得到Bitmap
Bitmap b = ((BitmapDrawable) drawable).getBitmap();
//3.BitmapFactory解析,decodeResource,decodeFile最终都会调用decodeStream。
BitmapFactory.decodeResource(Resources res, int id);
BitmapFactory.decodeFile(String pathName);
BitmapFactory.decodeStream(InputStream is);
2.BitmapFactory.Options
BitmapFactory.Optinos是用于解码Bitmap时的各种参数控制,参数很多,此处对最常见的做个解释。
inPreferredConfig:色彩模式,默认值为Bitmap.Config.ARGB_8888(每像素占4byte,有透明度),压缩一般使用RGB_565(每像素占2byte,没有透明度);
inJustDecodeBounds:为true时仅返回 Bitmap 宽高属性,不加载Bitmap到内存,返回的Bitmap=null,为false时才返回占内存的 Bitmap;
outputWidth:返回的 Bitmap的宽;
outputHeight:返回的 Bitmap的高;
inSampleSize:表示 Bitmap 的压缩比例,值必须 > 1 & 是2的幂次方。为2是指目标宽高是原宽高的1/2;
...
3.Android的文件目录和缓存目录
android保存文件的路径有5种,分别如下:
getExternalFilesDir(): SDCard/Android/data/<application package>/files/目录
getFilesDir(): data/data/<application package>/files/目录
getExternalCacheDir():SDCard/Android/data/<application package>/cache/目录
getCacheDir(): data/data/<application package>/cache/目录
Environment.getExternalStorageDirectory(): SDCard/目录
FilesDir一般放一些长时间保存的数据,CacheDir放临时缓存数据,有External的是指外部SD卡。前4个路径下的数据都会随着app被用户卸载而删除,FilesDir 和 CacheDir 分别对应的是 设置->应用->应用详情里面的“清除数据”和”清除缓存“选项。
使用如下:
private static File getCacheDir(){
if(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())){
return App.ctx.getExternalCacheDir();
}
return App.ctx.getCacheDir();
}
private static File getFilesDir(){
if(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())){
return App.ctx.getExternalFilesDir(null); //传null,访问的是files文件夹
}
return App.ctx.getFilesDir();
}
Environment.getExternalStorageDirectory()和前面4个路径的区别是,它不依赖于app。也就是说app卸载,它也不会删除。所以具体使用看情况处理,比如app里有邀请推广二维码的,就不要保存在xxxFilesDir()了,在Environment.getExternalStorageDirectory()创建一个文件夹来保存,如果保存到前面4个路径下,是不会在系统相册显示的。
问题
1.图片是如何加载的?
在Android中, 网络、本地文件、资源id的图片,最终都是解析成Bitmap,系统提供了解析Bitmap的静态工厂方法——BitmapFactory。最常见的三种解析方法如下:
//加载资源id
BitmapFactory.decodeResource(Resources res, int id);
//加载本地图片
BitmapFactory.decodeFile(String pathName);
//加载网络
BitmapFactory.decodeStream(InputStream is);
//其实decodeResource,decodeFile最终都会调用decodeStream。
2.三级缓存是怎么实现的?
原理:内存 -> 文件(本地)->网络
流程:
1)内存,创建LruCache<String,SoftReference<Bitmap>> 作为内存缓存容器,每次从文件或网络加载图片时,要加入缓存中。
2)文件,在缓存目录 getExternalCacheDir() 或 getCacheDir()下找到该文件,用BitmapFactory.decodeFile(xx)得到bitmap, 并将bitmap放入LruCache(内存)中。
3)网络,请求网络流数据,放入内存中且保存File到本地。
选用LruCache的原因如下:
/**
* LruCache其实是一个Hash表,内部使用的是LinkedHashMap存储数据。
* 使用LruCache类可以规定缓存内存的大小,并且这个类内部使用到了最近最少使用算法来管理缓存内存。
* 这里定义 8M的大小作为缓存
*/
private static LruCache<String, SoftReference<Bitmap>> mImageCache = new LruCache<>(1024 * 1024 * 8);
流程处理
public static void load(ImageView iv, String url){
//1.从内存读取
SoftReference<Bitmap> reference = mImageCache.get(url);
Bitmap cacheBitmp;
if(reference != null){
cacheBitmp = reference.get();
iv.setImageBitmap(cacheBitmp);
KLog.d(TAG,"内存中图片显示");
return;
}
//2.从文件读取
cacheBitmp = getBitmapFromFile(url);
if(cacheBitmp!=null){
//bitmap保存到内存
mImageCache.put(url,new SoftReference<Bitmap>(cacheBitmp));
iv.setImageBitmap(cacheBitmp);
KLog.d(TAG,"文件中图片显示");
return;
}
//3.连网处理
getBitmapFromUrl(iv,url);
}
//网络加载图片,在onResponse()里解析文件流
okHttpClient.newCall(request).enqueue(new Callback() {
public void onFailure(Call call, IOException e) {
}
public void onResponse(Call call, Response response) throws IOException {
KLog.d(TAG,"文件中图片显示");
InputStream inputStream = response.body().byteStream();//得到图片的流
final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
saveBitmap(url,bitmap); //加入内存缓存、放入cache目录
if(weakReference.get()!=null){
weakReference.get().runOnUiThread(new Runnable() {
@Override
public void run() {
iv.setImageBitmap(bitmap);
}
});
}
}
});
3.图片压缩
Bitmap常用压缩方法
1)质量压缩
质量压缩不会减少图片的像素,它是在保持像素的前提下改变图片的位深及透明度,来达到压缩图片的目的,图片的长,宽,像素都不会改变,那么bitmap所占内存大小是不会变的,但是保存成文件,它的大小会变化的。
注意:质量压缩对png格式图片没效,因为png是无损压缩。
2)宽高压缩
有3种方式改变宽高,采样率压缩(inSampleSize);缩放法压缩(Matrix),通过矩阵对图片进行缩放;Bitmap.createScaledBitmap。常用的是改变inSampleSize。
3)RGB_565压缩
改用内存占用更小的编码格式来达到压缩的效果。Android默认的颜色模式为ARGB_8888,如果对透明度没有要求,可以把颜色模式改为RGB_565,相比ARGB_8888将节省一半的内存开销。
注意点
1)图片的所占的内存大小和很多因素相关,常规方法bitmap.getByteCount()得到的内存大小不一定准确,但用来判断内存大小是否改变时可以用它。
2)Bitmap所占内存大小和文件大小不是一样的,所占内存比文件大得多。
3)质量压缩不改变所占内存大小。
实例
图片要求,宽1080,高1920,文件大小不超过1M。
先进行宽高压缩,再进行质量压缩,最终通过BitmapFactory.decodeByteArray 得到目标Bitmap,在解析的时候改成RGB_565颜色模式,可以少占一半的内存,如果不改成RGB_565,可以看到质量压缩前后,所占内存是没有变化的。
/**
* 压缩图片
* 压缩要求,宽1080,高1920,文件大小不超过1M。
* @param path 图片路径
* */
public static Bitmap getCompressBitmap(String path){
Bitmap bitmap = getResizeBitmap(path,1080,1920) ;
return getQualityBitmap(bitmap,1024);
}
/**
* 宽高压缩
* @param filePath 文件路径
* @param width 目标宽度
* @param height 目标高度
* @return
*/
public static Bitmap getResizeBitmap(String filePath, int width, int height) {
Bitmap bitmap = null;
File f = new File(filePath);
if (f.exists() && f.length() > 0) {
try {
BitmapFactory.Options options = new BitmapFactory.Options();
//只取宽高
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filePath, options);
int picWidth = options.outWidth;
int picHeight = options.outHeight;
KLog.e(TAG, "宽高压缩前图片宽度="+ picWidth + ",高度=" + picHeight);
//如果原图,宽比高大,则 宽/height,高/width比。否则,宽/width,高/height比。
if(picWidth>picHeight && (picWidth > height || picHeight > width)){
options.inSampleSize = Math.max(options.outWidth / height, options.outHeight / width);
}else if(picWidth > width || picHeight > height){
options.inSampleSize = Math.max(options.outWidth / width, options.outHeight / height);
}else{
options.inSampleSize = 1;
}
options.inJustDecodeBounds = false;
bitmap = BitmapFactory.decodeFile(filePath, options);
KLog.e(TAG, "宽高压缩后图片宽度="+ bitmap.getWidth() + ",高度=" + bitmap.getHeight()
+ ",所占内存大小=" + bitmap.getByteCount()/ 1024 +"KB");
} catch (OutOfMemoryError e) {
e.printStackTrace();
}
}
return bitmap;
}
/**
* 质量压缩
* 这个方法只会改变图片的存储大小,不会改变bitmap的大小
* @param bitmap bitmap
* @param maxFileSize 最大大小
* @return Bitmap 压缩后bitmap
*/
public static Bitmap getQualityBitmap(Bitmap bitmap, int maxFileSize) {
if(bitmap == null){
return null;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int quality = 100;
bitmap.compress(Bitmap.CompressFormat.JPEG,quality,baos);
int baosLength = baos.toByteArray().length;
KLog.e(TAG, "质量压缩前所占内存大小=" + (bitmap.getByteCount() / 1024 +"KB")
+ ",文件大小(bytes.length)=" + (baosLength/ 1024) + "KB"
+ ",quality=" + quality);
while (baosLength/1024 > maxFileSize){
//清空baos
baos.reset();
quality = quality <= 10 ? quality - 1 : quality - 10;
if (quality == 0) {
break;
}
bitmap.compress(Bitmap.CompressFormat.JPEG,quality,baos);
//将压缩后的图片保存到baos中
baosLength = baos.toByteArray().length;
}
KLog.e(TAG, "质量压缩后所占内存大小=" + (bitmap.getByteCount() / 1024 +"KB")
+ ",文件大小(bytes.length)=" + (baosLength/ 1024) + "KB"
+ ",quality=" + quality);
bitmap.recycle();
bitmap = null;
//最终目标Bitmap是经过压缩后,再decodeByteArray出来的,而decodeByteArray默认的是ARGB_8888,为了减少内存占用,
//要用RGB_565编码解析。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap targetBitmap = BitmapFactory.decodeByteArray(baos.toByteArray(),0,baosLength,options);
KLog.e("BitmapUtils", "最终解析后所占内存大小" + (targetBitmap.getByteCount() / 1024 + "KB"));
return targetBitmap;
}
最终打印数据:
可以看到宽高压缩了,但是宽高都比我们希望的1080和1920大,是因为inSampleSize是整数,而用2592/1080 或者 4608/1920得到2.4,取整就是2了。如果是要严格不大于希望的值,可以用个while循环去继续调整inSampleSize,我项目中不处理是因为,再/2得到的图片太小了。质量压缩前后了10%,文件大小也比1M小了,内存大小没有变化,之所以最终解析出内存少了一半,是因为用了RGB_565。图片压缩大概流程就是这样,具体使用根据需求进行修改。
4.图片保存、通知图库更新的代码实现。
图片保存就是保存文件,用文件流或输出流都行。
考虑使用Context.getExternalFilesDir() 还是 Environment.getExternalStorageDirectory(),两者区别是前者会随着app删除而删除,且不会更新到图库。后者不会删除,可以更新到图库。通知图库更新发送一个广播即可。
/**
* 保存图片到 /storage/emulated/0/<application package>/DASImage/ 下
* 且更新到图库
* @param bitmap
* @param fileName
* @return 是否保存成功
*/
public static boolean saveImageInSdCard(Bitmap bitmap, String fileName){
boolean isSuccess = false;
if(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())){
String path = Environment.getExternalStorageDirectory().getAbsolutePath() +
File.separator + App.ctx.getPackageName() + File.separator + "DASImage";
File dirFile = new File(path);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
File file = new File(path, fileName + ".jpg");
try {
FileOutputStream out = new FileOutputStream(file);
//format:JPEG, PNG 和 WEBP,保存JPEG比PNG格式的文件小。
isSuccess = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out);
out.flush();
out.close();
//通知图库更新
Uri uri = Uri.fromFile(file);
App.ctx.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri));
}catch (IOException e) {
e.printStackTrace();
}
KLog.e(TAG,"Bitmap已保存至" + file.getAbsolutePath());
}
return isSuccess;
}
5.打开相册选择图片、拍照的代码实现。
相册选择和拍照要优化的地方很多,比如选择图片如何选多张图片、拍照的Uri问题、拍照保存的图片路径、图片剪切、加载图片太大等问题。项目中还是用第三方库好些,例如这个https://github.com/LuckSiege/PictureSelector,连权限都写上了。。。
调取系统相册和拍照的关键代码如下:
public static final int REQUEST_TAKEPHOTO = 1; // 拍照
public static final int REQUEST_GALLERY = 2; // 从相册中选择
/**
* 相册选取
*/
private void onGallery() {
Intent intent = new Intent(Intent.ACTION_PICK, null);
intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
startActivityForResult(intent, REQUEST_GALLERY);
}
/**
* 拍照
*/
private void onCamera() {
if (AppUtils.hasSdcard()) {
//1.创建图片文件夹
String path = Environment.getExternalStorageDirectory().getAbsolutePath() +
File.separator + App.ctx.getPackageName() + File.separator + "DASImage";
imagePath = path + File.separator + BitmapUtils.getFileName() + ".jpg";
//创建目录
File dirFile = new File(path);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
File file = new File(imagePath);
//2.获取Uri
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
imageUri = FileProvider.getUriForFile(getActivity(), getActivity().getPackageName() + ".fileProvider", file);
}else{
imageUri = Uri.fromFile(file);
}
//3.拍照
Intent it = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
it.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
startActivityForResult(it, REQUEST_TAKEPHOTO);
} else {
ToastUtils.showToast("SdCard不存在,不允许拍照");
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK) {
switch (requestCode) {
case REQUEST_TAKEPHOTO: //拍照
//data为null,因为是保存在指定路径下,所以获取图片,直接拿那个路径即可
//imageUri为content://com.sz.dzh.dandroidsummary.fileProvider/my_images/DAS_1562837817999.jpg
break;
case REQUEST_GALLERY: //画库选择图片
//data不为null,content://media/external/file/1710928 flg=0x1
if (data != null) {
}
break;
}
}
}
6.圆形图片、圆角图片等如何实现?
圆形图片、圆角图片的方式有很多。
如果是用Glide,可以写个转换器完成,转换器继承BitmapTransformation,对Bitmap做操作,最后画圆画or画圆角,Glide的转换器网上有很好的库——glide-transformations(链接:https://github.com/wasabeef/glide-transformations)。或者自定义ImageView,在onDraw方法里canvas.drawCircle(...)画圆、用canvas.clipPath(...)裁剪画布等。不管什么方式,最终都是在onDraw方法,对Bitmap进行处理,再画出来。涉及的内容就是Canvas、Bitmap、BitmapShader、Paint、Xfermode等。(链接:https://blog.csdn.net/shenggaofei/article/details/83793536)
参考:
Android性能优化:Bitmap详解&你的Bitmap占多大内存?
深入理解Android Bitmap的各种操作
Android 第三方RoundedImageView设置各种圆形、方形头像