Battery-Efficient Canvas Rendering: Lessons From a Live Wallpaper

Building a live wallpaper is one of the best ways to learn Android’s rendering pipeline — and one of its harshest teachers of battery discipline. When your app draws on the home screen all day, every wasted frame eventually shows up in someone’s battery stats. Over the years building Seasons Live Wallpaper, I’ve learned that efficient canvas rendering isn’t about drawing less detail — it’s about not doing work you don’t need to do.

This is an honest look at what actually keeps Seasons light on the battery today, and — just as importantly — what I haven’t built yet and would still like to improve. No invented benchmarks, just the real patterns from a shipping app.

A modern smartphone floating at an angle, displaying a vibrant live wallpaper shifting from autumn to winter, surrounded by glowing neon data streams and an efficient battery icon with a green leaf.
Balancing fluid rendering with strict battery discipline in Android live wallpapers.

Sync to the display with Choreographer

Seasons drives its draw loop with Choreographer.FrameCallback rather than a Handler.postDelayed loop or Thread.sleep. The choreographer fires in step with the display’s vsync, so your frames land exactly when the screen is ready to show them — no oversleeping, no busy-waiting, no frames rendered that the display will never present.

It also caps at 50 FPS, not 60. On a wallpaper sitting behind your icons and clock, the difference is invisible — but it’s roughly 17% fewer frames, and fewer frames means less CPU, less GPU, and less battery. The loop simply skips a doFrame if not enough time has passed:

private val choreographer = Choreographer.getInstance()
private var lastFrameNanos = 0L
private var frameIntervalNanos = FRAME_INTERVAL_DEFAULT

private val frameCallback = object : Choreographer.FrameCallback {
    override fun doFrame(frameTimeNanos: Long) {
        // If we're hidden or the surface is gone, stop the loop entirely.
        if (!visible || !surfaceHolder.surface.isValid) {
            isDrawingScheduled = false
            return
        }
        if (frameTimeNanos - lastFrameNanos >= frameIntervalNanos) {
            drawFrame()
            lastFrameNanos = frameTimeNanos
        }
        choreographer.postFrameCallback(this)
    }
}

private companion object {
    const val FRAME_INTERVAL_DEFAULT = 20_000_000L // 50 FPS
    const val FRAME_INTERVAL_SAVER   = 33_000_000L // ~30 FPS
}

The biggest win: stop drawing when nobody’s looking

The single largest saving in Seasons isn’t a clever algorithm. It’s not drawing at all when the wallpaper is hidden. The moment you open an app, your wallpaper is behind an opaque window — every frame it renders from that point is pure waste.

A wallpaper engine receives onVisibilityChanged() callbacks from the system. When the wallpaper goes invisible, Seasons stops posting frame callbacks completely; when it becomes visible again, the loop restarts. There is no clever throttling here — hidden means zero rendering.

override fun onVisibilityChanged(visible: Boolean) {
    this.visible = visible
    if (visible) {
        refreshSettings()
        startDrawing() // re-posts the frame callback
    } else {
        pauseDrawing() // stops posting frames — nothing renders
    }
}

If you take one thing from this post, make it this: listen to onVisibilityChanged() and genuinely stop your loop. It’s the highest-impact, lowest-effort change you can make.

A battery-saver mode (and an honest caveat)

Seasons ships a Battery Saver toggle in settings. When you enable it, the frame interval switches from 50 FPS to about 30 FPS — a noticeable cut in rendering work for a barely perceptible change in smoothness.

fun setBatterySaverEnabled(enabled: Boolean) {
    frameIntervalNanos = if (enabled) {
        FRAME_INTERVAL_SAVER   // ~30 FPS
    } else {
        FRAME_INTERVAL_DEFAULT // 50 FPS
    }
}

I want to be precise here, because it would be easy to oversell it: this is a user setting, not automatic battery detection. Seasons does not currently read BatteryManager to drop frames on its own when the battery runs low. That’s a genuine improvement on my list — wiring up an ACTION_BATTERY_CHANGED receiver so the saver engages automatically below, say, 20%. For now, the user stays in control of the trade-off.

