1. 程式人生 > >subsampling-scale-image-view部分載入bitmap原始碼分析(一)

subsampling-scale-image-view部分載入bitmap原始碼分析(一)

subsampling-scale-image-view原始碼分析

背景

對於安卓開發人員,最頭疼的問題就是記憶體問題了,而記憶體問題又當屬bitmap最頭疼,雖然說現在市面上已經有越來越多成熟的圖片載入框架,像Fresco,Glide,它們也確實幫我更好地管理了圖片的記憶體,生命週期等,但是還是有一個比較棘手的問題,那就是大圖長圖的載入,動輒750 * 30000的長圖,如果一次性不壓縮加載出來,記憶體就暴漲,如下圖: 在這裡插入圖片描述 看著這接近90度的走勢,嚇得我腎上腺也飆升。那既然一次性載入太耗記憶體,那就區域性載入不就等了,系統還真的提供了這樣的類,那就是BitmapRegionDecoder

,鴻洋大神也寫過一篇載入巨圖的部落格,就是利用了BitmapRegionDecoder,傳送門Android 高清載入巨圖方案 拒絕壓縮圖片。但是今天的主角是subsampling-scale-image-view它,它不僅提供了局部載入長圖,還提供縮放支援,這是它的github主頁,接下來我們就來看看它的用法。

介紹使用

首先是引入依賴

dependencies {
    implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
}

佈局檔案引入控制元件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
        android:id="@+id/image_big"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop"/>

</RelativeLayout>

程式碼呼叫

        image_big.setImage(ImageSource.resource(R.mipmap.ic_long))

拭目以待看看記憶體佔用吧 在這裡插入圖片描述

此時只想發一個表情,666? 太low了,應該是0110 0110 0100 在這裡插入圖片描述

上面就是SubsamplingScaleImageView的用法,用法很簡單,接下來我就從入口開始分析它的原始碼。

原始碼分析

  • setImage

在呼叫setImage的時候,會建立一個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;
    }
 }

這個類有好幾個屬性, uri bitmap resource這幾個就是圖片的來源, 還有幾個是圖片的尺寸,而我們呼叫的構造方法裡面主要是resource和tile這兩個屬性, tile = true說明支援區域性載入屬性。 接著我們往下看,setImage方法

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

這裡主要是根據imagesource的屬性進行一些初始化工作,結合上文的構造方法,這裡進入了一個初始化任務的呼叫,即

       TilesInitTask task = new TilesInitTask(this, getContext(), regionDecoderFactory, uri);
        execute(task);

話不多說,我們進入TilesInitTask 一窺究竟。

TilesInitTask 是一個AsyncTask, 主要的程式碼邏輯如下

     @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);
                }
            }
        }

在後臺執行的主要事情是呼叫瞭解碼器decoder的初始化方法,獲取圖片的寬高資訊,然後再回到主執行緒呼叫onTilesInited方法通知已經初始化完成。我們先看初始化方法做的事情,先找到解碼器,內建的解碼器工廠如下,

private DecoderFactory<? extends ImageRegionDecoder> regionDecoderFactory = new CompatDecoderFactory<ImageRegionDecoder>(SkiaImageRegionDecoder.class);

所以我們只需看看SkiaImageRegionDecoder這個decoder既可,檢視init方法

    @Override
    @NonNull
    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);
                decoder = BitmapRegionDecoder.newInstance(inputStream, false);
            } finally {
                if (inputStream != null) {
                    try { inputStream.close(); } catch (Exception e) { /* Ignore */ }
                }
            }
        }
        return new Point(decoder.getWidth(), decoder.getHeight());
    }

先是一堆的資源和uri解析判斷,這個我們不用管,關鍵程式碼是BitmapRegionDecoder.newInstance(inputStream, false); 然後最後返回了decoder解析的寬高資訊,BitmapRegionDecoder就是上文提到的部分載入bitmap的類,所以分析到這裡我們就知道了,初始化工作就是呼叫BitmapRegionDecoder獲取bitmap寬高。 解析寬高之後,我們再回過頭看看初始化完成的回撥:

// 程式碼經過整理 為了更方便看

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

回撥完成主要做了一些賦值操作,還有進行判斷是否初始化baseLayer,由於我們事先並沒有覆蓋尺寸大小,所以直接進入重繪操作。馬不停蹄,我們繼續看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) {

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

        }

    }

ScaleAndTranslate是儲存了繪製的時候的偏移量和縮放級別,呼叫fitToBounds其實就是先對基本的偏移位置等設定好,我們先重點關注ScaleAndTranslate的scale,先看看scale的計算,

    private float minScale() {
        int vPadding = getPaddingBottom() + getPaddingTop();
        int hPadding = getPaddingLeft() + getPaddingRight();
        if (minimumScaleType == SCALE_TYPE_CENTER_CROP || minimumScaleType == SCALE_TYPE_START) {
            return Math.max((getWidth() - hPadding) / (float) sWidth(), (getHeight() - vPadding) / (float) sHeight());
        } else if (minimumScaleType == SCALE_TYPE_CUSTOM && minScale > 0) {
            return minScale;
        } else {
            return Math.min((getWidth() - hPadding) / (float) sWidth(), (getHeight() - vPadding) / (float) sHeight());
        }
    }

