Android 图片压缩之Luban

5,825 阅读5分钟

前言

目前做App开发总绕不开图片这个元素。但是随着手机拍照分辨率的提升,图片的压缩成为一个很重要的问题。单纯对图片进行裁切,压缩已经有很多文章介绍。但是裁切成多少,压缩成多少却很难控制好,裁切过头图片太小,质量压缩过头则显示效果太差。

于是自然想到App巨头“微信”会是怎么处理,Luban(鲁班)就是通过在微信朋友圈发送近100张不同分辨率图片,对比原图与微信压缩后的图片逆向推算出来的压缩算法。

因为有其他语言也想要实现Luban,所以描述了一遍算法步骤

因为是逆向推算,效果还没法跟微信一模一样,但是已经很接近微信朋友圈压缩后的效果,具体看以下对比!

效果与对比

对外方法

调用方式

异步调用

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();

源码剖析

目录结构

Checker.java

  /**
   * 用来判断是否是jpg图片
   *
   */
  private boolean isJPG(byte[] data) {
    if (data == null || data.length < 3) {
      return false;
    }
    byte[] signatureB = new byte[]{data[0], data[1], data[2]};
    return Arrays.equals(JPEG_SIGNATURE, signatureB);
  }
  
  /**
   * 图片是否需要压缩
   *
   */
  boolean needCompress(int leastCompressSize, String path) {
    if (leastCompressSize > 0) {
      File source = new File(path);
      return source.exists() && source.length() > (leastCompressSize << 10);
    }
    return true;
  }
  
  /**
   * android camera源码 用来获取图片的角度
   *
   */
  private int getOrientation(byte[] jpeg) {
    if (jpeg == null) {
      return 0;
    }

    int offset = 0;
    int length = 0;

    // ISO/IEC 10918-1:1993(E)
    while (offset + 3 < jpeg.length && (jpeg[offset++] & 0xFF) == 0xFF) {
      int marker = jpeg[offset] & 0xFF;

      // Check if the marker is a padding.
      if (marker == 0xFF) {
        continue;
      }
      offset++;

      // Check if the marker is SOI or TEM.
      if (marker == 0xD8 || marker == 0x01) {
        continue;
      }
      // Check if the marker is EOI or SOS.
      if (marker == 0xD9 || marker == 0xDA) {
        break;
      }

      // Get the length and check if it is reasonable.
      length = pack(jpeg, offset, 2, false);
      if (length < 2 || offset + length > jpeg.length) {
        Log.e(TAG, "Invalid length");
        return 0;
      }

      // Break if the marker is EXIF in APP1.
      if (marker == 0xE1 && length >= 8
          && pack(jpeg, offset + 2, 4, false) == 0x45786966
          && pack(jpeg, offset + 6, 2, false) == 0) {
        offset += 8;
        length -= 8;
        break;
      }

      // Skip other markers.
      offset += length;
      length = 0;
    }

    // JEITA CP-3451 Exif Version 2.2
    if (length > 8) {
      // Identify the byte order.
      int tag = pack(jpeg, offset, 4, false);
      if (tag != 0x49492A00 && tag != 0x4D4D002A) {
        Log.e(TAG, "Invalid byte order");
        return 0;
      }
      boolean littleEndian = (tag == 0x49492A00);

      // Get the offset and check if it is reasonable.
      int count = pack(jpeg, offset + 4, 4, littleEndian) + 2;
      if (count < 10 || count > length) {
        Log.e(TAG, "Invalid offset");
        return 0;
      }
      offset += count;
      length -= count;

      // Get the count and go through all the elements.
      count = pack(jpeg, offset - 2, 2, littleEndian);
      while (count-- > 0 && length >= 12) {
        // Get the tag and check if it is orientation.
        tag = pack(jpeg, offset, 2, littleEndian);
        if (tag == 0x0112) {
          int orientation = pack(jpeg, offset + 8, 2, littleEndian);
          switch (orientation) {
            case 1:
              return 0;
            case 3:
              return 180;
            case 6:
              return 90;
            case 8:
              return 270;
          }
          Log.e(TAG, "Unsupported orientation");
          return 0;
        }
        offset += 12;
        length -= 12;
      }
    }
    return 0;
  }

CompressionPredicate.java

断言是否需要压缩接口

public interface CompressionPredicate {

