Android图片压缩工具,仿微信朋友圈压缩策略Luban(鲁班)
目录
介绍
目前做App
开发总绕不开图片这个元素。但是随着手机拍照分辨率的提升,图片的压缩成为一个很重要的问题。单纯对图片进行裁切,压缩已经有很多文章介绍。但是裁切成多少,压缩成多少却很难控制好,裁切过头图片太小,质量压缩过头则显示效果太差。
于是自然想到App
巨头“微信”会是怎么处理,Luban
(鲁班)就是通过在微信朋友圈发送近100张不同分辨率图片,对比原图与微信压缩后的图片逆向推算出来的压缩算法。
因为有其他语言也想要实现Luban
,所以描述了一遍算法步骤。
因为是逆向推算,效果还没法跟微信一模一样,但是已经很接近微信朋友圈压缩后的效果,具体看以下对比!
今天给大家介绍的是Android鲁班压缩,是一位大神写的 地址
效果与对比
内容 | 原图 | Luban |
Wechat |
---|---|---|---|
截屏 720P | 720*1280,390k | 720*1280,87k | 720*1280,56k |
截屏 1080P | 1080*1920,2.21M | 1080*1920,104k | 1080*1920,112k |
拍照 13M(4:3) | 3096*4128,3.12M | 1548*2064,141k | 1548*2064,147k |
拍照 9.6M(16:9) | 4128*2322,4.64M | 1032*581,97k | 1032*581,74k |
滚动截屏 | 1080*6433,1.56M | 1080*6433,351k | 1080*6433,482k |
使用
方法列表
方法 | 描述 |
---|---|
load | 传入原图 |
filter | 设置开启压缩条件 |
ignoreBy | 不压缩的阈值,单位为K |
setFocusAlpha | 设置是否保留透明通道 |
setTargetDir | 缓存压缩图片路径 |
setCompressListener | 压缩回调接口 |
setRenameListener | 压缩前重命名接口 |
异步调用
Luban
内部采用IO
线程进行图片压缩,外部调用只需设置好结果监听即可:
Luban.with(this)
.load(photos)
.ignoreBy(100)
.setTargetDir(getPath())
.filter(new CompressionPredicate() {
@Override
public boolean apply(String path) {
return !(TextUtils.isEmpty(path) || path.toLowerCase().endsWith(".gif"));
}
})
.setCompressListener(new OnCompressListener() {
@Override
public void onStart() {
// TODO 压缩开始前调用,可以在方法内启动 loading UI
}
@Override
public void onSuccess(File file) {
// TODO 压缩成功后调用,返回压缩后的图片文件
}
@Override
public void onError(Throwable e) {
// TODO 当压缩过程出现问题时调用
}
}).launch();
同步调用
同步方法请尽量避免在主线程调用以免阻塞主线程,下面以rxJava调用为例
Flowable.just(photos)
.observeOn(Schedulers.io())
.map(new Function<List<String>, List<File>>() {
@Override public List<File> apply(@NonNull List<String> list) throws Exception {
// 同步方法直接返回压缩后的文件
return Luban.with(MainActivity.this).load(list).get();
}
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
压缩多张图片:
private <T> void withLs(final List<T> photos) {
Luban.with(this)
.load(photos)
.ignoreBy(100)
.setTargetDir(getPath())
.setFocusAlpha(false)
.filter(new CompressionPredicate() {
@Override
public boolean apply(String path) {
return !(TextUtils.isEmpty(path) || path.toLowerCase().endsWith(".gif"));
}
})
.setRenameListener(new OnRenameListener() {
@Override
public String rename(String filePath) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(filePath.getBytes());
return new BigInteger(1, md.digest()).toString(32);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return "";
}
})
.setCompressListener(new OnCompressListener() {
@Override
public void onStart() { }
@Override
public void onSuccess(File file) {
Log.i(TAG, file.getAbsolutePath());
showResult(originPhotos, file);
}
@Override
public void onError(Throwable e) { }
}).launch();
}
简单分析源码
看了一下目录结构其实没有多少代码,但是设计的思想非常好,大概一看就知道用到了这里是一个静态的with方法,返回值是Builder,一般对设计模式比较熟悉的人,看到这里就应该懂了,他这里使用的是建造者模式。什么是建造者模式呢?建造者模式和工厂模式很相似,比工厂模式多了一个控制类,其实说白了,就是在创建对象的时候,减少初始化数据的代码,设计模式介绍
我们点到Builder里面看到如下代码:
我们看到了是一个静态的内部类Builder,我们这里看到了有5个变量,上面我们说道了,为了减少初始化数据的代码,就拿这个举例子说明,我如果有4个地方调用这个鲁班压缩,其中这4个地方,mTargetDir,mLeastCompressSize这2个变量的值都是一样的,其他3个不一样,按照我们以往的写法都得一个一个的赋值,要写4遍,那如果使用建造者模式了,这里就只用写一遍赋值,这2个变量。其他3个不一样,就得写多遍。当然,这是我个人对于建造者模式的理解。
我上面多粘贴了一个build()方法,为什么会多粘贴一个呢?就是为了更好的说明建造者模式,我们可以看到他这个方法,返回的是Luban对象,调用的是需要传Builder的构造方法,我们点进去看
private Luban(Builder builder) {
this.mTargetDir = builder.mTargetDir;
this.mRenameListener = builder.mRenameListener;
this.mStreamProviders = builder.mStreamProviders;
this.mCompressListener = builder.mCompressListener;
this.mLeastCompressSize = builder.mLeastCompressSize;
this.mCompressionPredicate = builder.mCompressionPredicate;
mHandler = new Handler(Looper.getMainLooper(), this);
}
他这里就是赋值,他这个值就是Builder里面默认的,我们不论在哪里调用这个方法,都不用去一个一个赋值,因为,他已经处理好了。
第二步:load()
public Builder load(InputStreamProvider inputStreamProvider) {
mStreamProviders.add(inputStreamProvider);
return this;
}
public Builder load(final File file) {
mStreamProviders.add(new InputStreamAdapter() {
@Override
public InputStream openInternal() throws IOException {
return new FileInputStream(file);
}
@Override
public String getPath() {
return file.getAbsolutePath();
}
});
return this;
}
public Builder load(final String string) {
mStreamProviders.add(new InputStreamAdapter() {
@Override
public InputStream openInternal() throws IOException {
return new FileInputStream(string);
}
@Override
public String getPath() {
return string;
}
});
return this;
}
public <T> Builder load(List<T> list) {
for (T src : list) {
if (src instanceof String) {
load((String) src);
} else if (src instanceof File) {
load((File) src);
} else if (src instanceof Uri) {
load((Uri) src);
} else {
throw new IllegalArgumentException("Incoming data type exception, it must be String, File, Uri or Bitmap");
}
}
return this;
}
public Builder load(final Uri uri) {
mStreamProviders.add(new InputStreamAdapter() {
@Override
public InputStream openInternal() throws IOException {
return context.getContentResolver().openInputStream(uri);
}
@Override
public String getPath() {
return uri.getPath();
}
});
return this;
}
一看就明白了,分别是url、集合........
第三步:ignoreBy() 和 setTargetDir()
点击去看到源码为
/**
* do not compress when the origin image file size less than one value
*
* @param size
* the value of file size, unit KB, default 100K
*/
public Builder ignoreBy(int size) {
this.mLeastCompressSize = size;
return this;
}
public Builder setTargetDir(String targetDir) {
this.mTargetDir = targetDir;
return this;
}
这两个我为啥要放在一起讲呢?因为这两个没啥好说的,都是设置值,跟我们平时写的set方法的作用是一样的。没啥好说的
第四步:setCompressListener(OnCompressListener listener)
点击去看到源码为
/**
* begin compress image with asynchronous
*/
public void launch() {
build().launch(context);
}
这个就是我们平时写自定义view的时候,要写回调方法,是一样的道理,他这里就是压缩方法的回调
第五步:launch()
点击去看到源码为
/**
* start asynchronous compress thread
*/
@UiThread private void launch(final Context context) {
if (mPaths == null || mPaths.size() == 0 && mCompressListener != null) {
mCompressListener.onError(new NullPointerException("image file cannot be null"));
}
Iterator<String> iterator = mPaths.iterator();
while (iterator.hasNext()) {
final String path = iterator.next();
if (Checker.isImage(path)) {
AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
@Override public void run() {
try {
mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_START));
File result = Checker.isNeedCompress(mLeastCompressSize, path) ?
new Engine(path, getImageCacheFile(context, Checker.checkSuffix(path))).compress() :
new File(path);
mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_SUCCESS, result));
} catch (IOException e) {
mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_ERROR, e));
}
}
});
} else {
Log.e(TAG, "can not read the path : " + path);
}
iterator.remove();
}
}
这个方法就是最后,执行压缩的方法,前面都是初始化,我们可以看到,他这个方法是在主线程调用的,所以,我们不用考虑切换线程的问题,直接可以操作UI变化。我一步一步的讲:
首先,他这个是用的迭代器,循环遍历,遍历一个就移除一个
然后就是通过handler发消息调用
具体压缩代码。最重要的就是第三点,我把第三点,提到下面讲
接着上面的第三点,具体压缩
File result = Checker.isNeedCompress(mLeastCompressSize, path) ?
new Engine(path, getImageCacheFile(context, Checker.checkSuffix(path))).compress() :
new File(path);
首先,他整体是一个三目运算符,我们点isNeedCompress()方法看一下
static boolean isNeedCompress(int leastCompressSize, String path) {
if (leastCompressSize > 0) {
File source = new File(path);
if (!source.exists()) {
return false;
}
if (source.length() <= (leastCompressSize << 10)) {
return false;
}
}
return true;
}
这个方法就是用来判断,你给定路径的图片大小和你规定的忽略文件大小比较,他这里先做了你给定的最小值判断,要大于0,不大于0就返回ture。然后做了文件是否存在的判断,如果文件不存在,就返回fals。最后,给定文件大小是不是小于等于最小值左移10位的值,小于就返回false。
然后,如果返回的是true,就去压缩,如果,返回的是false,就直接返回file文件。压缩的方法点进去:
Engine(String srcImg, File tagImg) throws IOException {
if (Checker.isJPG(srcImg)) {
this.srcExif = new ExifInterface(srcImg);
}
this.tagImg = tagImg;
this.srcImg = srcImg;
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
options.inSampleSize = 1;
BitmapFactory.decodeFile(srcImg, options);
this.srcWidth = options.outWidth;
this.srcHeight = options.outHeight;
}
这就又要说道另一个类了Engine类,它的类注释就是:用于操作,开始压缩,管理活动,缓存资源的类。他这里传原文件,也就是你需要压缩的图片,还有一个就是目标文件,也就是你压缩之后,要保存的文件。
我们先看第二个参数是什么怎么传的,有的人看不懂
**
* Returns a mFile with a cache audio name in the private cache directory.
*
* @param context
* A context.
*/
private File getImageCacheFile(Context context, String suffix) {
if (TextUtils.isEmpty(mTargetDir)) {
mTargetDir = getImageCacheDir(context).getAbsolutePath();
}
String cacheBuilder = mTargetDir + "/" +
System.currentTimeMillis() +
(int) (Math.random() * 1000) +
(TextUtils.isEmpty(suffix) ? ".jpg" : suffix);
return new File(cacheBuilder);
}
他这里就是新建一个文件,设置路径,设置名称,然后返回文件
再掉回去看Engine的构造方法,我们这里获取到了源文件和目标文件,我们只用把压缩后的流存到目标文件就行了。我之前写过一篇关于图片压缩的博客。它这里的option就是设置压缩的参数,不懂的可以看一下我之前的博客,或者用google百度一下就知道了。具体压缩就是用的bitmap的工厂类,调用的decodeFile方法。没错就是这一句 BitmapFactory.decodeFile(srcImg, options);
最后,辣么一切都准备就绪了,怎么样开始压缩呢?compress()
File compress() throws IOException {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = computeSize();
Bitmap tagBitmap = BitmapFactory.decodeFile(srcImg, options);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
tagBitmap = rotatingImage(tagBitmap);
tagBitmap.compress(Bitmap.CompressFormat.JPEG, 60, stream);
tagBitmap.recycle();
FileOutputStream fos = new FileOutputStream(tagImg);
fos.write(stream.toByteArray());
fos.flush();
fos.close();
stream.close();
return tagImg;
}
这里面就是常规的压缩,存储的逻辑了,最最重要的压缩算法呢?就是这里的computeSize()方法
private int computeSize() {
srcWidth = srcWidth % 2 == 1 ? srcWidth + 1 : srcWidth;
srcHeight = srcHeight % 2 == 1 ? srcHeight + 1 : srcHeight;
int longSide = Math.max(srcWidth, srcHeight);
int shortSide = Math.min(srcWidth, srcHeight);
float scale = ((float) shortSide / longSide);
if (scale <= 1 && scale > 0.5625) {
if (longSide < 1664) {
return 1;
} else if (longSide >= 1664 && longSide < 4990) {
return 2;
} else if (longSide > 4990 && longSide < 10240) {
return 4;
} else {
return longSide / 1280 == 0 ? 1 : longSide / 1280;
}
} else if (scale <= 0.5625 && scale > 0.5) {
return longSide / 1280 == 0 ? 1 : longSide / 1280;
} else {
return (int) Math.ceil(longSide / (1280.0 / scale));
}
}
private Bitmap rotatingImage(Bitmap bitmap) {
if (srcExif == null) return bitmap;
Matrix matrix = new Matrix();
int angle = 0;
int orientation = srcExif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
switch (orientation) {
case ExifInterface.ORIENTATION_ROTATE_90:
angle = 90;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
angle = 180;
break;
case ExifInterface.ORIENTATION_ROTATE_270:
angle = 270;
break;
}
matrix.postRotate(angle);
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
}
算法步骤
第三挡压缩(参考最新版微信压缩效果)