sWidth,sHeight是剛剛獲取的圖片大小,getWidth,getHeight是控制元件的大小,所以scale的值其實就是,控制元件大小佔圖片大小的比例,這樣一來就可以把圖片縮放到合適的比例大小。 計算scale之後,接著是計算bitmap的取樣率, 對應程式碼的fullImageSampleSize,

    private int calculateInSampleSize(float scale) {
        if (minimumTileDpi > 0) {
            DisplayMetrics metrics = getResources().getDisplayMetrics();
            float averageDpi = (metrics.xdpi + metrics.ydpi)/2;
            scale = (minimumTileDpi/averageDpi) * scale;
        }

        int reqWidth = (int)(sWidth() * scale);
        int reqHeight = (int)(sHeight() * scale);

        // Raw height and width of image
        int inSampleSize = 1;
        if (reqWidth == 0 || reqHeight == 0) {
            return 32;
        }

        if (sHeight() > reqHeight || sWidth() > reqWidth) {

            // Calculate ratios of height and width to requested height and width
            final int heightRatio = Math.round((float) sHeight() / (float) reqHeight);
            final int widthRatio = Math.round((float) sWidth() / (float) reqWidth);

            // Choose the smallest ratio as inSampleSize value, this will guarantee
            // a final image with both dimensions larger than or equal to the
            // requested height and width.
            inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
        }

        // We want the actual sample size that will be used, so round down to nearest power of 2.
        int power = 1;
        while (power * 2 < inSampleSize) {
            power = power * 2;
        }

        return power;
    }

引數scale是上文計算得來的,這裡會根據設定的目標dpi進行調整,接著再根據圖片實際大小與請求繪製的大小比例就得到了相應的取樣率,即對bitmap的縮放。 特別地,如果計算得到的fullImageSampleSize 等於1,即圖片大小能夠顯示完全,就會呼叫BitmapLoadTask 這個任務,我們進去這個任務一窺究竟。

    @Override
        protected Integer doInBackground(Void... params) {
            try {
                String sourceUri = source.toString();
                Context context = contextRef.get();
                DecoderFactory<? extends ImageDecoder> decoderFactory = decoderFactoryRef.get();
                SubsamplingScaleImageView view = viewRef.get();
                if (context != null && decoderFactory != null && view != null) {
                    view.debug("BitmapLoadTask.doInBackground");
                    bitmap = decoderFactory.make().decode(context, source);
                    return view.getExifOrientation(context, sourceUri);
                }
            } catch (Exception e) {
                Log.e(TAG, "Failed to load bitmap", e);
                this.exception = e;
            } catch (OutOfMemoryError e) {
                Log.e(TAG, "Failed to load bitmap - OutOfMemoryError", e);
                this.exception = new RuntimeException(e);
            }
            return null;
        }

和上面提到的TileLoadTask大同小異,這裡是呼叫瞭解碼方法,再看看解碼方法

    @Override
    @NonNull
    public Bitmap decode(Context context, @NonNull Uri uri) throws Exception {
        String uriString = uri.toString();
        BitmapFactory.Options options = new BitmapFactory.Options();
        Bitmap bitmap;
        options.inPreferredConfig = bitmapConfig;
        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) {
                }
            }

            bitmap = BitmapFactory.decodeResource(context.getResources(), id, options);
        } else if (uriString.startsWith(ASSET_PREFIX)) {
            String assetName = uriString.substring(ASSET_PREFIX.length());
            bitmap = BitmapFactory.decodeStream(context.getAssets().open(assetName), null, options);
        } else if (uriString.startsWith(FILE_PREFIX)) {
            bitmap = BitmapFactory.decodeFile(uriString.substring(FILE_PREFIX.length()), options);
        } else {
            InputStream inputStream = null;
            try {
                ContentResolver contentResolver = context.getContentResolver();
                inputStream = contentResolver.openInputStream(uri);
                bitmap = BitmapFactory.decodeStream(inputStream, null, options);
            } finally {
                if (inputStream != null) {
                    try { inputStream.close(); } catch (Exception e) { /* Ignore */ }
                }
            }
        }
        if (bitmap == null) {
            throw new RuntimeException("Skia image region decoder returned null bitmap - image format may not be supported");
        }
        return bitmap;
    }

呼叫了BitmapFactory進行解碼,之後主執行緒回撥對bitmap進行賦值。 然後重新重新整理ui,因為此時bitmap不為null,那麼就把解碼得到的bitmap進行繪製。 此時,就完成了圖片的繪製過程,本篇部落格就先到此為止,下一篇部落格再對取樣率不等於1的情況進行分析。

總結

本篇部落格分析了subsampling-scale-image-view的初始化過程,對圖片的縮放控制,以及當圖片大小小於等於螢幕時,即取樣率等於1時,直接呼叫BitmapFactory進行解碼。

參考