Draw on the GPU with lockHardwareCanvas()

When you lock a canvas to draw a wallpaper frame, you have a choice. The software lockCanvas() renders on the CPU into a memory buffer. The hardware-accelerated lockHardwareCanvas() routes your draw calls through the GPU, which is both faster and more power-efficient for the gradients, bitmaps, and shapes a scene like this is made of. Seasons uses the hardware path:

private fun drawFrame() {
    if (!surfaceHolder.surface.isValid) return
    val canvas = surfaceHolder.lockHardwareCanvas() ?: return
    try {
        drawFullScene(canvas)
    } finally {
        surfaceHolder.unlockCanvasAndPost(canvas)
    }
}

lockHardwareCanvas() requires API 26+. Seasons sets a high minSdk, so it’s always available; if you support older devices, guard it with a Build.VERSION.SDK_INT check and fall back to lockCanvas().

Don’t rebuild what didn’t change

The day/night sky is a vertical gradient. The naive approach rebuilds a LinearGradient every single frame — 50 fresh allocations a second for something that barely changes from one minute to the next. Instead, Seasons rebuilds the gradient on a timer (about once a minute) and reuses the same Paint shader for the roughly 3,000 frames in between.

private val skyPaint = Paint()

private fun startSkyRefreshLoop() = scope.launch {
    while (isActive) {
        updateSkyGradient() // builds one new LinearGradient
        delay(60_000)       // ~3000 frames at 50 FPS reuse it
    }
}

private fun updateSkyGradient() {
    skyPaint.shader = LinearGradient(
        0f, 0f, 0f, height,
        skyColorsForCurrentTime(), null,
        Shader.TileMode.CLAMP,
    )
}

// The per-frame draw just blits with the cached shader — no allocation.
fun draw(canvas: Canvas) {
    canvas.drawRect(0f, 0f, width, height, skyPaint)
}

The same discipline applies to Paint objects: allocate them once in the constructor and reuse them in the draw loop. Allocating a Paint on every frame is a classic, easy-to-miss source of jank and GC pressure.

What I’d still improve

Honesty cuts both ways, so here’s what Seasons does not do yet:

  • Object pooling for particles. Snow and rain particles are capped and their list is reused, so it isn’t allocating hundreds of objects per frame. But new Raindrop and Snowflake objects are still created when weather intensity changes. A small pre-allocated pool would remove even those allocations and make GC behaviour more predictable — which matters most for heavy effects on low-end devices.
  • Automatic, battery-aware frame rate. As above, the saver is a manual toggle today. Reading the battery level and engaging it automatically is the obvious next step.
  • Dirty-rectangle invalidation. Seasons redraws the full scene each frame. For many apps, clipping to just the region that changed is a big win — but in a live wallpaper where the sky, clouds, and particles all move continuously, nearly the whole frame changes anyway, so the payoff is small. The hardware canvas is the better lever here. The technique is worth knowing, and worth measuring before assuming it’ll help your case.

Measure, don’t guess

Every claim above is something you can verify yourself in the Android Profiler. Watch CPU and GPU usage while the wallpaper runs, then toggle visibility — you’ll see rendering cost fall to zero the moment it’s hidden. Prefer Choreographer frame callbacks over Thread.sleep for timing, because they’re synced to the real display refresh rate and give you the most stable cadence.

For a deeper dive into frame timing and how the choreographer’s guarantees work, see my post on Choreographer and frame timing.

Battery efficiency in a live wallpaper comes down to a mindset: sync to the display, never draw when hidden, drop frames when the user asks, push pixels through the GPU, and don’t rebuild what didn’t change. Get those right and an animated wallpaper can run all day without anyone spotting it in their battery screen.

For more on the rendering work behind this app, see the particle system behind Seasons and its weather-reactive rendering engine.

See it running — Seasons Live Wallpaper on Google Play

Scroll to Top