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 的實現。這就是我們為啥需要讀源碼,學習源碼。
接著我們往下看,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載入長圖源碼分析總結
//juejin.cn/post/6955427322291814431
//juejin.cn/post/6844903910088392712