Androidのメモリを最適化する方法|使用量の測定からメモリリーク対策まで徹底解説
Androidアプリを長時間使用したときに動作が重くなる、画面を何度か開閉すると強制終了する、画像一覧を表示すると端末が固まるといった問題は、メモリの使い方が適切でない可能性があります。高性能な端末では問題が見えにくくても、搭載メモリが少ない端末や複数のアプリが動作している環境では、小さな無駄が大きな不具合につながります。
Androidでは、実行環境が不要になったオブジェクトを自動的に回収します。しかし、自動回収があるからといって、開発者がメモリ管理を意識しなくてよいわけではありません。画面への参照、巨大な画像、終了していない処理、増え続けるキャッシュなどが残っていると、本来解放できるはずの領域が長時間保持されます。
本記事では、Androidアプリのメモリ使用量を正確に測定し、問題の原因を特定したうえで改善する方法を解説します。Android Studioによる計測、メモリリーク対策、画像の軽量化、キャッシュ設計、バックグラウンド処理、Jetpack Compose、低メモリ端末への対応まで、実際の開発で利用できるコード例とともに紹介します。
1. Androidのメモリ最適化とは
Androidのメモリ最適化とは、単純に使用量を最小化することではありません。必要なデータを必要な期間だけ保持し、不要になった参照や処理を適切なタイミングで解放することが重要です。Androidの実行環境は、ページングやメモリ割り当てなど複数の仕組みを利用してアプリのメモリを管理しています。
1.1 メモリ最適化が必要な理由
アプリが利用できるメモリには上限があり、その上限は端末の性能、同時に動作しているアプリ、アプリの状態によって変わります。画像、画面、通信結果、データベースの取得結果などを無制限に保持すると、空き領域が徐々に減少し、処理速度の低下や強制終了が発生しやすくなります。
メモリ使用量が多いアプリは、自分自身だけでなく端末全体の操作感にも影響を与えます。不要なオブジェクトを大量に生成すると回収処理が頻繁に実行されるため、画面の描画やスクロールが一時的に停止する原因にもなります。
1.2 メモリ不足で発生する問題
メモリが不足すると、アプリ内部で新しい領域を確保できなくなり、メモリ不足を示す例外が発生することがあります。特に、大きな画像を一度に読み込む処理や、大量のデータを一括で取得する処理では、短時間に使用量が急増するため注意が必要です。
さらに、Androidシステムは端末全体の空き領域が不足すると、重要度の低いプロセスから終了させます。画面に表示されていないアプリや利用者が直接操作していない処理は終了対象になりやすく、状態を保存していない場合は、アプリへ戻ったときに入力内容が失われる可能性があります。
1.3 使用量を減らすことと性能の関係
メモリ使用量を減らせば、必ず処理が速くなるとは限りません。必要なデータまで毎回破棄すると、再計算、再通信、再読み込みが増え、処理時間や電池消費量が悪化する可能性があります。
最適化では、保持することで得られる速度向上と、保持によって増える使用量の釣り合いを考える必要があります。頻繁に利用する小さなデータは一時保存し、巨大で再取得しやすいデータは必要なときに読み込むといった判断が求められます。
1.4 メモリリークとの違い
メモリ使用量が多い状態とメモリリークは同じではありません。現在の画面を表示するために大きな画像を保持している場合、その使用量は多くても必要な割り当てです。一方、閉じた画面への参照が残り続けている場合は、不要になった領域を回収できないためメモリリークと判断できます。
改善するときは、使用量の大きさだけを見るのではなく、画面を閉じた後に使用量が戻るかを確認します。操作を繰り返すたびに使用量が階段状に増え続ける場合は、画面、監視処理、コールバック、静的な参照などが残っていないか調査する必要があります。
1.5 最適化を始める適切な順序
最初に行うべきことは、推測による修正ではなく計測です。どの操作で使用量が増えるのか、どの種類のオブジェクトが残っているのか、回収処理の後にどれだけ減るのかを記録します。
計測後は、メモリリーク、巨大な画像、増え続けるキャッシュ、不要な一時オブジェクトの順に確認すると効率的です。影響の大きい原因から修正し、変更前後で同じ操作を再現して改善量を比較します。
2. メモリ使用量を正確に測定する
メモリ最適化では、実際の端末または条件をそろえた検証端末で計測することが重要です。Android Studioには、処理装置、スレッド、メモリなどの使用状況を確認する機能があり、問題のある操作を実行しながら変化を調査できます。
2.1 Android Studioで使用量を確認する
Android Studioのプロファイラーを使用すると、アプリが使用しているメモリの推移を時間軸で確認できます。画面の起動、画像一覧の表示、画面遷移、戻る操作などを実行し、どの時点で使用量が増えているかを調べます。
一度の操作だけで判断せず、同じ画面を十回程度開閉して変化を見ることが大切です。画面を閉じるたびに一部が解放され、一定範囲で推移する場合は正常な可能性があります。反対に、操作ごとに最低値が上昇する場合は、参照が残っている可能性があります。
2.2 アプリ内でメモリ状態を記録する
開発中は、特定の処理の前後でメモリ状態を記録すると、問題の発生箇所を絞り込みやすくなります。実行環境が確保している領域や、端末全体の空き状況を記録し、画像読み込みや大量データ処理の前後を比較します。
ただし、記録した数値だけで原因を断定してはいけません。回収処理が実行される時期は一定ではないため、複数回の測定結果とプロファイラーの推移を組み合わせて判断します。
Kotlinコード例
import android.app.ActivityManager
import android.content.Context
import android.util.Log
fun logMemoryState(context: Context, label: String) {
val runtime = Runtime.getRuntime()
val usedBytes = runtime.totalMemory() - runtime.freeMemory()
val maxBytes = runtime.maxMemory()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val usedMb = usedBytes / 1024L / 1024L
val maxMb = maxBytes / 1024L / 1024L
val availableMb = memoryInfo.availMem / 1024L / 1024L
Log.d(
"MemoryCheck",
"$label: 使用中=${usedMb}MB, 上限=${maxMb}MB, 端末空き=${availableMb}MB"
)
}
2.3 再現手順を固定する
測定ごとに操作内容が異なると、変更前後を正確に比較できません。「アプリを起動する」「一覧を三回スクロールする」「詳細画面を十回開閉する」など、検証手順を具体的に決めます。
通信結果や画像の状態によって結果が変わる場合は、検証用の固定データを利用します。端末、画面サイズ、通信状態、アプリの構成、測定時間もできる限りそろえることで、修正の効果を判断しやすくなります。
2.4 ヒープダンプを分析する
ヒープダンプは、特定の時点で存在しているオブジェクトと参照関係を調べるための資料です。Android Studioではヒープダンプを取得し、重複して保持されている画像や、想定以上に残っているオブジェクトを確認できます。
画面を閉じた後にヒープダンプを取得し、その画面の断片、表示部品、表示状態を管理するクラスが残っていないか確認します。残っている場合は、どのオブジェクトから参照されているかをたどり、解除されていない監視処理や静的な参照を探します。
2.5 実端末と仮想端末を使い分ける
仮想端末は同じ条件を再現しやすく、修正前後の比較に適しています。一方、実際の端末では、製造元による設定、搭載メモリ、画面解像度、他アプリの動作などが影響するため、最終確認には実端末が必要です。
高性能端末だけで検証すると、低メモリ端末で発生する問題を見逃しやすくなります。搭載メモリが少ない端末、画面解像度が高い端末、古い端末など、性質の異なる複数の環境で確認します。
3. メモリリークを防ぐ
メモリリークは、不要になったオブジェクトが別の長寿命オブジェクトから参照され続け、回収されない状態です。画面を何度も開閉したときに使用量が増え続ける場合は、画面のライフサイクルと参照の寿命が一致しているかを重点的に確認します。
3.1 画面への参照を残さない
画面を表すオブジェクトは、利用者が戻る操作をした後に解放される必要があります。しかし、単一の共有領域、長時間動作する処理、監視対象などから画面を参照すると、画面全体が残り続けます。
画面に依存しない処理では、画面そのものではなく、アプリ全体で使用できるコンテキストを利用します。画面固有の操作が必要な場合は、処理終了時に参照を解除し、保持期間を画面の寿命より長くしないようにします。
3.2 アプリ全体のコンテキストを使い分ける
設定の読み込み、データベースの初期化、依存関係の生成など、画面に依存しない処理で画面のコンテキストを保持すると、画面が閉じられても解放できません。長寿命のクラスには、アプリ全体のコンテキストを渡す設計が適しています。
ただし、すべての処理をアプリ全体のコンテキストへ置き換えればよいわけではありません。画面の見た目、画面専用のテーマ、ダイアログ表示などは画面のコンテキストを必要とするため、用途に応じて使い分けます。
Kotlinコード例
class SettingsRepository(context: Context) {
private val appContext = context.applicationContext
private val preferences by lazy {
appContext.getSharedPreferences(
"application_settings",
Context.MODE_PRIVATE
)
}
fun saveDisplayMode(mode: String) {
preferences.edit()
.putString("display_mode", mode)
.apply()
}
}
3.3 コールバックを解除する
位置情報、センサー、ネットワーク状態、画面更新などのコールバックを登録したままにすると、登録先が画面や管理クラスを参照し続けます。画面が非表示になった時点、または破棄された時点で登録を解除します。
登録処理と解除処理を別々の場所へ無計画に記述すると、解除漏れが起こりやすくなります。登録するライフサイクルと解除するライフサイクルを対にし、複数回登録されない仕組みも用意します。
Kotlinコード例
class SampleActivity : AppCompatActivity() {
private val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
// 必要な処理
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int
) = Unit
}
private lateinit var sensorManager: SensorManager
override fun onStart() {
super.onStart()
val sensor = sensorManager.getDefaultSensor(
Sensor.TYPE_ACCELEROMETER
)
sensorManager.registerListener(
listener,
sensor,
SensorManager.SENSOR_DELAY_NORMAL
)
}
override fun onStop() {
sensorManager.unregisterListener(listener)
super.onStop()
}
}
3.4 匿名クラスと遅延処理に注意する
匿名クラスやラムダ式は、外側の画面を暗黙的に参照する場合があります。長時間待機する遅延処理や、終了時期が不明な非同期処理で画面を参照すると、処理が完了するまで画面を解放できません。
遅延処理は、画面の破棄時に取り消せる構成にします。また、処理結果を画面へ直接返すのではなく、ライフサイクルを認識できる状態管理を通して受け取ると、破棄済み画面への更新も防止できます。
Kotlinコード例
class SampleFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper())
private val delayedTask = Runnable {
if (viewLifecycleOwner.lifecycle.currentState
.isAtLeast(Lifecycle.State.STARTED)
) {
updateScreen()
}
}
override fun onStart() {
super.onStart()
handler.postDelayed(delayedTask, 3_000L)
}
override fun onStop() {
handler.removeCallbacks(delayedTask)
super.onStop()
}
private fun updateScreen() {
// 画面更新
}
}
3.5 単一共有領域に大きなデータを置かない
アプリ全体から参照できる共有領域に、画像、画面、一覧データ、通信結果を保存すると、アプリのプロセスが終了するまで保持される可能性があります。便利さを優先して何でも共有すると、解放時期を管理できなくなります。
共有領域には、小さく変更頻度の低い設定や、アプリ全体で本当に一つだけ必要なオブジェクトを置きます。画面専用データは画面の状態管理へ置き、再取得可能なデータは保存領域やリポジトリから必要なときに読み込みます。
4. 画像とビットマップを軽量化する
画像はAndroidアプリで特に大きなメモリを消費しやすい要素です。保存ファイルの容量が小さくても、表示時には幅、高さ、色の情報に応じた領域が必要になるため、高解像度画像をそのまま読み込むと使用量が急増します。
4.1 表示サイズに合わせて読み込む
小さな一覧画像を表示するために、元の高解像度画像を完全な大きさで読み込む必要はありません。表示部品の幅と高さを確認し、それに近い解像度へ縮小してから読み込みます。
一覧画面では、数十枚の画像が同時に保持されることがあります。一枚当たりの使用量を抑えるだけでも、画面全体では大きな削減につながり、スクロール中の停止やメモリ不足を防ぎやすくなります。
Kotlinコード例
fun calculateSampleSize(
options: BitmapFactory.Options,
requiredWidth: Int,
requiredHeight: Int
): Int {
val originalWidth = options.outWidth
val originalHeight = options.outHeight
var sampleSize = 1
if (
originalWidth > requiredWidth ||
originalHeight > requiredHeight
) {
var halfWidth = originalWidth / 2
var halfHeight = originalHeight / 2
while (
halfWidth / sampleSize >= requiredWidth &&
halfHeight / sampleSize >= requiredHeight
) {
sampleSize *= 2
}
}
return sampleSize
}
fun decodeScaledBitmap(
filePath: String,
requiredWidth: Int,
requiredHeight: Int
): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(filePath, options)
options.inSampleSize = calculateSampleSize(
options,
requiredWidth,
requiredHeight
)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(filePath, options)
}
4.2 画像読み込みライブラリを活用する
画像読み込みライブラリには、縮小、再利用、一時保存、画面のライフサイクルに合わせた要求の停止などが用意されています。独自実装で同じ機能を作るより、実績のある仕組みを利用したほうが解放漏れを防ぎやすくなります。
利用時には、元画像の大きさではなく表示予定の大きさを指定します。また、一覧用の小さな画像と詳細用の大きな画像を分け、常に最大解像度を取得する設計を避けます。
Kotlinコード例
imageView.load(imageUrl) {
size(480, 480)
crossfade(true)
memoryCachePolicy(CachePolicy.ENABLED)
diskCachePolicy(CachePolicy.ENABLED)
placeholder(R.drawable.image_placeholder)
error(R.drawable.image_error)
}
4.3 画面外の画像を保持し続けない
一覧画面では、画面外へ移動した項目の表示部品が再利用されます。古い画像要求や参照を解除しないと、別の項目へ誤った画像が表示されたり、不要な画像が長時間保持されたりします。
表示部品が再利用されるタイミングで、以前の画像要求を取り消し、仮画像へ戻します。画像ライブラリが自動処理する場合でも、独自の変換や追加参照が残っていないか確認します。
Kotlinコード例
override fun onViewRecycled(holder: PhotoViewHolder) {
holder.imageView.dispose()
holder.imageView.setImageResource(
R.drawable.image_placeholder
)
super.onViewRecycled(holder)
}
4.4 同じ画像を重複して保持しない
同じ画像を異なる画面やクラスで個別に読み込むと、同一内容の画像が複数存在する可能性があります。画像の取得経路を統一し、共通の一時保存機構から再利用できるようにします。
ただし、すべての画像を永久に共有する設計は避ける必要があります。再利用の可能性、画像の大きさ、表示頻度を基準に保持数を決め、上限を超えた古い画像は削除します。
4.5 画像形式と品質を使い分ける
写真、透過画像、図形、アイコンでは、適切な保存形式が異なります。必要以上に高い品質や巨大な寸法で配布すると、通信量、保存容量、読み込み時間、メモリ使用量のすべてが増えます。
配布前に不要な余白を削除し、表示寸法に応じた複数サイズを用意します。アイコンや単純な図形は、拡大しても劣化しにくいベクター画像を利用することで、大きな画像を複数用意する必要がなくなります。
5. コレクションとデータ構造を最適化する
一覧、地図、集合などのデータ構造は便利ですが、要素数が多いほど本体以外の管理領域も増えます。数件のデータでは差が見えなくても、数万件を保持する処理では構造の選び方が使用量に影響します。
5.1 必要な項目だけを保持する
通信やデータベースから取得したオブジェクトをそのまま画面へ渡すと、画面で使わない項目まで保持することになります。巨大な本文、詳細情報、内部管理用データなどが不要であれば、画面専用の小さなデータへ変換します。
画面用データを分離すると、使用量を削減できるだけでなく、表示処理の責任範囲も明確になります。詳細画面でのみ必要な情報は、項目が選択されたときに読み込む設計が適しています。
Kotlinコード例
data class ProductEntity(
val id: Long,
val name: String,
val description: String,
val imageUrl: String,
val internalMemo: String,
val rawResponse: String
)
data class ProductListItem(
val id: Long,
val name: String,
val thumbnailUrl: String
)
fun ProductEntity.toListItem(): ProductListItem {
return ProductListItem(
id = id,
name = name,
thumbnailUrl = imageUrl
)
}
5.2 全件取得を避ける
大量データを一度に読み込むと、取得直後の変換処理や並べ替えで複数の一覧が一時的に生成されます。最終的に必要なデータが一部であっても、途中では全件分の領域を使用する可能性があります。
ページ単位で取得し、画面に必要な範囲だけを読み込む構成にすると、一時的な使用量を抑えられます。検索結果や履歴のように件数が増え続ける機能では、最初から分割取得を前提に設計します。
5.3 不要なコピーを作らない
一覧を加工するたびに、新しい一覧へコピーすると、一時的に元データと加工後データの両方が存在します。絞り込み、並べ替え、変換を連続して実行する場合は、途中結果が重なって使用量が増えることがあります。
処理順序を見直し、最初に件数を減らしてから変換すると効果的です。また、一回しか走査しない処理では遅延評価を利用し、最終結果が必要になるまで中間の一覧を生成しない方法もあります。
Kotlinコード例
val displayItems = products
.asSequence()
.filter { it.isVisible }
.filter { it.stockCount > 0 }
.map { product ->
ProductListItem(
id = product.id,
name = product.name,
thumbnailUrl = product.imageUrl
)
}
.take(100)
.toList()
5.4 重複データを正規化する
複数の項目が同じ利用者情報、カテゴリ情報、店舗情報を丸ごと保持すると、同じ文字列やオブジェクトが大量に重複します。共通情報を識別子で関連付け、必要なときに参照する構造へ変更します。
ただし、参照を細かく分けすぎると、表示のたびに複雑な結合処理が必要になります。更新頻度、件数、表示速度を考慮し、頻繁に使う小さな情報だけを複製するなど、実用的な範囲で正規化します。
5.5 一時一覧の寿命を短くする
関数内で作成した一時一覧が、状態管理クラスのプロパティや共有領域へ代入されると、想定より長く残ることがあります。一時的な計算結果は、可能な限り関数内で処理を完結させます。
長時間保持する必要がある場合は、保持理由と破棄条件を明確にします。画面が変わったとき、検索条件が変わったとき、利用者がログアウトしたときなど、データを使わなくなる契機で参照を削除します。
6. オブジェクト生成と回収処理を抑える
短時間に大量のオブジェクトを作成すると、メモリ使用量が一時的に増えるだけでなく、不要オブジェクトを回収する処理も増えます。特に、毎秒何十回も実行される描画やスクロール処理では、小さな割り当てでも積み重なります。
6.1 繰り返し処理内の生成を減らす
描画、スクロール、位置更新、アニメーションなどの繰り返し処理内で、一覧、座標、文字列、書式設定オブジェクトを毎回生成しないようにします。変更されないものは事前に生成し、同じインスタンスを再利用します。
ただし、再利用のために共有範囲を広げすぎると、状態の混在やメモリリークが発生します。画面または表示部品の寿命に合わせて保持し、その範囲を超えて共有しないことが重要です。
6.2 文字列の連結を効率化する
少数の文字列を連結するだけなら大きな問題になりませんが、大量の要素を繰り返し連結すると、そのたびに中間文字列が作られる可能性があります。ログ、書き出し処理、長文生成では専用の構築方法を利用します。
利用者へ表示しない診断用ログでも、文字列を先に組み立てている場合はメモリを消費します。本番環境で不要な詳細ログは無効化し、必要な場合のみ文字列を生成するようにします。
Kotlinコード例
fun buildProductSummary(products: List<Product>): String {
return buildString {
products.forEachIndexed { index, product ->
append(index + 1)
append(". ")
append(product.name)
append(":")
append(product.price)
append("円")
append('\n')
}
}
}
6.3 データクラスのコピーを乱用しない
不変データを扱うときのコピーは安全性を高めますが、大きな一覧や深い階層を頻繁にコピーすると、一時的な割り当てが増えます。入力中に一文字変わるたび、画面全体の巨大な状態を作り直す設計は避けます。
状態を役割ごとに分割し、変更された部分だけを更新できる構造にします。画面全体の状態を一つにまとめる場合でも、巨大な未加工データや画像を状態オブジェクトへ直接含めないようにします。
6.4 値の変換回数を減らす
同じ値を表示するたびに日付、通貨、数値形式へ変換すると、変換用オブジェクトや文字列が繰り返し生成されます。入力値が変わらない間は、変換結果を再利用できるようにします。
一方、すべての変換結果を無制限に保存すると、別のメモリ問題が発生します。表示中の項目、直近の項目、利用頻度の高い値など、保存対象に上限を設けます。
6.5 早すぎる最適化を避ける
小さなオブジェクトを一つ削減するために複雑な仕組みを導入しても、全体への影響がほとんどない場合があります。可読性や安全性を犠牲にした最適化は、保守時の不具合を増やす原因になります。
プロファイラーで割り当て回数が多い処理や、実際に停止時間が発生している処理を優先します。改善後は必ず同じ条件で再測定し、複雑化に見合う効果が得られたか確認します。
7. 画面とライフサイクルに合わせて解放する
Androidの画面は、表示中、非表示、破棄、再生成といった状態を持ちます。画面オブジェクトと表示部品の寿命が一致しない場合があるため、それぞれの破棄タイミングに合わせて参照を解放しなければなりません。
7.1 フラグメントの表示参照を解放する
フラグメント本体は残っていても、表示部分だけが先に破棄されることがあります。表示参照をフラグメントの破棄まで保持すると、古い表示階層全体が残る可能性があります。
表示参照は、表示部分が破棄されるタイミングで無効にします。破棄後に誤って参照した場合にすぐ気づけるよう、取得方法を制限することも有効です。
Kotlinコード例
class ProductListFragment : Fragment(
R.layout.fragment_product_list
) {
private var _binding: FragmentProductListBinding? = null
private val binding: FragmentProductListBinding
get() = requireNotNull(_binding)
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
_binding = FragmentProductListBinding.bind(view)
binding.productList.adapter = productAdapter
}
override fun onDestroyView() {
binding.productList.adapter = null
_binding = null
super.onDestroyView()
}
}
7.2 一覧のアダプター参照を解除する
一覧表示部品がアダプターを保持し、アダプターが画面やコールバックを保持している場合、表示部分を破棄しても参照関係が残ることがあります。画面破棄時にアダプターを外すと、この連鎖を切断できます。
アダプター内部で画面を直接保持する設計も避けます。項目選択は小さな関数として受け取り、画面が破棄された後は呼び出されないように一覧表示部品から切り離します。
7.3 ライフサイクルに応じて監視する
データの流れを画面の寿命と無関係に監視し続けると、非表示中にも画面更新用のデータが作られます。表示中だけ監視し、非表示になったら自動的に収集を停止する構成が適しています。
ライフサイクル対応の仕組みを利用すると、開始と停止を個別に管理する負担を減らせます。ただし、収集処理の内部で別の長時間処理を開始している場合は、その処理も適切に取り消されるか確認します。
Kotlinコード例
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
7.4 非同期処理を画面破棄時に停止する
画面を閉じても画像加工、検索、通信、計算が継続すると、その処理が保持する入力データや結果も残ります。結果を表示する必要がなくなった処理は、画面の破棄に合わせて取り消します。
ただし、アップロードや購入処理など、画面を閉じても継続すべき仕事は別です。そのような処理は画面から切り離し、永続的なバックグラウンド処理として管理します。
7.5 画面状態を保存可能にする
Androidでは、バックグラウンドに移動したアプリのプロセスが終了する可能性があります。重要な状態をメモリ上だけに保持すると、復帰時に入力内容や選択状態を再現できません。
画面へ戻るために最低限必要な情報は保存し、巨大な一覧や画像そのものは保存しないようにします。識別子、検索条件、ページ位置などを保存し、復帰時にデータを再取得する設計が安定します。
8. キャッシュ戦略を設計する
キャッシュは読み込み時間を短縮する一方、上限を決めなければ使用量を増やし続けます。保存対象、保存場所、最大容量、有効期間、削除条件を事前に決めることが重要です。
8.1 上限付きのメモリキャッシュを使う
参照回数の多い小さなデータは、メモリキャッシュによって再計算や再通信を減らせます。ただし、通常の地図へ追加し続けるだけでは、項目が削除されないため使用量が増えます。
最近利用されたデータを優先して残し、上限を超えた古いデータを削除する構造を利用します。上限は固定件数だけでなく、保存するデータのおおよその大きさを基準に決めます。
Kotlinコード例
class ThumbnailCache(maxSizeKb: Int) {
private val cache = object : LruCache<String, Bitmap>(
maxSizeKb
) {
override fun sizeOf(
key: String,
value: Bitmap
): Int {
return value.allocationByteCount / 1024
}
}
fun get(key: String): Bitmap? = cache.get(key)
fun put(key: String, bitmap: Bitmap) {
cache.put(key, bitmap)
}
fun clear() {
cache.evictAll()
}
}
8.2 メモリと保存領域を使い分ける
メモリキャッシュは高速ですが、アプリのプロセスが終了すると失われます。保存領域のキャッシュは読み込みに時間がかかるものの、プロセス終了後も再利用できます。
頻繁に表示する小さなデータはメモリ、再取得に時間がかかる大きなデータは保存領域へ置くなど、二段階の構成が有効です。すべてをメモリへ保持しないことが、低メモリ端末での安定性につながります。
8.3 有効期間を設定する
古いデータを永久に保存すると、容量だけでなく情報の正確性にも問題が生じます。保存時刻を記録し、一定時間が過ぎたデータは再取得または削除します。
データの種類ごとに更新頻度は異なります。利用者のプロフィール、商品価格、静的な設定画像などを同じ期間で管理せず、変化の速さと再取得費用に応じて有効期間を決めます。
8.4 ログアウト時に利用者データを削除する
利用者が切り替わっても以前のキャッシュが残っていると、不要なデータを保持するだけでなく、別利用者の情報を誤表示する危険があります。ログアウト処理では、認証情報と関連キャッシュをまとめて削除します。
削除対象を各画面に分散させると漏れが起きやすくなります。利用者単位の保存領域を一つの管理クラスへ集約し、ログアウト時に一括削除できる構成にします。
8.5 キャッシュ命中率を測定する
キャッシュを用意しても、ほとんど再利用されていなければメモリを占有するだけです。取得回数、再利用回数、削除回数を記録し、本当に効果があるか確認します。
再利用率が低いデータは、メモリキャッシュから保存領域へ移すか、毎回取得するほうが適切な場合があります。実際の利用状況を基に上限を調整し、推測だけで大きな領域を確保しないようにします。
9. バックグラウンド処理を軽量化する
バックグラウンド処理が多いと、画面を操作していない間もデータ、通信結果、実行環境が保持されます。Androidではバックグラウンド実行に制限があり、目的に合った処理方法を選ぶ必要があります。
9.1 遅延可能な仕事をまとめる
即時実行が不要な同期、整理、送信処理を個別に起動すると、短時間に複数の処理が重なる可能性があります。条件を設定できる仕事管理機構を使い、通信可能時や充電中など適切な時点にまとめます。
同じ種類の仕事が重複登録されないように、一意な名前や置き換え方針を設定します。検索履歴の送信や定期同期で、古い仕事と新しい仕事を両方実行する必要があるかを検討します。
Kotlinコード例
val constraints = Constraints.Builder()
.setRequiredNetworkType(
NetworkType.CONNECTED
)
.build()
val syncRequest =
OneTimeWorkRequestBuilder<DataSyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"account_data_sync",
ExistingWorkPolicy.REPLACE,
syncRequest
)
9.2 大量データを小分けに処理する
数万件のデータを一度に読み込み、変換し、送信すると、元データ、中間結果、送信データが同時に存在します。処理単位を小さくし、一つの単位が終わったら参照を破棄します。
小分けにする場合は、途中で処理が停止しても再開できる位置を記録します。最初からやり直す設計では、電池、通信量、メモリを何度も消費することになります。
9.3 不要な常駐サービスを避ける
常駐サービスは、必要性が明確な場合に限って使用します。単純な定期同期や後回し可能な送信処理のために常駐させると、実行環境と関連データを長時間保持します。
利用者が認識できる継続処理でない場合は、仕事管理機構や予定実行の仕組みを検討します。画面が閉じられた後も本当に即時実行が必要かを見直すことが重要です。
9.4 処理終了後に参照を破棄する
バックグラウンド処理の管理クラスが、最後に処理したデータや失敗結果をプロパティとして残していると、次回実行まで保持されます。処理が完了した時点で、大きな一時データへの参照を削除します。
診断情報を残す必要がある場合は、オブジェクト全体ではなく、識別子、件数、失敗理由などの小さな情報を保存します。巨大な通信応答や画像を、そのままエラー記録へ含めないようにします。
9.5 取り消しを処理へ反映する
仕事が取り消されても内部処理が継続すると、利用者に必要のない計算や通信が続きます。中断可能な関数を利用し、長い繰り返し処理では定期的に取り消し状態を確認します。
取り消し時には、開いているファイル、通信応答、データベース処理などを確実に閉じます。終了処理を一か所へまとめることで、正常終了と中断の両方で資源を解放できます。
10. Jetpack Composeのメモリを最適化する
Jetpack Composeでは、状態の変更に応じて画面関数が再実行されます。再実行そのものは通常の動作ですが、そのたびに重い計算や大きなオブジェクトを生成すると、不要な割り当てが増えます。
10.1 状態を必要な場所に限定する
画面全体で使わない状態を最上位へ置くと、小さな変更で広い範囲が再実行される可能性があります。状態は、それを利用する最も狭い範囲へ配置します。
一方で、複数の画面から共有する必要がある状態を各部品へ複製すると、整合性が崩れます。画面専用、機能専用、アプリ全体という単位を分け、それぞれ適切な管理場所を選びます。
10.2 計算結果を記憶する
再実行のたびに同じ並べ替えや変換を行うと、中間一覧が繰り返し生成されます。入力が変わらない間は計算結果を記憶し、必要なときだけ再計算します。
記憶処理の鍵が不足していると、入力が変わっても古い結果が使われます。反対に、毎回変わる値を鍵にすると再利用できません。結果に影響する値だけを指定します。
Kotlinコード例
@Composable
fun ProductScreen(
products: List<Product>,
selectedCategory: String
) {
val visibleProducts = remember(
products,
selectedCategory
) {
products.filter { product ->
product.category == selectedCategory &&
product.isVisible
}
}
ProductList(items = visibleProducts)
}
10.3 派生状態を利用する
元の状態から計算できる値を別の変更可能な状態として保持すると、二つの状態を同期する必要があります。同期漏れだけでなく、不要な更新やオブジェクト生成が増える原因になります。
元データから導ける値は派生状態として定義し、必要なときだけ計算します。スクロール位置からボタンの表示状態を決める場合など、入力の変化頻度が高く、出力の変化頻度が低い処理に適しています。
Kotlinコード例
@Composable
fun ScrollToTopButton(
listState: LazyListState,
onClick: () -> Unit
) {
val shouldShow by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 5
}
}
if (shouldShow) {
Button(onClick = onClick) {
Text("先頭へ戻る")
}
}
}
10.4 一覧へ安定した識別子を指定する
一覧項目の識別子が不安定だと、並べ替えや追加の際に既存項目を再利用しにくくなります。項目ごとに変化しない一意の識別子を指定し、状態と表示部品を正しく対応させます。
表示位置を識別子として使うと、先頭へ項目が追加されたときに、後続項目の識別子がすべて変化します。データベースの識別子やサーバーから受け取った一意値を使用します。
Kotlinコード例
LazyColumn {
items(
items = products,
key = { product -> product.id }
) { product ->
ProductRow(product = product)
}
}
10.5 画面関数で巨大データを生成しない
画面関数の内部で画像、巨大な文字列、大量の一覧、解析結果を直接生成すると、再実行時の負担が増えます。重い処理は状態管理クラスやリポジトリで実行し、画面には表示に必要な結果だけを渡します。
ただし、すべての小さな計算を外部へ移す必要はありません。文字の表示切り替えや単純な条件判定など、軽い処理は画面関数内に置いたほうが理解しやすくなります。
11. ネイティブメモリとNDKを管理する
Androidアプリでは、KotlinやJavaの領域だけでなく、画像処理、音声処理、外部ライブラリ、NDKなどがネイティブ領域を使用します。管理対象の外側に見える使用量が増えている場合は、ネイティブ側も調査する必要があります。
11.1 確保と解放を必ず対にする
ネイティブコードで確保した領域は、不要になった時点で明示的に解放する必要があります。途中で処理が失敗した場合や、複数の終了経路がある場合でも、解放処理が実行される構造にします。
手動で解放関数を呼ぶ方法だけに頼ると、早期終了や例外相当の分岐で漏れやすくなります。寿命に応じて自動的に解放される所有権管理を利用します。
C++コード例
#include <memory>
#include <vector>
std::unique_ptr<unsigned char[]> createBuffer(
std::size_t size
) {
auto buffer =
std::make_unique<unsigned char[]>(size);
for (std::size_t index = 0; index < size; ++index) {
buffer[index] = 0;
}
return buffer;
}
11.2 境界を越えるコピーを減らす
Kotlin側とネイティブ側の間で巨大な配列を何度も受け渡すと、その過程でコピーが発生する場合があります。画像や音声を細かい単位で繰り返し渡す設計は、使用量と処理時間の両方を増やします。
処理単位をまとめ、受け渡し回数を減らします。ただし、一度に巨大なデータを渡すと一時的な使用量が増えるため、データサイズと呼び出し回数の釣り合いを測定して決めます。
11.3 ネイティブハンドルを閉じる
ファイル、画像処理器、音声処理器、圧縮器などは、Kotlin上では小さなオブジェクトに見えても、内部で大きなネイティブ領域を保持していることがあります。使用後は提供されている終了関数を呼び出します。
自動回収を待つ設計では、解放時期を制御できません。短時間に複数回生成する処理では、以前の領域が残っている間に新しい領域が確保され、使用量が急増する可能性があります。
11.4 外部ライブラリの使用量を確認する
動画、地図、画像編集、機械学習などの外部ライブラリは、大きな内部キャッシュやネイティブ領域を持つことがあります。アプリ側のオブジェクトだけを調べても原因が見つからない場合は、ライブラリの初期化と終了方法を確認します。
複数画面で同じ処理器を個別に作るのではなく、安全に共有できる場合は一つにまとめます。ただし、共有した処理器が画面を参照しないこと、利用終了時に明確に解放できることが条件です。
11.5 端末の構成差を考慮する
ネイティブ処理は、端末の命令集合、画像形式、画面解像度、製造元の実装によって使用量が変わる可能性があります。一台の検証結果だけで安全と判断しないようにします。
特に、動画や高解像度画像を扱う機能は、複数の実端末で長時間試験します。開始と終了を繰り返し、処理終了後に使用量が元の範囲へ戻るかを確認します。
12. 低メモリ端末とプロセス終了に備える
Androidシステムはメモリ圧力が高まると、重要度の低いプロセスを終了して端末全体の動作を維持します。低メモリ終了の情報は、Android Vitalsや終了理由を取得する仕組みから調査できます。
12.1 メモリ削減通知へ対応する
システムからメモリ削減の通知を受け取った場合は、再取得可能なキャッシュ、非表示画面用の画像、一時的な計算結果などを削除します。利用者が現在操作している重要データまで破棄しないように注意します。
通知を受け取ってから初めて削除方法を考えるのではなく、キャッシュ管理クラスに削除機能を用意しておきます。削除後に必要になったデータを安全に再生成できることも確認します。
Kotlinコード例
class App : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when {
level >= TRIM_MEMORY_RUNNING_CRITICAL -> {
AppCaches.clearMemoryCache()
}
level >= TRIM_MEMORY_RUNNING_LOW -> {
AppCaches.reduceMemoryCache()
}
level >= TRIM_MEMORY_UI_HIDDEN -> {
AppCaches.clearScreenImageCache()
}
}
}
}
12.2 プロセス終了後に復元できるようにする
アプリがバックグラウンドへ移動した後、プロセスが終了すると、メモリ上の単一共有領域や状態管理クラスも失われます。復帰時にそれらが存在する前提で処理すると、空の画面や異常終了が発生します。
重要な識別子、入力途中の値、選択条件を保存し、プロセスが新しく作られた場合でも画面を再構築できるようにします。画像や巨大な一覧を保存するのではなく、再取得に必要な最小情報だけを残します。
12.3 前回の終了理由を確認する
利用者から「突然アプリが閉じた」と報告されても、通常の例外記録だけでは原因が分からない場合があります。前回のプロセスがメモリ不足、利用者操作、システム更新など、どの理由で終了したかを確認できる仕組みがあります。
終了理由を収集するときは、端末情報、アプリの版、直前の画面、使用量の傾向を合わせて記録します。個人情報や入力内容を不要に保存しないよう、診断に必要な最小限の情報へ限定します。
Kotlinコード例
fun logPreviousExitReasons(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
val manager =
context.getSystemService(
ActivityManager::class.java
)
val reasons = manager.getHistoricalProcessExitReasons(
context.packageName,
0,
5
)
reasons.forEach { info ->
Log.d(
"ExitReason",
"理由=${info.reason}, " +
"重要度=${info.importance}, " +
"時刻=${info.timestamp}"
)
}
}
12.4 低性能端末向けに機能を調整する
端末の状態に応じて、先読み件数、画像解像度、キャッシュ上限、同時処理数を調整すると、幅広い環境で安定しやすくなります。高性能端末と同じ設定をすべての端末へ適用しないことが重要です。
ただし、端末名による固定分岐は保守が難しくなります。利用可能なメモリ、画面寸法、処理結果の実測値など、明確な条件を基に調整します。
12.5 メモリ不足を意図的に再現する
通常利用だけでは、メモリ不足時の状態保存や復元を確認できません。開発者向け機能や検証手順を利用し、画面をバックグラウンドへ移した後にプロセスが再生成される状況を試します。
復元試験では、入力フォーム、決済途中、検索条件、一覧位置、詳細画面など、利用者が失うと困る状態を重点的に確認します。単にアプリが起動できるだけでなく、自然な位置へ戻れることが重要です。
13. ビルド設定とアプリ資源を最適化する
メモリ問題の中心は実行時の保持ですが、不要なコードや資源を減らすこともアプリ全体の軽量化につながります。Androidの最適化機能は、未使用コードや未使用資源の削除などを行えます。
13.1 コード最適化を有効にする
公開用ビルドでは、使用されていないコードの削除と最適化を有効にします。不要なクラスが減ることで配布容量が小さくなり、読み込むコードや関連情報も整理されます。
ただし、反射によって参照されるクラスや、外部から名前で呼び出される処理は、未使用と判断される可能性があります。公開前に主要機能を確認し、必要な保持規則だけを追加します。
Gradleコード例
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile(
"proguard-android-optimize.txt"
),
"proguard-rules.pro"
)
}
}
}
13.2 未使用資源を削除する
使用していない画像、画面定義、文字列、音声などが残っていると、配布容量が増え、管理も複雑になります。定期的に未使用資源を検出し、安全を確認したうえで削除します。
名前を動的に組み立てて参照する資源は、解析機能で未使用と判断される場合があります。動的参照を減らし、可能な限り型安全な直接参照へ変更します。
13.3 巨大な初期化を分割する
アプリ起動時にすべての機能、データベース、画像処理器、外部ライブラリを初期化すると、最初から大きな領域を使用します。利用されない機能まで読み込むため、起動時間にも影響します。
起動直後に必要な処理と、特定機能を開いたときに必要な処理を分けます。遅延初期化を利用するときは、最初の利用時に画面が停止しないよう、必要に応じてバックグラウンドで準備します。
13.4 依存ライブラリを見直す
一つの小さな機能のために巨大なライブラリを追加すると、コード量、資源、初期化処理が増える可能性があります。利用している機能と導入費用を定期的に確認します。
ただし、独自実装へ置き換えることで不具合や保守費用が増える場合もあります。単純に依存数を減らすのではなく、実際の使用量、安定性、更新状況、代替実装の負担を比較します。
13.5 検証用機能を公開版から除外する
詳細な診断画面、巨大な試験データ、開発用画像、通信内容の保存などは、公開版で不要な場合があります。構成ごとに含める機能を分け、利用者向けの版へ不要な資源を入れないようにします。
ログについても、公開版で必要な重要情報と、開発中だけ必要な詳細情報を分けます。巨大な文字列や応答内容を常に組み立てるログは、出力しない場合でも負担になる可能性があります。
14. メモリ最適化手法の違いと選び方
メモリリークの修正、画像の縮小、キャッシュ削減、オブジェクト生成の抑制は、それぞれ解決する問題が異なります。使用量が多いという理由だけで同じ対策を適用せず、計測結果と増加の仕方から方法を選びます。
14.1 メモリリーク対策と使用量削減の違い
メモリリーク対策は、不要になったオブジェクトが解放されない問題を解決します。画面を閉じても最低使用量が下がらず、操作を繰り返すたびに増加する場合に優先します。
使用量削減は、現在必要なオブジェクト自体が大きすぎる問題を改善します。画像の解像度、読み込み件数、キャッシュ上限などを見直し、必要な機能をより小さな領域で実現します。
14.2 メモリキャッシュと保存領域キャッシュの違い
メモリキャッシュは高速で、画面表示中に同じデータを繰り返し使う場合に適しています。一方、容量が限られ、プロセス終了時に失われるため、大量データの長期保存には向きません。
保存領域キャッシュは読み込み速度がメモリより遅いものの、大きなデータを保存しやすく、プロセス終了後も利用できます。実際のアプリでは両方を組み合わせ、段階的に検索する構成がよく利用されます。
14.3 即時解放と遅延解放の違い
画像処理器、ファイル、監視処理など、終了時点が明確なものは即時解放が適しています。自動回収を待たずに閉じることで、次の処理が始まる前に使用量を戻せます。
小さな通常オブジェクトまで手動で細かく管理する必要はありません。参照を残さない構造にし、実行環境の回収へ任せます。管理方法を複雑にすると、二重解放や状態不整合の危険が高まります。
14.4 主な最適化方法を比較する
最適化方法を選ぶときは、症状、主な原因、効果が出る範囲、導入時の注意点を確認します。複数の原因が重なっている場合は、一つずつ修正して測定し、どの変更がどれだけ効果を出したかを記録します。
| 最適化方法 | 適している症状 | 主な確認箇所 | 期待できる効果 | 注意点 |
|---|---|---|---|---|
| 参照の解除 | 画面を閉じても使用量が戻らない | 画面、監視処理、コールバック | 不要オブジェクトの解放 | 解除後の呼び出しに注意 |
| 画像の縮小 | 画像画面で急激に使用量が増える | 解像度、表示寸法、読み込み件数 | 一枚当たりの使用量削減 | 画質との釣り合いが必要 |
| キャッシュ上限の設定 | 長時間利用で徐々に増える | 保存件数、容量、有効期間 | 使用量の最大値を制御 | 再取得回数が増える場合がある |
| 分割取得 | 大量データ取得時に停止する | 通信、データベース、変換処理 | 一時的な使用量削減 | 再開位置の管理が必要 |
| 生成回数の削減 | スクロールや描画が途切れる | 繰り返し処理、文字列、変換 | 回収処理の頻度低下 | 可読性を損なわない |
| 状態の保存 | 復帰時に入力内容が消える | 画面状態、識別子、検索条件 | プロセス終了への耐性向上 | 巨大データを保存しない |
| ビルド最適化 | 配布容量や起動処理が大きい | 未使用コード、資源、依存関係 | アプリ全体の軽量化 | 公開前の機能確認が必要 |
表の内容は、すべてのアプリへ同じように適用できる優先順位ではありません。たとえば、画像を使わない業務アプリでは画像縮小の効果は限定的ですが、大量の帳票データを扱う場合は分割取得が大きな効果を持ちます。
14.5 優先順位の決め方
最初に、強制終了や操作不能につながる問題を優先します。メモリリーク、巨大画像の一括読み込み、上限のないキャッシュなど、使用量が継続的または急激に増える原因から修正します。
次に、スクロール中の細かな停止や、一時的な使用量増加を改善します。最後に、効果が小さい生成削減や細かなデータ構造の調整を行うと、開発時間を重要な問題へ集中できます。
15. 実践チェックリストと継続監視
メモリ最適化は一度の修正で完了する作業ではありません。新しい画面、画像、外部ライブラリ、バックグラウンド処理を追加すると使用傾向が変わるため、開発工程の中へ測定と確認を組み込む必要があります。
15.1 開発時の確認項目を決める
画面を追加したときは、開閉を繰り返して使用量が戻るか、非同期処理が停止するか、表示参照が解放されるかを確認します。画像一覧では、長時間スクロールと画面回転も試します。
確認項目を担当者の経験だけに任せると、機能によって試験品質が変わります。共通の確認表を用意し、機能完成時と公開前の両方で実施します。
15.2 修正前後の数値を保存する
「軽くなった気がする」という感覚だけでは、改善効果を判断できません。特定操作後の使用量、最大使用量、画面開閉後の最低値、割り当て回数などを記録します。
端末やアプリの版も一緒に保存すると、後から比較しやすくなります。改善によって別の処理時間や通信量が増えていないかも確認し、全体として有効な変更かを判断します。
15.3 長時間試験を実施する
短時間では問題が見えなくても、一覧の更新、画面遷移、画像読み込みを何十分も続けると使用量が増える場合があります。実際の利用に近い操作を繰り返し、安定した範囲で推移するか確認します。
利用者がよく使う主要経路だけでなく、ログインとログアウト、利用者切り替え、通信失敗からの再試行なども含めます。例外経路では、正常終了時に実行される解放処理が抜けることがあります。
15.4 自動試験へ組み込む
すべてのメモリ問題を自動検出することは難しいものの、重要画面の反復操作やプロセス再生成試験は自動化できます。同じ手順を継続して実行すれば、大きな悪化を公開前に発見しやすくなります。
自動試験では、一回の数値だけで失敗判定しないようにします。端末状態による揺れを考慮し、複数回の中央値、一定範囲を超えた継続的増加、以前の版との差などを基準にします。
Kotlinコード例
@Testfun productDetail_canBeOpenedRepeatedly() { repeat(20) { composeTestRule .onNodeWithText("商品一覧") .assertIsDisplayed()
composeTestRule .onAllNodesWithTag("product_item")[0] .performClick()
composeTestRule .onNodeWithText("商品詳細") .assertIsDisplayed()
composeTestRule.activityRule .scenario .onActivity { activity -> activity.onBackPressedDispatcher.onBackPressed() } }}
15.5 公開後の情報を継続確認する
開発環境で再現しない問題は、特定端末、特定画面、長時間利用などの条件で発生している可能性があります。公開後は、強制終了、低メモリ終了、端末別の発生傾向を確認します。
Android Vitalsでは、利用者端末から収集された安定性や性能に関する情報を確認できます。特定の版で発生率が上がった場合は、その版で追加した画像、ライブラリ、キャッシュ、バックグラウンド処理を重点的に調査します。
おわりに
Androidのメモリ最適化では、使用量を無条件に減らすのではなく、必要なデータを必要な期間だけ保持する設計が重要です。画面を閉じた後も残る参照、表示寸法より大きな画像、上限のないキャッシュ、大量データの一括処理などを見直すことで、アプリの安定性を大きく改善できます。
最初からコード全体を書き換える必要はありません。Android Studioで問題のある操作を測定し、メモリリーク、画像、キャッシュ、バックグラウンド処理の順に影響の大きい原因を調査します。修正後は同じ条件で再測定し、数値として改善したことを確認してください。
さらに、低メモリ端末やプロセス終了を前提とした状態保存を実装し、公開後も実際の利用状況を継続的に確認することが重要です。計測、修正、比較、監視を開発工程へ組み込むことで、端末性能に左右されにくい、軽量で安定したAndroidアプリを維持できます。
EN
JP
KR