Android大图预览

1,961 阅读6分钟

前言

加载高清大图时,往往会有不能缩放分段加载的需求出现。本文将就BitmapRegionDecodersubsampling-scale-image-view的使用总结一下Bitmap的分区域解码

定义

image.png

假设现在有一张这样的图片,尺寸为3040 × 1280。如果按需要完全显示在ImageView上的话就必须进行压缩处理。当需求要求是不能缩放的时候,就需要进行分段查看了。由于像这种尺寸大小的图片在加载到内存后容易造成OOM,所以需要进行区域解码

图中红框的部分就是需要区域解码的部分,即每次只有进行红框区域大小的解码,在需要看其余部分时可以通过如拖动等手势来移动红框区域,达到查看全图的目的。

BitmapRegionDecoder

Android原生提供了BitmapRegionDecoder用于实现Bitmap的区域解码,简单使用的api如下:

// 根据图片文件的InputStream创建BitmapRegionDecoder
val decoder = BitmapRegionDecoder.newInstance(inputStream, false)

val option: BitmapFactory.Options = BitmapFactory.Options()
val rect: Rect = Rect(0, 0, 100, 100)

// rect制定的区域即为需要区域解码的区域
decoder.decodeRegion(rect, option)
  • 通过BitmapRegionDecoder.newInstance可以根据图片文件的InputStream对象创建一个BitmapRegionDecoder对象。
  • decodeRegion方法传入一个Rect和一个BitmapFactory.Options,前者用于规定解码区域,后者用于配置Bitmap,如inSampleSize、inPreferredConfig等。ps:解码区域必须在图片宽高范围内,否则会出现崩溃。

区域解码与全图解码

通过区域解码得到的Bitmap,宽高和占用内存只是指定区域的图像所需要的大小

譬如按1080 × 1037区域大小加载,可以查看Bitmap的allocationByteCount为4479840,即1080 * 1037 * 4

image.png

若直接按全图解码,allocationByteCount为15564800,即3040 * 1280 * 4image.png

可以看到,区域解码的好处是图像不会完整的被加载到内存中,而是按需加载了。

自定义一个图片查看的View

由于BitmapRegionDecoder只是实现区域解码,如果改变这个区域还是需要开发者通过具体交互实现。这里用触摸事件简单实现了一个自定义View。由于只是简单依赖触摸事件,滑动的灵敏度还是偏高,实际开发可以实现一些自定义的拖拽工具来进行辅助。代码比较简单,可参考注释。

class RegionImageView @JvmOverloads constructor(
    context: Context,
    attr: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attr, defStyleAttr) {

    private var decoder: BitmapRegionDecoder? = null
    private val option: BitmapFactory.Options = BitmapFactory.Options()
    private val rect: Rect = Rect()

    private var lastX: Float = -1f
    private var lastY: Float = -1f

    fun setImage(fileName: String) {
        val inputStream = context.assets.open(fileName)
        try {
            this.decoder = BitmapRegionDecoder.newInstance(inputStream, false)
            
            // 触发onMeasure,用于更新Rect的初始值
            requestLayout()
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            inputStream.close()
        }
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                this.decoder ?: return false
                this.lastX = event.x
                this.lastY = event.y
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                val decoder = this.decoder ?: return false
                val dx = event.x - this.lastX
                val dy = event.y - this.lastY
                
                // 每次MOVE事件根据前后差值对Rect进行更新,需要注意不能超过图片的实际宽高
                if (decoder.width > width) {
                    this.rect.offset(-dx.toInt(), 0)
                    if (this.rect.right > decoder.width) {
                        this.rect.right = decoder.width
                        this.rect.left = decoder.width - width
                    } else if (this.rect.left < 0) {
                        this.rect.right = width
                        this.rect.left = 0
                    }
                    invalidate()
                }
                if (decoder.height > height) {
                    this.rect.offset(0, -dy.toInt())
                    if (this.rect.bottom > decoder.height) {
                        this.rect.bottom = decoder.height
                        this.rect.top = decoder.height - height
                    } else if (this.rect.top < 0) {
                        this.rect.bottom = height
                        this.rect.top = 0
                    }
                    invalidate()
                }
            }
            MotionEvent.ACTION_UP -> {
                this.lastX = -1f
                this.lastY = -1f
            }
            else -> {

            }
        }

        return super.onTouchEvent(event)
    }

    // 测量后默认第一次加载的区域是从0开始到控件的宽高大小
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val w = MeasureSpec.getSize(widthMeasureSpec)
        val h = MeasureSpec.getSize(heightMeasureSpec)

        this.rect.left = 0
        this.rect.top = 0
        this.rect.right = w
        this.rect.bottom = h
    }
    
    // 每次绘制时,通过BitmapRegionDecoder解码出当前区域的Bitmap
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas?.let {
            val bitmap = this.decoder?.decodeRegion(rect, option) ?: return
            it.drawBitmap(bitmap, 0f, 0f, null)
        }
    }
}

