A widget's images travel by value, and the trip is capped
Android only. A widget is drawn out of process: your code builds a RemoteViews tree, the system
hands it to the launcher, and the launcher inflates it. Anything that is not a resource id — which
means every decoded bitmap — is copied into that payload. The platform enforces a ceiling on the
bitmap memory one widget update may carry, and an update over it is rejected outright.
Rejected means the widget stops showing your layout and starts showing the framework's error placeholder. It does not throw where you can catch it, and the real cause appears only in the system log.
The arithmetic, which is the whole decision
A decoded bitmap costs width × height × 4 bytes at eight bits per channel. Nothing about the
source file matters — not its byte size, not its compression. Only its pixel dimensions.
# adapted — substitute your own tile count and source dimension
python3 -c 's=1145; n=10; print(f"{n} tiles at {s}px = {n*s*s*4/1e6:.1f} MB")'
# 10 tiles at 1145px = 52.4 MB
python3 -c 's=256; n=10; print(f"{n} tiles at {s}px = {n*s*s*4/1e6:.1f} MB")'
# 10 tiles at 256px = 2.6 MB
Against a budget in the region of 20 MB, that is the difference between rejected and four percent used. Put the other way round: one image at ~1145 px costs 5.2 MB, so fewer than four of them fit in the whole budget — a widget showing a row of five is already over before you add a second row.
Decode at draw size and the same widget is nowhere near the ceiling:
// adapted — the decode size is a constant next to the tile size it serves, not a magic number
private val TILE_SIZE = 58.dp
private const val TILE_DECODE_PX = 256 // still oversamples a 58dp tile at 3x; see the note below
suspend fun Context.loadTile(url: String?, cacheKey: String, sizePx: Int): Bitmap? { … }
256 px is chosen to oversample deliberately: a 58 dp tile is 174 px on a 3x screen, and launchers scale widget cells. Sizing exactly to the nominal dp is the one direction that produces a visibly soft tile.
Traps
The failure looks like a layout bug, not a memory bug. The widget goes blank or shows a generic "can't load" plate — the same thing a crash in the composition produces, and the same thing a missing permission produces. Reach for the system log before re-reading the layout; nothing in your own logging will mention size.
It is device-dependent, so "it works here" proves nothing. The budget is derived from the display, and the same widget can pass on a small screen and fail on a large one, or pass in one launcher's cell size and fail in another's. A widget that renders on the development device has not been tested.
Count every bitmap in the tree, including the ones you forgot are bitmaps. The large hero image, each tile, and any background you drew as a bitmap rather than a resource all land in the same payload. Adding one tile to a row is a budget change.
A bitmap you decode but never draw is free — and that is a trap in the other direction. An image loaded only to sample a colour from never enters the payload, so it does not count against the budget. It is also the one place a larger decode is defensible. Do not "optimise" it into the same constant as the drawn tiles without deciding that separately.
One key and one decode size per logical image, travelling together. The key names the entry on
disk — the encoded bytes. The pixels come from the request's size(...), and the in-memory
identity already includes that size, so the two are not interchangeable: one key with two sizes still
produces two bitmaps, and a surface deriving a colour from one samples different pixels and hands
back a different colour — two widgets on one home screen tinting themselves differently from the
same source. Two keys at the same size drift not at all; they only duplicate a disk entry. Sharing
the key is necessary and not sufficient. Name the pair, and pass both together, everywhere:
// adapted — every surface wanting the large decode of this image asks by the same name AND size
loadTile(url, cacheKey = url + "BIGGER", sizePx = TILE_DECODE_PX)
Hardware-backed bitmaps are unusable here for two separate reasons. They cannot be serialised into the payload, and their pixels cannot be read for palette extraction. Turn the option off on every request whose result the widget draws or samples — the failure is a blank image and a default colour, with nothing in your log.
Decoding at draw size is not a substitute for limiting the count. Halving the decode buys a factor of four; adding tiles costs linearly. A design that wants twenty tiles needs a smaller decode and an argument about why twenty tiles are legible at that size.
Verifying it
Count the bitmaps the widget actually draws, and the decode size of each. Every call that produces a bitmap for the tree, plus the constants feeding it:
grep -rn 'loadBitmap(\|_DECODE_PX' --include='*.kt' <widget-source-dir>Multiply out with the snippet above using your own count. Any total in the tens of MB is already broken on some device.
Every drawn bitmap must come from a sized request. A call with no size argument decodes at the source's native dimensions. Give it a generous context window — the size call is often the last line of a builder chain:
grep -rn -A16 'ImageRequest' --include='*.kt' <widget-source-dir> \ | grep -E '\bsize\(|allowHardware\('Pass condition: one
size(...)and oneallowHardware(false)per request whose bitmap is drawn or sampled. Expect this to find at least one request with the hardware flag and no size — that shape survives only while it is a single image, and it is the one that breaks the day a second is added beside it.Confirm key and decode size agree across surfaces. List both in one pass — the key alone tells you nothing (
-vdrops layout sizing, which is indp, not pixels):grep -rnE 'diskCacheKey|_DECODE_PX|\.size\(' --include='*.kt' <widget-source-dir> | grep -v '\.dp)'One logical image read by two surfaces must show the same key and the same size on both. One key against two sizes — or against a size and no size at all, which decodes natively — is the colour drift already happening, and it is what a comment saying "shared key, so every surface reads the identical bitmap" hides: the sizes live in other files and nobody lines them up.
Watch the system log while placing the widget, filtering for the widget service rather than your own tag. A rejected update is reported there and nowhere else; if the widget renders and the log is silent, you are inside the budget on this device.
Place every widget you ship on one home screen at the largest cell size the launcher offers, on the largest screen you support. That is the configuration the budget is tightest in, and it is the one nobody sets up by accident.
Layout-side companions: glance-layout-vocabulary for sizing the tiles this budget pays for, and
glance-widget-over-existing-state for where the decode belongs in the update cycle.