AndroidでLargeビットマップ読み込み時のjava.lang.OutOfMemoryErrorを修正する

intermediate📱 Android2026-05-21| Android(API 21以上)、Java/Kotlin、Android Studio、ヒープメモリが制限されたデバイス

Error Message

java.lang.OutOfMemoryError: Failed to allocate a 52428800 byte allocation with 8388608 free bytes and 7MB until OOM
#ビットマップ#メモリ#glide#picasso#oom

エラーの内容

ギャラリーピッカー、カメラ撮影、またはリモートURLから画像を読み込んでいると、アプリが以下のエラーでクラッシュします:

java.lang.OutOfMemoryError: Failed to allocate a 52428800 byte allocation with 8388608 free bytes and 7MB until OOM

52428800 バイトは50 MBです。Androidは1枚のビットマップに50 MBを割り当てようとしましたが、ヒープに空きが約8 MBしかなく、処理を中断しました。アプリはクラッシュします。

発生原因

Androidは画像をメモリ上の生のピクセルデータとしてデコードします。12 MPの写真(4032×3024)をARGB_8888形式でデコードすると:

4032 × 3024 × 4 bytes = ~46.5 MB

これは1枚の写真の話です。多くのミッドレンジ端末のデフォルトヒープは合計48〜128 MBで、アプリの他の処理と共有されています。圧縮されていないビットマップを2〜3枚読み込むだけで、予算を超過してしまいます。

よくある原因:

  • フル解像度のカメラ写真を直接 ImageView に読み込む
  • RecyclerView 内でビットマップをリサイクルせずにデコードする
  • サンプリングオプションなしで BitmapFactory.decodeFile() を使用する
  • 静的フィールドやシングルトンにビットマップへの参照を保持する
  • Glide/Picassoを使用しているが、キャッシュをバイパスしている(例:どこでも .skipMemoryCache(true) を呼び出している)

ステップ1 — Logcatで原因を特定する

コードに触れる前に、クラッシュしている正確な呼び出し箇所を特定します。アプリを実行してクラッシュを再現し、Logcatでフィルタリングします:

adb logcat | grep -E "OutOfMemoryError|BitmapFactory|alloc"

または、Android Studioで OutOfMemoryError でフィルタリングします。スタックトレースが正確な行を示します — 通常は BitmapFactory.decode* の呼び出しか画像ライブラリのローダーです。

ステップ2 — 生のBitmapFactoryを使用している場合

対象のビューに合わせてサンプリングダウンせずに、フル画像をデコードしないでください。BitmapFactory.Options を使用します:

fun decodeSampledBitmap(filePath: String, reqWidth: Int, reqHeight: Int): Bitmap {
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true  // サイズのみデコード、ピクセルなし
    }
    BitmapFactory.decodeFile(filePath, options)

    // inSampleSizeを計算する
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false

    return BitmapFactory.decodeFile(filePath, options)
}

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height, width) = options.run { outHeight to outWidth }
    var inSampleSize = 1
    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

ハードコードした推測値ではなく、ImageView の実際の表示サイズを渡してください:

imageView.post {
    val bmp = decodeSampledBitmap(filePath, imageView.width, imageView.height)
    imageView.setImageBitmap(bmp)
}

これにより、サムネイルで画質を損なうことなく、46 MBの割り当てを3〜4 MBまで削減できます。

ステップ3 — 透過不要な画像にはRGB_565を使用する

写真や背景画像にアルファ透過が必要なことはほとんどありません。デフォルトの ARGB_8888 の代わりに RGB_565 を使用してください — 1ピクセルあたり4バイトではなく2バイトを使用するため、メモリが半分になります:

val options = BitmapFactory.Options().apply {
    inPreferredConfig = Bitmap.Config.RGB_565
    inSampleSize = 4  // または計算した値
}
val bitmap = BitmapFactory.decodeFile(filePath, options)

ステップ4 — Glideを使用している場合

Glideはほとんどの処理を自動的に行います。ターゲットサイズをフル解像度にオーバーライドしたり、明示的なサイズ指定なしに wrap_content のビューに読み込んだりすると、OOMが発生することがあります。

サイズを明示的に制限し、軽量なピクセルフォーマットを使用してください:

Glide.with(context)
    .load(imageUrl)
    .override(800, 600)          // サイズを制限
    .format(DecodeFormat.PREFER_RGB_565)  // メモリを半分に
    .diskCacheStrategy(DiskCacheStrategy.ALL)
    .into(imageView)

固定ピクセルサイズなしで画面幅の解像度が必要なフルスクリーンバナーの場合:

Glide.with(context)
    .load(imageUrl)
    // .override(Target.SIZE_ORIGINAL)  // フル解像度が本当に必要な場合を除き避ける
    .override(Resources.getSystem().displayMetrics.widthPixels, 0)  // 画面幅、高さは自動
    .into(imageView)

また、静的フィールドに Target の参照を保持していないか確認してください — これはGlideがビットマップを解放できなくなる確実な方法です。

ステップ5 — Picassoを使用している場合

PicassoのAPIはサイジングについてより明示的です。大きな画像を制限しつつ、小さな画像を誤ってアップスケールしないように、resize()onlyScaleDown() を一緒に設定します:

Picasso.get()
    .load(imageUrl)
    .resize(800, 600)
    .onlyScaleDown()
    .centerCrop()
    .into(imageView)

ローカルファイルの場合は、生のファイルパスではなくURIを渡してください。Picassoは file:// URIをクリーンに処理し、生のパスで起こりうるようなデータの二重バッファリングを防ぎます。

ステップ6 — 不要になったビットマップをリサイクルする

GlideやPicassoなしで手動でビットマップを管理する場合、クリーンアップはあなたの責任です。ビューがデタッチされたときに recycle() を呼び出します:

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    val drawable = imageView.drawable
    if (drawable is BitmapDrawable) {
        drawable.bitmap?.recycle()
    }
    imageView.setImageDrawable(null)
}

絶対的なルール:まだ表示中のビットマップに対して recycle() を呼び出さないでください。これにより Canvas: trying to use a recycled bitmap クラッシュが発生し、元のOOMよりもデバッグが困難になります。

ステップ7 — プロファイラーでメモリリークを確認する

リークによるOOMは、単一の大きな割り当てとは異なる見え方をします。ヒープ使用量が各ナビゲーションで着実に増加し、元に戻らなくなります。Android StudioのMemory Profilerを使用してこれを検出します:

  • デバッグモードでアプリを実行 → View → Tool Windows → Profiler
  • Memory トラックをクリック
  • 画像を読み込む画面に移動し、戻るを繰り返す(5回)
  • Force GC(ゴミ箱アイコン)をクリック
  • ヒープ使用量がベースラインに戻らない場合、リークがあります

Capture heap dump をクリックし、解放されているべき Bitmap インスタンスを探します。たった1枚のスタックしたビットマップでも、46 MBの永続的なオーバーヘッドになる可能性があります。

ステップ8 — 最終手段としてのみlargeHeapを増やす

AndroidManifest.xmlandroid:largeHeap="true" を追加すると、ほとんどのデバイスで利用可能なヒープがおよそ2倍になります:

<application
    android:largeHeap="true"
    ...>

これは時間を稼ぐだけで、根本的な解決にはなりません。アプリが少し多くのデータを読み込むとすぐにクラッシュが再発し、一部のOEMデバイスではこのフラグを完全に無視します。本当の修正に取り組んでいる間、または写真エディタのような本当にメモリ需要の高いアプリにのみ使用してください。

修正の確認

  • クラッシュしていた正確なフローを再現する — クラッシュしなければ修正が効いています
  • 画像読み込み中にMemory Profilerを確認し、割り当てが安定していることを確認する
  • 残っている Bitmap allocation 警告をLogcatで確認する
  • ローエンドデバイス(RAM 1〜2 GB)でテストするか、エミュレータをRAM 512 MBに設定する — フラッグシップ機では隠れていた問題がここですぐに現れます

得られた教訓

  • inJustDecodeBounds + inSampleSize なしに BitmapFactory.decode* を呼び出さない
  • GlideとPicassoは難しい部分を処理してくれます — 手動で再実装するよりも、ライフサイクルの管理をライブラリに任せましょう
  • 12 MPの写真は、ディスク上のJPEGがどれほど小さくても、メモリ上では常に約46 MBです
  • RGB_565 は透過なしの画像に対して無償のメモリ節約になります
  • リークは単一の大きな割り当てよりも発見が難しい — 一回限りの読み込み問題と決めつける前にまずプロファイリングしましょう

Related Error Notes