SubsamplingScaleImageView

davemorrissey/subsampling-scale-image-view可以用于加载超大尺寸的图片,避免大内存导致的OOM,内部依赖的也是BitmapRegionDecoder。好处是SubsamplingScaleImageView已经帮我们实现了相关的手势如拖动、缩放,内部还实现了二次采样和区块显示的逻辑。

如果需要加载assets目录下的图片,可以这样调用

subsamplingScaleImageView.setImage(ImageSource.asset("sample1.jpeg"))
public final class ImageSource {

    static final String FILE_SCHEME = "file:///";
    static final String ASSET_SCHEME = "file:///android_asset/";

    private final Uri uri;
    private final Bitmap bitmap;
    private final Integer resource;
    private boolean tile;
    private int sWidth;
    private int sHeight;
    private Rect sRegion;
    private boolean cached;

ImageSource是对图片资源信息的抽象

  • uri、bitmap、resource分别指代图像来源是文件、解析好的Bitmap对象还是resourceId。
  • tile:是否需要分片加载,一般以uri、resource形式加载的都会为true。
  • sWidth、sHeight、sRegion:加载图片的宽高和区域,一般可以指定加载图片的特定区域,而不是全图加载
  • cached:控制重置时,是否需要recycle掉Bitmap
public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource, ImageViewState state) {
    ...

    if (imageSource.getBitmap() != null && imageSource.getSRegion() != null) {
        ...
    } else if (imageSource.getBitmap() != null) {
        ...
    } else {
        sRegion = imageSource.getSRegion();
        uri = imageSource.getUri();
        if (uri == null && imageSource.getResource() != null) {
            uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + imageSource.getResource());
        }
        if (imageSource.getTile() || sRegion != null) {
            // Load the bitmap using tile decoding.
            TilesInitTask task = new TilesInitTask(this, getContext(), regionDecoderFactory, uri);
            execute(task);
        } else {
            // Load the bitmap as a single image.
            BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
            execute(task);
        }
    }
}

由于在我们的调用下,tile为true,setImage方法最后会走到一个TilesInitTask当中。是一个AsyncTask。ps:该库中的多线程异步操作都是通过AsyncTask封装的。

// TilesInitTask
@Override
protected int[] doInBackground(Void... params) {
    try {
        String sourceUri = source.toString();
        Context context = contextRef.get();
        DecoderFactory<? extends ImageRegionDecoder> decoderFactory = decoderFactoryRef.get();
        SubsamplingScaleImageView view = viewRef.get();
        if (context != null && decoderFactory != null && view != null) {
            view.debug("TilesInitTask.doInBackground");
            decoder = decoderFactory.make();
            Point dimensions = decoder.init(context, source);
            int sWidth = dimensions.x;
            int sHeight = dimensions.y;
            int exifOrientation = view.getExifOrientation(context, sourceUri);
            if (view.sRegion != null) {
                view.sRegion.left = Math.max(0, view.sRegion.left);
                view.sRegion.top = Math.max(0, view.sRegion.top);
                view.sRegion.right = Math.min(sWidth, view.sRegion.right);
                view.sRegion.bottom = Math.min(sHeight, view.sRegion.bottom);
                sWidth = view.sRegion.width();
                sHeight = view.sRegion.height();
            }
            return new int[] { sWidth, sHeight, exifOrientation };
        }
    } catch (Exception e) {
        Log.e(TAG, "Failed to initialise bitmap decoder", e);
        this.exception = e;
    }
    return null;
}

