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);
}
タッチイベントによる操作対応
スクロール、フリック、ピンチイン/アウト、ダブルタップなどのジェスチャーを処理するために、GestureDetector と ScaleGestureDetector を併用します。
@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;
}
このように、必要な部分だけを動的に読み込むことで、巨大画像でも安定したメモリ使用量で表示・操作が可能になります。