Pixels Are Not Free: Right-Size, Share, and Release Bitmaps Before Android Does It for You

2026-11-12来源 dev.toEnglish
Pixels Are Not Free: Right-Size, Share, and Release Bitmaps Before Android Does It for You

Most image-related out-of-memory crashes are not caused by one enormous photo. They come from a thousand small decisions: a list cell that asks for “whatever size the server has,” a signed URL that defeats the cache, a singleton that keeps thumbnails alive long after the user left the screen. Each decision looks harmless in review, and each one shows up later as a crash cluster in your production dashboards.

This post is a field guide for senior Android engineers. It organizes the problem into three levers, Reduce, Reuse, and Recycle, and for each one it covers idiomatic Kotlin, the library knobs that matter, and the failure modes that actually reach production. The framing follows the Android Developers session “Actionable strategies for reducing your app’s Bitmap memory footprint” from Android Dev Summit 2026; the code, numbers, and scenarios below are my own.

1. Start with the arithmetic

A decoded bitmap is just a grid of pixels. Its memory cost does not depend on the file size on disk, the format, or how well it compressed. It depends on the dimensions and the pixel format:

bytes = width × height × bytesPerPixel 

A 4000 × 3000 JPEG may be 3 MB on disk, but decoded as ARGB_8888 (4 bytes per pixel) it occupies roughly 46 MiB. If that image is displayed in a 360 × 270 slot, the slot needs well under 1% of those bytes.

bitmap-memory-math

Two platform facts shape how this plays out:

  • Since Android 8.0 (API 26), bitmap pixel data lives in native memory rather than the Java heap. The Java heap limit no longer tells you how close you are to trouble, so you need native-aware tooling (more on that in section 7).
  • Bitmap.Config.HARDWARE (API 26+) keeps pixels in graphics memory and is cheaper for pure display, but the pixels cannot be read back by software code. Details in section 3.

Failure mode: “it’s only a thumbnail.” A product grid decodes full-resolution assets because the image loader was never told the target size. On a flagship test device nobody notices. On a 3 GB device with a few other apps cached, the kernel starts killing processes, and your app either dies in the background or throws OutOfMemoryError mid-scroll.

2. What “healthy” looks like in production

Before touching code, decide what you are measuring. Memory problems reach you through three channels:

  1. Crash reports with OutOfMemoryError stacks, often pointing at BitmapFactory, ImageDecoder, or a library's decode job.
  2. Android Vitals and Play Console reliability data, which let you slice by device model, RAM class, and OS version, so you can see that the problem lives on low-RAM devices rather than everywhere.
  3. Silent process death. The app is not crashing; the system is reclaiming it. Users experience this as “the app restarted when I came back to it.” It rarely produces a stack trace, which is why it hides for so long.

The practical takeaway: segment by RAM class first. If your crash rate is flat on high-memory devices and spiking on low-memory ones, you have a footprint problem, not a logic bug.

3. Reduce: decode only what the screen can show

Let the loader know the target size

Coil and Glide both size decodes from the target view or composable by default. The failures happen when you opt out of that, deliberately or accidentally. Coil snippets in this post target Coil 3; where Coil 2 differs, it is called out.

