Android 超大图长图浏览库 SubsamplingScaleImageView 源码解析
一开始没打算分析 SubsamplingScaleImageView 这个开源的图片浏览器的,因为这个库在我们 App 中使用了,觉得自己对这个库还是比较熟悉的,结果某天再看看到源码介绍的时候,才发现自己对其了解并不够深入,所以这才打算再细细看看源码的实现,同时记录方便以后回顾。
那么 SubsamplingScaleImageView 有啥优点呢?
-
采用 GestureDetector 进行手势控制,支持图片的点击,双击,滑动等来控制的放大缩小;
-
使用了 BitmapRegionDecoder,具有分块加载功能;
-
支持查看长图,超大图
上面的优点简直就是非常实用,基本上拿来就可以直接用,简单省力。
下面就是要来分析,它是如何满足这些优点的。
源码分析
首先附上源码地址:
使用说明
如果可以拿到图片的资源id,assert或者文件路径,直接使用下面方式进行使用:
SubsamplingScaleImageView imageView = (SubsamplingScaleImageView)findViewById(id.imageView); imageView.setImage(ImageSource.resource(R.drawable.monkey)); // ... or ... imageView.setImage(ImageSource.asset("map.png")) // ... or ... imageView.setImage(ImageSource.uri("/sdcard/DCIM/DSCM00123.JPG"));
如果可以拿到 bitmap 就可以这么使用:
SubsamplingScaleImageView imageView = (SubsamplingScaleImageView)findViewById(id.imageView);
imageView.setImage(ImageSource.bitmap(bitmap));
ImageSource
在上节使用的过程中,发现都是依赖 ImageSource 来进行控制的,下面看下
// 缩减之后的部分源码 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; private ImageSource(int resource) { this.bitmap = null; this.uri = null; this.resource = resource; this.tile = true; } }
简单来说,ImageSource 的作用跟它的命名是一样的,用来处理图片地址来源,最后 SubsamplingScaleImageView 也是从它获取图片的。这个类有好几个属性, uri bitmap resource这几个就是图片的来源, 还有几个是图片的尺寸,而我们调用的构造方法里面主要是resource和tile这两个属性, tile = true说明支持局部加载属性。
这个也是我们需要借鉴的。当我们再写一个图片库的时候,除了支持网络图片,也要考虑其他场景,比如对本地图片和资源的支持。还有就是如果你不知道怎么去支持的时候,这时候就可以看看 ImageSource 的实现。这就是我们为啥需要读源码,学习源码。
这里还有个点需要注意的是,如果直接给 bitmap 传给 ImageSource 是不会触发瓦片式加载的。因为整个图片的 bitmap 已经存在了,在做瓦片式意义不大。
接着我们往下看,setImage 方法
public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource, ImageViewState state) { //noinspection ConstantConditions 为空直接抛出异常 if (imageSource == null) { throw new NullPointerException("imageSource must not be null"); } reset(true); // 新图片,一切重置 if (state != null) { restoreState(state); } // 一般情况下都是为 nuLl,这里就不看了 if (previewSource != null) { if (imageSource.getBitmap() != null) { throw new IllegalArgumentException("Preview image cannot be used when a bitmap is provided for the main image"); } if (imageSource.getSWidth() <= 0 || imageSource.getSHeight() <= 0) { throw new IllegalArgumentException("Preview image cannot be used unless dimensions are provided for the main image"); } this.sWidth = imageSource.getSWidth(); this.sHeight = imageSource.getSHeight(); this.pRegion = previewSource.getSRegion(); if (previewSource.getBitmap() != null) { this.bitmapIsCached = previewSource.isCached(); onPreviewLoaded(previewSource.getBitmap()); } else { Uri uri = previewSource.getUri(); if (uri == null && previewSource.getResource() != null) { uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + previewSource.getResource()); } BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, true); execute(task); } } // 下面加载图片会分成好几种类型进行加载,比如是否设置了 region,bitmap,uri,不同的参数,会有不同的加载方式 if (imageSource.getBitmap() != null && imageSource.getSRegion() != null) { onImageLoaded(Bitmap.createBitmap(imageSource.getBitmap(), imageSource.getSRegion().left, imageSource.getSRegion().top, imageSource.getSRegion().width(), imageSource.getSRegion().height()), ORIENTATION_0, false); } else if (imageSource.getBitmap() != null) { onImageLoaded(imageSource.getBitmap(), ORIENTATION_0, imageSource.isCached()); } else { sRegion = imageSource.getSRegion(); uri = imageSource.getUri(); if (uri == null && imageSource.getResource() != null) { uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + imageSource.getResource()); }
// 这里会判断是否要开启瓦片加载形式,或者设置了 region 就说明需要开启瓦片加载方式 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); } } }
private static class TilesInitTask extends AsyncTask<Void, Void, int[]> { @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 decoder = decoderFactory.make(); Point dimensions = decoder.init(context, source); int sWidth = dimensions.x; int sHeight = dimensions.y; int exifOrientation = view.getExifOrientation(context, sourceUri);
// 获取 region,或者说修正 region 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); } } } }
在后台执行的主要事情是调用了解码器decoder的初始化方法,获取图片的宽高信息,然后再回到主线程调用onTilesInited方法通知已经初始化完成。我们先看初始化方法做的事情,先找到解码器,内置的解码器工厂如下,
private DecoderFactory<? extends ImageRegionDecoder> regionDecoderFactory = new CompatDecoderFactory<ImageRegionDecoder>(SkiaImageRegionDecoder.class);
所以我们只需看看 SkiaImageRegionDecoder 这个decoder 既可:
public class SkiaImageRegionDecoder implements ImageRegionDecoder { private BitmapRegionDecoder decoder; private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true); private static final String FILE_PREFIX = "file://"; private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/"; private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://"; private final Bitmap.Config bitmapConfig; @Keep @SuppressWarnings("unused") public SkiaImageRegionDecoder() { this(null); } @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) public SkiaImageRegionDecoder(@Nullable Bitmap.Config bitmapConfig) { Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig(); if (bitmapConfig != null) { this.bitmapConfig = bitmapConfig; } else if (globalBitmapConfig != null) { this.bitmapConfig = globalBitmapConfig; } else {
// 如果没有传配置,就会使用 565 的方式,这样一个像素占有2个字节,16位 = 5+6+5 this.bitmapConfig = Bitmap.Config.RGB_565; } } @Override @NonNull
// 总结起来就是根据不同的图片资源类型来选择合适的 regiondecoder 进行解析,最终返回的是图片的宽高。 public Point init(Context context, @NonNull Uri uri) throws Exception { String uriString = uri.toString(); if (uriString.startsWith(RESOURCE_PREFIX)) { Resources res; String packageName = uri.getAuthority(); if (context.getPackageName().equals(packageName)) { res = context.getResources(); } else { PackageManager pm = context.getPackageManager(); res = pm.getResourcesForApplication(packageName); } int id = 0; List<String> segments = uri.getPathSegments(); int size = segments.size(); if (size == 2 && segments.get(0).equals("drawable")) { String resName = segments.get(1); id = res.getIdentifier(resName, "drawable", packageName); } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) { try { id = Integer.parseInt(segments.get(0)); } catch (NumberFormatException ignored) { } } decoder = BitmapRegionDecoder.newInstance(context.getResources().openRawResource(id), false); } else if (uriString.startsWith(ASSET_PREFIX)) { String assetName = uriString.substring(ASSET_PREFIX.length()); decoder = BitmapRegionDecoder.newInstance(context.getAssets().open(assetName, AssetManager.ACCESS_RANDOM), false); } else if (uriString.startsWith(FILE_PREFIX)) { decoder = BitmapRegionDecoder.newInstance(uriString.substring(FILE_PREFIX.length()), false); } else { InputStream inputStream = null; try { ContentResolver contentResolver = context.getContentResolver(); inputStream = contentResolver.openInputStream(uri); if (inputStream == null) { throw new Exception("Content resolver returned null stream. Unable to initialise with uri."); } decoder = BitmapRegionDecoder.newInstance(inputStream, false); } finally { if (inputStream != null) { try { inputStream.close(); } catch (Exception e) { /* Ignore */ } } } } return new Point(decoder.getWidth(), decoder.getHeight()); }
SkiaImageRegionDecoder 主要就是根据图片资源类型选择一个合适的 RegionDecoder。接下去再看看 onTilesInited 都做了啥:
// overrides for the dimensions of the generated tiles 省略无关的代码 public static final int TILE_SIZE_AUTO = Integer.MAX_VALUE; private int maxTileWidth = TILE_SIZE_AUTO; private int maxTileHeight = TILE_SIZE_AUTO; this.decoder = decoder; this.sWidth = sWidth; this.sHeight = sHeight; this.sOrientation = sOrientation; checkReady(); if (!checkImageLoaded() && maxTileWidth > 0 && maxTileWidth != TILE_SIZE_AUTO && maxTileHeight > 0 && maxTileHeight != TILE_SIZE_AUTO && getWidth() > 0 && getHeight() > 0) { initialiseBaseLayer(new Point(maxTileWidth, maxTileHeight)); } invalidate(); requestLayout();
这里就将相关参数都传给 SubsamplingScaleImageView 了,后续就可以直接用了。可以看到最后调用了invalidate 和 requestLayout,也就说最终会触发重绘操作。
绘制流程
onMeasure
比较简单,这块就直接略过了。
ondraw
下面直接看 ondraw 方法。ondraw 的方法很长,我们主要看一些关键逻辑:
protected void onDraw(Canvas canvas) { super.onDraw(canvas); createPaints(); // When using tiles, on first render with no tile map ready, initialise it and kick off async base image loading. if (tileMap == null && decoder != null) { initialiseBaseLayer(getMaxBitmapDimensions(canvas)); } preDraw(); if (tileMap != null && isBaseLayerReady()) { // Optimum sample size for current scale int sampleSize = Math.min(fullImageSampleSize, calculateInSampleSize(scale)); // First check for missing tiles - if there are any we need the base layer underneath to avoid gaps boolean hasMissingTiles = false; for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { if (tileMapEntry.getKey() == sampleSize) { for (Tile tile : tileMapEntry.getValue()) { if (tile.visible && (tile.loading || tile.bitmap == null)) { hasMissingTiles = true; } } } } // Render all loaded tiles. LinkedHashMap used for bottom up rendering - lower res tiles underneath. for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { if (tileMapEntry.getKey() == sampleSize || hasMissingTiles) { for (Tile tile : tileMapEntry.getValue()) { sourceToViewRect(tile.sRect, tile.vRect); if (!tile.loading && tile.bitmap != null) { if (tileBgPaint != null) { canvas.drawRect(tile.vRect, tileBgPaint); } if (matrix == null) { matrix = new Matrix(); } matrix.reset(); setMatrixArray(srcArray, 0, 0, tile.bitmap.getWidth(), 0, tile.bitmap.getWidth(), tile.bitmap.getHeight(), 0, tile.bitmap.getHeight()); matrix.setPolyToPoly(srcArray, 0, dstArray, 0, 4); canvas.drawBitmap(tile.bitmap, matrix, bitmapPaint); } } } } else if (bitmap != null) { float xScale = scale, yScale = scale; if (bitmapIsPreview) { xScale = scale * ((float)sWidth/bitmap.getWidth()); yScale = scale * ((float)sHeight/bitmap.getHeight()); } if (matrix == null) { matrix = new Matrix(); } matrix.reset(); matrix.postScale(xScale, yScale); matrix.postRotate(getRequiredRotation()); matrix.postTranslate(vTranslate.x, vTranslate.y); if (tileBgPaint != null) { if (sRect == null) { sRect = new RectF(); } sRect.set(0f, 0f, bitmapIsPreview ? bitmap.getWidth() : sWidth, bitmapIsPreview ? bitmap.getHeight() : sHeight); matrix.mapRect(sRect); canvas.drawRect(sRect, tileBgPaint); } canvas.drawBitmap(bitmap, matrix, bitmapPaint); } }
onDraw主要做了几件事,initialiseBaseLayer,设置tileMap,最后就是先优先tileMap进行drawBitmap,再取bitmap绘制,我们先看看initialiseBaseLayer做了什么。
initialiseBaseLayer
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); // 计算采样率,要不要samplesize 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. 不需要regiondecoder ,直接加载图片 decoder.recycle(); decoder = null; BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false); execute(task); } else { // 需要进行瓦片化加载 initialiseTileMap(maxTileDimensions); // 首先取出当前屏幕需要的采样率, fullImageSampleSIze 就是当前屏幕所需要的采样率,并不是对map所有的数据都进行解压 List<Tile> baseGrid = tileMap.get(fullImageSampleSize); for (Tile baseTile : baseGrid) { TileLoadTask task = new TileLoadTask(this, decoder, baseTile); execute(task); }
// 按照要求来加载展示图片,同时对不是该采样率的 bitmap 进行回收 refreshRequiredTiles(true); } }
ScaleAndTranslate是存储了绘制的时候的偏移量和缩放级别,调用 fitToBounds 其实就是先对基本的偏移位置等设置好。然后计算采用率来决定要不要进行 regiondecoder。
下面直接看 regiondecoder 相关逻辑。首先是要对 TileMap 进行初始化。
private void initialiseTileMap(Point maxTileDimensions) { debug("initialiseTileMap maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y); this.tileMap = new LinkedHashMap<>(); int sampleSize = fullImageSampleSize; // 采样率 int xTiles = 1; int yTiles = 1; while (true) { // 死循环 int sTileWidth = sWidth()/xTiles; // 即将被采样的图片大小 int sTileHeight = sHeight()/yTiles; int subTileWidth = sTileWidth/sampleSize; // 采样率下的图片大小 int subTileHeight = sTileHeight/sampleSize;
// maxTileDimensions 本质上就是 cavas 可以支持的最大宽高,这里调整 subtileWidth 的宽度,使得其可以显示在屏幕上,这里需要注意的是,一块tile 其实还包含1/4的不可见区域(屏幕外) while (subTileWidth + xTiles + 1 > maxTileDimensions.x || (subTileWidth > getWidth() * 1.25 && sampleSize < fullImageSampleSize)) { xTiles += 1; sTileWidth = sWidth()/xTiles; subTileWidth = sTileWidth/sampleSize;
// 当采样率为1的时候,由于此时采样后图片依旧远远大于屏幕宽度,因此,会被分割成块数也会更多 } while (subTileHeight + yTiles + 1 > maxTileDimensions.y || (subTileHeight > getHeight() * 1.25 && sampleSize < fullImageSampleSize)) { yTiles += 1; sTileHeight = sHeight()/yTiles; subTileHeight = sTileHeight/sampleSize; }
// 最终划分的块数 List<Tile> tileGrid = new ArrayList<>(xTiles * yTiles); for (int x = 0; x < xTiles; x++) { for (int y = 0; y < yTiles; y++) { Tile tile = new Tile(); tile.sampleSize = sampleSize; tile.visible = sampleSize == fullImageSampleSize; // 当前是否可见 tile.sRect = new Rect( x * sTileWidth, y * sTileHeight, x == xTiles - 1 ? sWidth() : (x + 1) * sTileWidth, y == yTiles - 1 ? sHeight() : (y + 1) * sTileHeight ); tile.vRect = new Rect(0, 0, 0, 0); tile.fileSRect = new Rect(tile.sRect); tileGrid.add(tile); } }
// 以采样率当做key 值,对应的 list 分块当做value tileMap.put(sampleSize, tileGrid);
// 采样率为1 就退出 if (sampleSize == 1) { break; } else { sampleSize /= 2; } } }
fileSRect是一个切片的矩阵大小,每一个切片的矩阵大小要确保在对应的缩放级别和采样率下能够显示正常。 初始化切片之后,就执行当前采样率下的TileLoadTask。
/** * Async task used to load images without blocking the UI thread. */ private static class TileLoadTask extends AsyncTask<Void, Void, Bitmap> { private final WeakReference<SubsamplingScaleImageView> viewRef; private final WeakReference<ImageRegionDecoder> decoderRef; private final WeakReference<Tile> tileRef; private Exception exception; TileLoadTask(SubsamplingScaleImageView view, ImageRegionDecoder decoder, Tile tile) { this.viewRef = new WeakReference<>(view); this.decoderRef = new WeakReference<>(decoder); this.tileRef = new WeakReference<>(tile); tile.loading = true; } @Override protected Bitmap doInBackground(Void... params) { try { SubsamplingScaleImageView view = viewRef.get(); ImageRegionDecoder decoder = decoderRef.get(); Tile tile = tileRef.get(); if (decoder != null && tile != null && view != null && decoder.isReady() && tile.visible) { view.debug("TileLoadTask.doInBackground, tile.sRect=%s, tile.sampleSize=%d", tile.sRect, tile.sampleSize); view.decoderLock.readLock().lock(); try { if (decoder.isReady()) { // Update tile's file sRect according to rotation 如果用户有过操作,需要对 rect 进行调整 view.fileSRect(tile.sRect, tile.fileSRect); if (view.sRegion != null) { tile.fileSRect.offset(view.sRegion.left, view.sRegion.top); } return decoder.decodeRegion(tile.fileSRect, tile.sampleSize); } else { tile.loading = false; } } finally { view.decoderLock.readLock().unlock(); } } else if (tile != null) { tile.loading = false; } } catch (Exception e) { Log.e(TAG, "Failed to decode tile", e); this.exception = e; } catch (OutOfMemoryError e) { Log.e(TAG, "Failed to decode tile - OutOfMemoryError", e); this.exception = new RuntimeException(e); } return null; } @Override protected void onPostExecute(Bitmap bitmap) { final SubsamplingScaleImageView subsamplingScaleImageView = viewRef.get(); final Tile tile = tileRef.get(); if (subsamplingScaleImageView != null && tile != null) { if (bitmap != null) { tile.bitmap = bitmap; tile.loading = false; subsamplingScaleImageView.onTileLoaded(); } else if (exception != null && subsamplingScaleImageView.onImageEventListener != null) { subsamplingScaleImageView.onImageEventListener.onTileLoadError(exception); } } } } /** * Called by worker task when a tile has loaded. Redraws the view. */ private synchronized void onTileLoaded() { debug("onTileLoaded"); checkReady(); checkImageLoaded(); if (isBaseLayerReady() && bitmap != null) { if (!bitmapIsCached) { bitmap.recycle(); } bitmap = null; if (onImageEventListener != null && bitmapIsCached) { onImageEventListener.onPreviewReleased(); } bitmapIsPreview = false; bitmapIsCached = false; } invalidate(); // 进行重绘 }
整体而言,没太多复杂逻辑,这里采用异步加载来获取bitmap,中间会调整 filerect,bitmap 解压完成后,就会重新绘制。
preDraw
没有太多逻辑,主要就是绘制前一些准备工作,包括缩放,位置等等。
isBaseLayerReady
主要就是看 tileMap 里面的 bitmap 是否准备好了。
for (Map.Entry<Integer, List<Tile>> tileMapEntry : tileMap.entrySet()) { if (tileMapEntry.getKey() == sampleSize || hasMissingTiles) { for (Tile tile : tileMapEntry.getValue()) { sourceToViewRect(tile.sRect, tile.vRect); if (!tile.loading && tile.bitmap != null) { if (tileBgPaint != null) { canvas.drawRect(tile.vRect, tileBgPaint); } matrix.reset(); setMatrixArray(srcArray, 0, 0, tile.bitmap.getWidth(), 0, tile.bitmap.getWidth(), tile.bitmap.getHeight(), 0, tile.bitmap.getHeight()); setMatrixArray(dstArray, tile.vRect.left, tile.vRect.top, tile.vRect.right, tile.vRect.top, tile.vRect.right, tile.vRect.bottom, tile.vRect.left, tile.vRect.bottom); matrix.setPolyToPoly(srcArray, 0, dstArray, 0, 4); canvas.drawBitmap(tile.bitmap, matrix, bitmapPaint); } } } }
这就是切片绘制的关键代码,在Tile这个类中,sRect负责保存切片的原始大小,vRect则负责保存切片的绘制大小,所以 sourceToViewRect(tile.sRect, tile.vRect) 这里进行了矩阵的缩放,其实就是根据之前计算得到的scale对图片原始大小进行缩放。 接着再通过矩阵变换,将图片大小变换为绘制大小进行绘制。分析到这里,其实整个的加载过程和逻辑已经是了解得七七八八了。 还有另外的就是手势缩放的处理,通过监听move等触摸事件,然后重新计算scale的大小,接着通过scale的大小去重新得到对应的采样率,继续通过tileMap取出采样率下对应的切片,对切片请求解码。值得一提的是,在move事件的时候,这里做了优化,解码的图片并没有进行绘制,而是对原先采样率下的图片进行缩放,直到监听到up事件,才会去重新绘制对应采样率下的图片。所以在缩放的过程中,会看到一个模糊的图像,其实就是高采样率下的图片进行放大导致的。等到缩放结束,会重新绘制,图片就显示正常了。 流程图如下:
到这里,SubsamplingScaleImageView 的关键逻辑就讲完了,希望对大家有帮助。
参考文章
subsampling-scale-image-view加载长图源码分析总结
https://juejin.cn/post/6955427322291814431
https://juejin.cn/post/6844903910088392712