@Override
protected void onPostExecute(int[] xyo) {
    final SubsamplingScaleImageView view = viewRef.get();
    if (view != null) {
        if (decoder != null && xyo != null && xyo.length == 3) {
            view.onTilesInited(decoder, xyo[0], xyo[1], xyo[2]);
        } else if (exception != null && view.onImageEventListener != null) {
            view.onImageEventListener.onImageLoadError(exception);
        }
    }
}

TilesInitTask主要的操作是创建一个SkiaImageRegionDecoder,它主要的作用是封装BitmapRegionDecoder。通过BitmapRegionDecoder获取图片的具体宽高和在Exif中获取图片的方向,便于显示调整。

后续会在onDraw时调用initialiseBaseLayer方法进行图片的加载操作,这里会根据比例计算出采样率来决定是否需要区域解码还是全图解码。值得一提的是,当采样率为1,图片宽高小于Canvas的getMaximumBitmapWidth()getMaximumBitmapHeight()时,也是会直接进行全图解码的。这里调用的TileLoadTask就是使用BitmapRegionDecoder进行解码的操作。

ps:Tile对象为区域的抽象类型,内部会包含指定区域的Bitmap,在onDraw时会根据区域通过Matrix绘制到Canvas上。

private synchronized void initialiseBaseLayer(@NonNull Point maxTileDimensions) {
    debug("initialiseBaseLayer maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y);

    satTemp = new ScaleAndTranslate(0f, new PointF(0, 0));
    fitToBounds(true, satTemp);

    // Load double resolution - next level will be split into four tiles and at the center all four are required,
    // so don't bother with tiling until the next level 16 tiles are needed.
    fullImageSampleSize = calculateInSampleSize(satTemp.scale);
    if (fullImageSampleSize > 1) {
        fullImageSampleSize /= 2;
    }

    if (fullImageSampleSize == 1 && sRegion == null && sWidth() < maxTileDimensions.x && sHeight() < maxTileDimensions.y) {
        // Whole image is required at native resolution, and is smaller than the canvas max bitmap size.
        // Use BitmapDecoder for better image support.
        decoder.recycle();
        decoder = null;
        BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
        execute(task);
    } else {
        initialiseTileMap(maxTileDimensions);

        List<Tile> baseGrid = tileMap.get(fullImageSampleSize);
        for (Tile baseTile : baseGrid) {
            TileLoadTask task = new TileLoadTask(this, decoder, baseTile);
            execute(task);
        }
        refreshRequiredTiles(true);

    }

}

加载网络图片

BitmapRegionDecoder只能加载本地图片,而如果需要加载网络图片,可以结合Glide使用,以SubsamplingScaleImageView为例

Glide.with(this)
    .asFile()
    .load("")
    .into(object : CustomTarget<File?>() {
        override fun onResourceReady(resource: File, transition: Transition<in File?>?) {
            subsamplingScaleImageView.setImage(ImageSource.uri(Uri.fromFile(resource)))
        }

        override fun onLoadCleared(placeholder: Drawable?) {}
})

可以通过CustomTarget获取到图片的File文件,然后再调用SubsamplingScaleImageView#setImage

最后

本文主要总结Bitmap的分区域解码,利用原生的BitmapRegionDecoder可实现区域解码,通过SubsamplingScaleImageView可以对BitmapRegionDecoder进行进一步的交互扩展和优化。如果需要是TV端开发可以参考这篇文章,里面有结合具体的TV端操作适配:Android实现TV端大图浏览

参考文章:

Android实现TV端大图浏览

tddrv.cn/a/233555

Android 超大图长图浏览库 SubsamplingScaleImageView 源码解析