モバイルアプリのメモリ制約下での画像処理──リスト表示で落とし穴を避ける設計

現場で起きている問題

スマートフォンで大量の画像を含むリスト(スクロールするギャラリーやフィード、商品一覧など)を表示するアプリを作ると、ほぼ必ずメモリ問題に直面します。数十枚程度なら見えませんが、百枚を超えると顕著になります。

典型的なシナリオはこうです。

  • 画像ファイルをそのまま読み込んで表示している
  • スクロールオフしたアイテムも、メモリ上に残ったままになっている
  • 端末のメモリが逼迫し、アプリが強制終了する
  • あるいは、他のアプリまで巻き添えになる

特に困るのは、開発環境(メモリ潤沢なPCやハイエンド端末)では問題が出ず、実際のユーザー環境(中価格帯のAndroid端末など)で初めて報告が上がることです。テスト時点で気づかず、運用開始後に対応を迫られるケースは少なくありません。

メモリ消費の実態を知る

画像処理のメモリ消費は、ファイルサイズと表示サイズで決まります。ここが理解できていないと、対策の優先順位を誤ります。

たとえば、ファイルサイズが500KBのJPEG画像でも、デコードしてメモリに展開すると、表示解像度に応じたビットマップになります。

画像メモリサイズ ≒ 幅ピクセル × 高さピクセル × 色深度(バイト)

480×360の画像をRGBA 32bitで展開すれば、約680KBのメモリが必要です。これが100枚あれば68MBです。キャッシュやシステム領域を含めると、すぐに端末の総メモリの大部分を占めます。

重要なのは、ファイルサイズの小ささと、メモリ効率の良さは別物だということです。圧縮率の高いWebP形式でも、デコード後のメモリサイズは同じです。

画像キャッシュの層別設計

対策の基本は、メモリ上に保持する画像の量を制限することです。実装としては「キャッシュの層別」が効果的です。

第1層:表示中の画像のみメモリに保持

スクロールビューやリサイクラービューを使う場合、見える範囲のアイテムだけデコードします。スクロール外のアイテムは、ファイル参照(パス)だけ保持し、デコードは遅延させます。

AndroidのRecyclerViewやiOSのUITableViewは、この仕組みが組み込まれています。ただし、カスタムビューを使う場合や、複数の画像を同時に読み込む場合は、自分で管理する必要があります。

第2層:ディスク(ローカルキャッシュ)に縮小版を保存

フルサイズの画像をネットワークから取得した後、表示用に縮小したサムネイルをローカルストレージに保存します。次回以降の表示は、このサムネイルから読み込みます。

この「表示用サイズ」の決定が重要です。画面解像度に合わせて、実際に表示される大きさ+余裕(スクロール時の先読み)で十分です。たとえば、リスト内で画像を100×100dpで表示するなら、デバイスピクセル比を考慮して150×150~200×200pxで保存するイメージです。

第3層:メモリキャッシュ(LRU)で頻繁にアクセスされる画像を保持

ディスク読み込みはメモリ読み込みより遅いため、最近使った画像をメモリキャッシュに置きます。ただし、容量は限定します。LRU(Least Recently Used)キャッシュなら、古いものから自動的に破棄されます。

// iOSの例:NSCacheを使ったシンプルなメモリキャッシュ
class ImageMemoryCache {
    private let cache = NSCache<NSString, UIImage>()
    
    init() {
        // メモリ上限を設定(MBで指定)
        cache.totalCostLimit = 50 * 1024 * 1024  // 50MB
    }
    
    func set(_ image: UIImage, forKey key: String) {
        let cost = image.size.width * image.size.height * 4  // RGBA概算
        cache.setObject(image, forKey: key as NSString, cost: Int(cost))
    }
    
    func get(_ key: String) -> UIImage? {
        return cache.object(forKey: key as NSString)
    }
}

実装時の落とし穴と対策

落とし穴1:スクロール中の画像読み込みが追いつかない

リスト表示で高速スクロールすると、画像の読み込みが間に合わず、プレースホルダーが表示され続けることがあります。ユーザーには「反応が遅い」と感じられます。

対策: 画像読み込みを優先度付きキューで管理し、見える範囲を優先的に処理します。また、スクロール停止時に確実に読み込み直すロジックを入れます。

落とし穴2:画像のメモリリーク

イメージビューを削除したり、セルを再利用したりする際に、古い画像参照が残ることがあります。特に、非同期読み込みの完了コールバックで、既に画面から外れたセルに画像をセットしてしまう場合です。

対策: セルの再利用時に、前の読み込みリクエストをキャンセルします。

// Androidの例:Glideを使った場合
Glide.with(context)
    .load(imageUrl)
    .into(object : CustomTarget<Drawable>() {
        override fun onResourceReady(
            resource: Drawable,
            transition: Transition<in Drawable>?
        ) {
            // セルがまだ表示中か確認
            if (holder.imageUrl == imageUrl) {
                holder.imageView.setImageDrawable(resource)
            }
        }
        override fun onLoadCleared(placeholder: Drawable?) {}
    })

落とし穴3:圧縮率と表示品質のバランス

画像をJPEGで圧縮する際、品質を落としすぎるとアーティファクトが目立ちます。かといって品質を高くするとファイルサイズが増えます。

対策: 用途に応じて品質を分ける。サムネイルは70~75%、詳細表示は85~90%程度が実用的です。また、WebPフォーマットの採用を検討します。同じ品質ならJPEGより20~30%ファイルサイズが小さくなります。

設計判断のポイント

ネットワーク取得 vs ローカルストレージ

画像をアプリにバンドルするか、サーバーから取得するか、キャッシュするかは、データ量と更新頻度で判断します。

  • バンドル: 数十枚程度の固定画像(アイコン、背景など)。更新がない場合のみ。
  • サーバー取得+ローカルキャッシュ: 数百枚以上、または定期的に更新される場合。ほとんどのリスト表示はこれ。
  • サーバー取得+キャッシュなし: 常に最新を表示する必要がある場合(ライブ配信など)。ただしネットワーク負荷が高い。

ライブラリ vs 自作

GlideやKingfisher、SDWebImageなどのライブラリを使うか、自作するか。

ライブラリを使うメリット:

  • キャッシュ、リサイズ、フォーマット変換が組み込まれている
  • バグや落とし穴が既に潰されている
  • チーム内で知見が共有しやすい

自作するメリット:

  • 細かい制御ができる
  • 外部依存を減らせる

現場では、ほぼ全てのケースでライブラリを使う方が無難です。自作は、特殊な要件がある場合に限定します。

運用開始後の監視

本番環境でメモリ問題が出ないか確認するポイント:

  • 低メモリ端末(2~3GB搭載機)での動作確認
  • 長時間スクロール(数分間、連続)時のメモリ推移
  • アプリ切り替え後の復帰時の挙動
  • Androidの場合、システム設定で「メモリ削減」有効時の動作

特に、開発チームが高スペック端末を使っている場合、本番報告で初めて問題が見つかることが多いです。テスト段階から低メモリ環境を意識した検証を組み込むことが重要です。