大容量画像のメモリ効率良い表示手法

100MBを超える高解像度画像をAndroidで表示する際、OutOfMemoryError(OOM)を回避するには、全体を一度に読み込むのではなく、必要な領域だけを部分的にデコードして描画する「タイル表示」方式が有効です。このアプローチでは、BitmapRegionDecoder を活用し、メモリ使用量を一定範囲内に抑えることができます。

画像情報の取得と初期化

まず、入力ストリームから画像のサイズ情報を取得します。この段階では実際のピクセルデータは読み込まず、inJustDecodeBounds = true を設定することで軽量なメタデータ読み取りを行います。

public void setImage(InputStream is) {
    BitmapFactory.Options opts = new BitmapFactory.Options();
    opts.inJustDecodeBounds = true;
    BitmapFactory.decodeStream(is, null, opts);
    
    int imgWidth = opts.outWidth;
    int imgHeight = opts.outHeight;
    
    opts.inPreferredConfig = Bitmap.Config.RGB_565; // 透明チャンネル不要ならメモリ半減
    opts.inJustDecodeBounds = false;
    
    try {
        mDecoder = BitmapRegionDecoder.newInstance(is, false);
    } catch (IOException e) {
        e.printStackTrace();
    }
    
    mImageWidth = imgWidth;
    mImageHeight = imgHeight;
    requestLayout();
}

RGB_565 は1ピクセルあたり16ビット(2バイト)で表現され、デフォルトの ARGB_8888(32ビット)と比べてメモリ使用量を約50%削減できます。

ビューのサイズに基づくスケール計算

onSizeChanged でビューの実際のサイズを取得し、初期表示時のスケールを算出します。

@Override
protected void onSizeChanged(int w, int h, int oldW, int oldH) {
    super.onSizeChanged(w, h, oldW, oldH);
    mViewWidth = w;
    mViewHeight = h;
    
    mDisplayRect.set(0, 0, (int) (mViewWidth / mBaseScale), (int) (mViewHeight / mBaseScale));
    mBaseScale = (float) mViewWidth / mImageWidth;
    mCurrentScale = mBaseScale;
}

ここで mDisplayRect は、現在表示すべき画像内の矩形領域を表します。初期状態では画像全体がビューに収まるように設定されます。

部分領域のデコードと描画

onDraw では、BitmapRegionDecoder を使って mDisplayRect に対応する画像の一部をデコードし、キャンバスに描画します。メモリ再利用のため、前回のBitmapを inBitmap に設定します。

@Override
protected void onDraw(Canvas canvas) {
    if (mDecoder == null) return;
    
    mDecodeOptions.inBitmap = mCurrentBitmap;
    mCurrentBitmap = mDecoder.decodeRegion(mDisplayRect, mDecodeOptions);
    
    Matrix matrix = new Matrix();
    matrix.postScale(mCurrentScale, mCurrentScale);
    canvas.drawBitmap(mCurrentBitmap, matrix, null);
}

タッチイベントによる操作対応

スクロール、フリック、ピンチイン/アウト、ダブルタップなどのジェスチャーを処理するために、GestureDetectorScaleGestureDetector を併用します。

@Override
public boolean onTouchEvent(MotionEvent ev) {
    mScaleDetector.onTouchEvent(ev);
    mGestureDetector.onTouchEvent(ev);
    return true;
}

スクロール処理

スクロール中は表示領域(mDisplayRect)を移動させ、画像の境界を超えないよう制限します。

public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
    mDisplayRect.offset((int) dx, (int) dy);
    
    // 左右境界チェック
    if (mDisplayRect.left < 0) {
        mDisplayRect.right = mDisplayRect.left + mDisplayRect.width();
        mDisplayRect.left = 0;
    }
    if (mDisplayRect.right > mImageWidth) {
        mDisplayRect.left = mImageWidth - mDisplayRect.width();
        mDisplayRect.right = mImageWidth;
    }
    
    // 上下境界チェック
    if (mDisplayRect.top < 0) {
        mDisplayRect.bottom = mDisplayRect.top + mDisplayRect.height();
        mDisplayRect.top = 0;
    }
    if (mDisplayRect.bottom > mImageHeight) {
        mDisplayRect.top = mImageHeight - mDisplayRect.height();
        mDisplayRect.bottom = mImageHeight;
    }
    
    invalidate();
    return true;
}

フリック(慣性スクロール)

Scroller を使って指を離した後の滑りを実現します。

public boolean onFling(MotionEvent e1, MotionEvent e2, float vx, float vy) {
    mScroller.fling(
        mDisplayRect.left, mDisplayRect.top,
        (int) -vx, (int) -vy,
        0, mImageWidth - mDisplayRect.width(),
        0, mImageHeight - mDisplayRect.height()
    );
    invalidate();
    return true;
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        mDisplayRect.offsetTo(mScroller.getCurrX(), mScroller.getCurrY());
        invalidate();
    }
}

ダブルタップによるズーム切り替え

ダブルタップで拡大・縮小を切り替え、表示領域を再計算します。

public boolean onDoubleTap(MotionEvent e) {
    float targetScale = (mCurrentScale > mBaseScale) ? mBaseScale : mBaseScale * 2.0f;
    mCurrentScale = targetScale;
    
    // 表示領域を再計算
    int newWidth = (int) (mViewWidth / mCurrentScale);
    int newHeight = (int) (mViewHeight / mCurrentScale);
    
    mDisplayRect.right = mDisplayRect.left + newWidth;
    mDisplayRect.bottom = mDisplayRect.top + newHeight;
    
    // 境界補正(省略:上記スクロール処理と同様)
    adjustRectBounds();
    
    invalidate();
    return true;
}

このように、必要な部分だけを動的に読み込むことで、巨大画像でも安定したメモリ使用量で表示・操作が可能になります。

タグ: Android BitmapRegionDecoder OOM タイル表示 メモリ最適化

6月13日 18:34 投稿