    /**
     * 断言的路径是否要压缩,并返回boolean值
     * @param path input path
     * @return the boolean result
     */
    boolean apply(String path);
}

Engine.java

用于操作,开始压缩,管理活动,缓存资源

/**
  * 构造函数
  *
  */
 Engine(InputStreamProvider srcImg, File tagImg, boolean focusAlpha) throws IOException {
    this.tagImg = tagImg;
    this.srcImg = srcImg;
    this.focusAlpha = focusAlpha;

    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    options.inSampleSize = 1;

    BitmapFactory.decodeStream(srcImg.open(), null, options);
    this.srcWidth = options.outWidth;
    this.srcHeight = options.outHeight;
 }
 
 /**
  * 计算压缩比例
  *
  */
 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 < 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));
    }
 }
 
 /**
  * 压缩
  *
  */
 File compress() throws IOException {
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inSampleSize = computeSize();

    Bitmap tagBitmap = BitmapFactory.decodeStream(srcImg.open(), null, options);
    ByteArrayOutputStream stream = new ByteArrayOutputStream();

    if (Checker.SINGLE.isJPG(srcImg.open())) {
      tagBitmap = rotatingImage(tagBitmap, Checker.SINGLE.getOrientation(srcImg.open()));
    }
    tagBitmap.compress(focusAlpha ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG, 60, stream);
    tagBitmap.recycle();

    FileOutputStream fos = new FileOutputStream(tagImg);
    fos.write(stream.toByteArray());
    fos.flush();
    fos.close();
    stream.close();

    return tagImg;
 }

InputStreamProvider.java

获取输入流兼容文件、FileProvider方式获取图片接口

public interface InputStreamProvider {
  InputStream open() throws IOException;
  void close();
  String getPath();
}

InputStreamAdapter.java

InputStreamProvider类的实现

  @Override
  public InputStream open() throws IOException {
    close();
    inputStream = openInternal();
    return inputStream;
  }

  public abstract InputStream openInternal() throws IOException;

  @Override
  public void close() {
    if (inputStream != null) {
      try {
        inputStream.close();
      } catch (IOException ignore) {
      }finally {
        inputStream = null;
      }
    }
  }

OnCompressListener.java

public interface OnCompressListener {

  /**
   * 开始压缩
   */
  void onStart();

  /**
   * 压缩成功
   */
  void onSuccess(File file);

  /**
   * 压缩异常
   */
  void onError(Throwable e);
}

OnRenameListener.java

public interface OnRenameListener {

  /**
   * 压缩前调用该方法用于修改压缩后文件名
   *
   */
  String rename(String filePath);
}

Luban.java

加载图片的几种方式

/**
 * 加载图片
 *
 */
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 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;
}

/**
 * 加载图片列表
 *
 */
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;
}

压缩可设置的先行条件

/**
 * 压缩的最小单位值,单位kB,默认100kb
 *
 */
public Builder ignoreBy(int size) {
      this.mLeastCompressSize = size;
      return this;
}

/**
 * 压缩断言
 *
 */  
public Builder filter(CompressionPredicate compressionPredicate) {
      this.mCompressionPredicate = compressionPredicate;
      return this;
}

压缩其他配置

/**
 * 压缩后目录
 *
 */  
public Builder setTargetDir(String targetDir) {
      this.mTargetDir = targetDir;
      return this;
}

/**
 * 是否开启透明通道,true为png格式压缩,false为jpg格式压缩
 *
 */  
public Builder setFocusAlpha(boolean focusAlpha) {
      this.focusAlpha = focusAlpha;
      return this;      
}

压缩启动

/**
 * 开启压缩
 *
 */  
private void launch(final Context context) {
    if (mStreamProviders == null || mStreamProviders.size() == 0 && mCompressListener != null) {
      mCompressListener.onError(new NullPointerException("image file cannot be null"));
    }

    Iterator<InputStreamProvider> iterator = mStreamProviders.iterator();

    while (iterator.hasNext()) {
      final InputStreamProvider path = iterator.next();

      AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
        @Override
        public void run() {
          try {
            mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_START));

            File result = compress(context, path);

            mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_SUCCESS, result));
          } catch (IOException e) {
            mHandler.sendMessage(mHandler.obtainMessage(MSG_COMPRESS_ERROR, e));
          }
        }
      });

      iterator.remove();
    }
}