import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest @Composable fun ProductThumb(url: String, modifier: Modifier = Modifier) { val context = LocalContext.current AsyncImage( model = ImageRequest.Builder(context) .data(url) // Fixed slot: say so. No layout-measurement dependency, no accidental ORIGINAL. .size(360, 270) .build(), contentDescription = null, modifier = modifier.size(120.dp, 90.dp), ) } 

Failure mode: Size.ORIGINAL copied from a detail screen into a list. It works beautifully for a hero image and is catastrophic for a LazyColumn. Grep your codebase for it and require a comment next to every use.

Failure mode: unbounded layouts. A wrap_content image inside a scroll container gives the loader nothing to measure against. Constrain the slot, or pass an explicit size.

Decode manually when you must

For code paths that bypass a loader (sharing, uploads, custom canvases), use ImageDecoder on API 28+ and a two-pass BitmapFactory decode below that.

import android.graphics.Bitmap import android.graphics.ImageDecoder import kotlin.math.roundToInt @androidx.annotation.RequiresApi(28) fun decodeToFit(source: ImageDecoder.Source, maxWidth: Int, maxHeight: Int): Bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> val scale = minOf( maxWidth.toFloat() / info.size.width, maxHeight.toFloat() / info.size.height, 1f, // never upscale ) decoder.setTargetSize( (info.size.width * scale).roundToInt().coerceAtLeast(1), (info.size.height * scale).roundToInt().coerceAtLeast(1), ) } 
import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.File fun decodeSampled(file: File, reqWidth: Int, reqHeight: Int): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.path, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null var sample = 1 // The platform documents that inSampleSize is rounded down to the nearest power of two, // and actual dimensions can vary by decoder. Stepping in powers of two ourselves keeps the // request and the result aligned. Stay at or above the request; treat the result as approximate. while (bounds.outWidth / (sample * 2) >= reqWidth && bounds.outHeight / (sample * 2) >= reqHeight) { sample *= 2 } val opts = BitmapFactory.Options().apply { inSampleSize = sample } return BitmapFactory.decodeFile(file.path, opts) } 

One caveat: the decoded dimensions are not guaranteed to match your arithmetic exactly, since formats and decoders may round differently. Read bitmap.width and bitmap.height after decoding instead of assuming them.

Hardware bitmaps: a discount with strings attached

Hardware bitmaps are a good default for images you only draw. They fail when something tries to touch the pixels:

  • Drawing one onto a software Canvas throws IllegalArgumentException.
  • getPixel, copy to a mutable config, or palette extraction can fail or force a conversion.
  • Some versions have had file-descriptor pressure with large numbers of hardware bitmaps, which is why mature loaders add guards for specific OS ranges.

Failure mode: the screenshot feature. A “share this card” action renders a view hierarchy into a software canvas. Everything works until the card contains a hardware-backed image, and then it crashes only on that path. Request a software config for any image that will be re-drawn or read back:

ImageRequest.Builder(context) .data(url) .allowHardware(false) // this image feeds a software Canvas .build() 

4. Reuse: one cache, one key, one size

Reducing decode size gets you the first big win. The second is making sure you decode each image once.

Duplicates are born at the key

Memory caches match on a key. If two call sites produce different keys for the same visual content, you hold two bitmaps. Common culprits:

  • Signed or expiring URLs where only the query string changes.
  • The same asset requested at several target sizes (list cell, bottom sheet, notification).
  • Multiple loader instances, each with its own cache.

duplicate-cache-entries

Normalize the key

With Coil, set the cache key explicitly:

import android.net.Uri private val VOLATILE_PARAMS = setOf("sig", "expires", "ts") fun String.stableKey(): String { val uri = Uri.parse(this) val keep = uri.queryParameterNames.filterNot { it in VOLATILE_PARAMS }.sorted() return uri.buildUpon().clearQuery().apply { keep.forEach { name -> uri.getQueryParameters(name).forEach { appendQueryParameter(name, it) } } }.build().toString() } fun imageRequest(context: Context, url: String, w: Int, h: Int) = ImageRequest.Builder(context) .data(url) .memoryCacheKey(url.stableKey()) .diskCacheKey(url.stableKey()) .size(w, h) .build() 

With Glide, subclass GlideUrl so the network URL and the cache identity can differ:

class StableGlideUrl(private val raw: String) : GlideUrl(raw) { override fun getCacheKey(): String = raw.stableKey() } Glide.with(view).load(StableGlideUrl(url)).override(360, 270).into(view) 

Failure mode: normalizing too aggressively. If a query parameter selects a different rendition (?w=720), stripping it merges two genuinely different images. Keep an allowlist of parameters that affect content and drop only the ones that affect authorization or time.

Standardize sizes and share one loader

Pick a handful of size buckets (small, medium, large) and let call sites choose a bucket rather than an arbitrary pixel count. Provide one ImageLoader through your DI graph:

// Coil 3 fun provideImageLoader(context: Context): ImageLoader = ImageLoader.Builder(context) .memoryCache { MemoryCache.Builder() .maxSizePercent(context, 0.15) // bounded share of the app's available memory .build() } .build() 

In Coil 2 the builder takes the context in its constructor instead: MemoryCache.Builder(context).maxSizePercent(0.15).build().

Failure mode: a second loader created in a feature module “just for previews.” It gets its own cache, doubling resident bitmaps for the same URLs. Make the loader a singleton and let features borrow it.

Failure mode: oversized cache budgets. Raising the memory cache to chase hit rate trades a smoother scroll for a higher chance of eviction by the system. Treat the cache percentage as a budget you defend in review.

5. Recycle: give memory back when the user is not looking

Reuse keeps memory useful. Recycling gives it back when it stops being useful, which is mostly when your UI leaves the screen.

Loaders such as Glide and Coil register for system trim callbacks themselves, so their caches shrink without extra code. The gap is everything you own: custom LRUs, decoded frames in a ViewModel, pre-rendered share cards, pooled buffers.

Two things about the levels trip people up. First, they are not one ladder. RUNNING_* (5, 10, 15) say "you are in the foreground and the device is short on RAM," while UI_HIDDEN (20) and BACKGROUND, MODERATE, COMPLETE (40, 60, 80) describe the UI leaving and the process sliding down the cached-process list. Comparing 15 against 20 mixes two different states. Second, on API 34 and above the platform no longer notifies apps of RUNNING_*, MODERATE, or COMPLETE (they are marked deprecated for that reason), and onLowMemory() is never called. On current devices, UI_HIDDEN and BACKGROUND are the signals you will actually receive; older OS versions still deliver all of them. If your minSdk is below 34, handle both tracks.

import android.content.ComponentCallbacks2 import android.content.res.Configuration import android.graphics.Bitmap import android.util.LruCache // framework class; androidx.collection.LruCache works the same way class ThumbnailStore(maxBytes: Int) : ComponentCallbacks2 { private val lru = object : LruCache<String, Bitmap>(maxBytes) { override fun sizeOf(key: String, value: Bitmap) = value.allocationByteCount } fun get(key: String): Bitmap? = lru.get(key) fun put(key: String, bitmap: Bitmap) { lru.put(key, bitmap) } @Suppress("DEPRECATION") override fun onTrimMemory(level: Int) { when { // Cached-process track: 40/60/80 all mean "you are expendable." level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> lru.evictAll() level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> lru.trimToSize(lru.size() / 2) // Foreground-pressure track: only delivered below API 34. level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> lru.trimToSize(lru.size() / 2) level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> lru.trimToSize(lru.size() * 3 / 4) } } override fun onConfigurationChanged(newConfig: Configuration) = Unit // Never called on API 34+, but older versions still invoke it. @Suppress("OVERRIDE_DEPRECATION") override fun onLowMemory() { lru.evictAll() } } // Application.onCreate(): register on the application context so the callback // lives exactly as long as the process. If you ever register from a shorter-lived // context, pair it with unregisterComponentCallbacks(). registerComponentCallbacks(thumbnailStore) 

Threading note: onTrimMemory is delivered on the main thread, while get and put usually run from decode coroutines. Both android.util.LruCache and androidx.collection.LruCache synchronize their operations, so this is safe. The size() / 2 target is a point-in-time snapshot, which is fine for a best-effort trim.

trim-memory-ladder

Failure modes that grow memory in the background

  • Bitmaps in ViewModel state. A StateFlow<Bitmap?> survives configuration changes by design, which means it also survives long after the screen stops needing it. Hold a Uri or an ID and let the loader resolve it.
  • Leaked views. A fragment that keeps its binding after onDestroyView pins the whole view tree, and every drawable inside it. Null the binding.
  • Manual recycle() on a shared bitmap. Calling recycle() while an ImageView still references the bitmap crashes later with a "trying to use a recycled bitmap" error, usually far from the call that caused it. With modern loaders, you rarely need to call it at all; drop references and let the loader and GC do their work.
  • Prefetch that never stops. An image prefetcher that keeps filling a cache while the app sits in the background is a slow leak with a friendly name. Pause it when the UI hides.

6. The 3R checklist

three-rs-playbook

Use it as a review gate. If a pull request touches image loading, ask three questions: Is every decode sized to its slot? Does this path produce the same key as the other paths that show this image? What releases this memory when the screen is gone?

7. Diagnose, fix, verify

Treat memory work like performance work: the loop has to end with a measurement.

diagnostic-loop

Detect with Android Vitals and crash clusters, segmented by RAM class and OS version.

Reproduce on a low-memory device or emulator profile with a scripted scenario: scroll a long list, rotate, background the app, return. Memory bugs love sequences.

Inspect with Android Studio’s Memory Profiler (native allocation tracking matters post-API 26), heap dumps for what is retained, and adb shell dumpsys meminfo <package> for the process-level picture.

Fix one lever at a time, so you can tell which change moved the number.

Verify with the same script on a release build, comparing peaks, not averages.

A tiny debug-only audit

Catching oversized decodes at development time is cheaper than catching them in Vitals. This helper compares what an ImageView holds against what it displays:

import android.content.pm.ApplicationInfo import android.graphics.drawable.BitmapDrawable import android.util.Log import android.widget.ImageView import androidx.core.view.doOnLayout fun ImageView.auditBitmapSize(tag: String = "BitmapAudit", slackFactor: Float = 2f) { // Not BuildConfig.DEBUG: AGP 8+ disables BuildConfig generation by default in library modules. val debuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 if (!debuggable) return doOnLayout { val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@doOnLayout val oversized = bitmap.width > width * slackFactor || bitmap.height > height * slackFactor if (oversized) { Log.w( tag, "Oversized: bitmap ${bitmap.width}x${bitmap.height} " + "(${bitmap.allocationByteCount / 1024} KiB) in ${width}x${height} view", ) } } } 

Call it from a base adapter or an image-binding extension and treat warnings as bugs. Tune slackFactor for screens where you intentionally oversample for zoom.

If your team uses AI-assisted analysis for heap dumps or Vitals exports, it can speed up triage, but treat its output as a hypothesis. Confirm every claim against a profiler capture before you ship a fix.

8. Key takeaways

  • Memory cost is arithmetic on pixels, not file size. Size every decode to the slot that shows it.
  • Duplicates come from keys and sizes. Normalize cache keys, standardize size buckets, and run one shared loader.
  • Release what you own on TRIM_MEMORY_UI_HIDDEN and TRIM_MEMORY_BACKGROUND, and handle the foreground RUNNING_* levels too if you support OS versions below 34. Keep bitmaps out of long-lived state.
  • Hardware bitmaps save memory but break read-back paths. Opt out per request where pixels are consumed by software code.
  • Measure before and after on the device class that was hurting. A fix without a number is a guess.

📱 Stop Guessing Whether Your Android Code Actually Works

If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend “Mastering Android Testing” — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.

原文:https://www.a8q9.cn/en/article/pixels-are-not-free-right-size-share-and-release-bitmaps-before-android-does-it--425bf9.html

相关文章