A vector costs CPU once, a bitmap costs memory forever

ImageVector is drawing commands executed at render time; ImageBitmap is pixels held in memory. The choice is about scaling fidelity versus per-frame cost, and the common mistakes run in both directions.

5 min read
androidcomposekotlingraphics

Day 56 — A vector costs CPU once, a bitmap costs memory forever

Day 56 of 100. Two ways to represent an image, and the difference is not a file format detail — it changes what happens on every frame.

The symptom

An app whose icons are blurry on a tablet:

Icon(
    painter = painterResource(R.drawable.ic_settings),   // a 24×24 PNG
    contentDescription = "Settings",
    modifier = Modifier.size(64.dp),
)

The PNG was exported at 24dp because that's the icon size in the design. Rendered at 64dp it's upscaled, and every edge is soft.

The mirror-image problem, in the same codebase: a full-screen illustration shipped as a vector, which drops frames while a screen animates in.

Why the obvious fix fails

The obvious fix for the icon is more PNGs — mdpi through xxxhdpi, plus a larger export for the tablet layout. That's five files per icon, a rebuild every time the design changes, and it still breaks at a size nobody exported.

The obvious fix for the illustration is to simplify the vector. That helps, and it's fighting the representation rather than choosing a different one.

A vector is drawing commands re-executed per frame; a bitmap is pixels held in memory

The actual mechanism

ImageVector is a tree of drawing commands — paths, fills, strokes, groups. Nothing is rasterised until it's drawn, so it renders at any size at full fidelity. The cost is CPU: those paths are executed to produce pixels, and complexity scales that cost.

ImageBitmap is a pixel buffer. Drawing it is a memory copy — very fast, constant time regardless of complexity. The cost is memory: width × height × 4 bytes, resident for as long as you hold it, and scaling it up interpolates.

So the trade is:

ImageVector ImageBitmap
Scaling perfect at any size interpolates; blurs when upscaled
Memory small (the commands) w × h × 4 bytes
Draw cost proportional to path complexity constant, very low
Best for icons, logos, simple illustration photos, complex art, anything painterly
Tinting free (tint on Icon) requires a colour filter

The icon should be a vector. The illustration should be a bitmap — a WebP, sized for the largest place it appears.

Caching the vector

The mechanism matters for a subtle reason: painterResource on a vector parses the XML the first time, which is not free.

Inside a LazyColumn item, that parse can happen per item:

items(rows) { row ->
    Icon(painterResource(R.drawable.ic_chevron), null)   // parsed per item
}

Compose caches resolved resources, so this is usually fine — but for a vector used in every row of a long list, hoisting it makes the intent explicit and removes any doubt:

val chevron = painterResource(R.drawable.ic_chevron)
LazyColumn {
    items(rows) { row -> Icon(chevron, null) }
}

The ImageVector.Builder API also lets you define icons in Kotlin rather than XML, which is what the Icons.Default.* set is — no resource lookup, no parse, and it works unchanged in Compose Multiplatform where Android resources don't exist.

Icon versus Image

Two composables that both draw a painter, with one important difference:

Icon(painter, contentDescription = "Settings")   // TINTS with LocalContentColor
Image(painter, contentDescription = "Photo")     // draws as-is

Icon applies the current content colour, which is why an icon inside a Button automatically matches the label. That's Day 44's LocalContentColor doing its job, and it's why a multi-colour logo passed to Icon comes out as a flat silhouette — a real confusion that has a one-word fix.

Icon also defaults to 24dp, matching the Material icon size.

The vector's own size

A vector has an intrinsic size from its viewportWidth/viewportHeight, and it will use that if the modifier doesn't constrain it. An icon authored at a 48×48 viewport drops into a layout at 48dp, not 24dp — which looks like a Compose bug and is an asset property.

Worth checking when an icon set renders inconsistently: the viewport dimensions in the drawable XML, not the code.

What vectors can't do

Two limits worth knowing before committing an asset to vector format.

Gradients are supported; filters mostly aren't. Android's vector drawable format covers paths, groups, clip paths and gradient fills. Blurs, blend modes and the more elaborate effects an SVG can express get dropped at import — which is why an illustration that looks right in the design tool sometimes imports flat.

Complexity has a real cost. A vector with several hundred path nodes, re-executed every frame while a screen animates, is measurably slower than blitting a bitmap. The threshold is fuzzy, but "an illustration a designer drew freehand" is usually past it, and "an icon" is usually nowhere near.

The practical division that holds: if a human could plausibly redraw it with a dozen shapes, it's a vector. If it has texture, photography or painterly shading, it's a bitmap.

Converting between them

Occasionally you need pixels from a vector — a shortcut icon, a notification, a share sheet:

val painter = painterResource(R.drawable.ic_logo)
val bitmap = remember(painter) {
    ImageBitmap(200, 200).also { image ->
        CanvasDrawScope().draw(density, LayoutDirection.Ltr, Canvas(image), Size(200f, 200f)) {
            with(painter) { draw(size) }
        }
    }
}

Rasterise once at the size you need and remember it. Doing this per frame is the worst of both representations — the vector's draw cost plus the bitmap's allocation.

How to prove it

The scaling claim is a preview at two sizes:

@Preview
@Composable fun IconAtSizes() = Row(verticalAlignment = Alignment.CenterVertically) {
    Icon(painterResource(R.drawable.ic_settings), null, Modifier.size(24.dp))
    Icon(painterResource(R.drawable.ic_settings), null, Modifier.size(96.dp))
}

A vector is crisp at both. A raster is visibly soft at 96dp, which makes the asset type obvious without opening the file.

For the memory claim, the Android Studio memory profiler shows bitmap allocations directly. A full-screen image at 1080×2400 is about 10MB as ARGB_8888 — worth knowing before you cache several of them.

What this generalizes to

The principle is a representation encodes a trade, and the right one depends on how the asset is used. Commands are compact and cost time to execute; pixels are bulky and cost nothing to copy. Neither is better; they're opposite ends of a space-time trade that shows up everywhere from fonts to video codecs.

The useful habit is asking what varies. If the size varies, you want commands. If the content is complex and the size is known, you want pixels. Most asset mistakes come from picking by file format habit rather than by that question.

There is a third option worth knowing about for the middle ground: rasterise the commands once at the size you need and cache the result. That's what font rendering does with a glyph atlas, and what the vector-to-bitmap conversion above is doing by hand. When an asset is complex and drawn repeatedly at one size, it's the best of both — and it's only available because the two representations are convertible.

Tomorrow, Day 57: writing a custom Painter, and where it sits between an asset and a Canvas.


Day 56 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: ImageVector.