Building a Live Wallpaper That Doesn't Drain the Battery — Designing the WallpaperService Render Loop Around a Power Budget
Live wallpapers quietly burn battery because they keep drawing when nothing is visible. Here is the WallpaperService.Engine design I used to cut power draw by roughly 60% in a real wallpaper app, built around visibility, idle, and thermal gates.
One day a wallpaper app of mine — slowly twinkling stars — picked up this review: "The art is nice, but the day I set it, my battery started draining about twice as fast." Only a few hundred stars, nothing but a simple alpha fade. Yet on a real device the CPU kept working even with the screen off, and the phone stayed faintly warm.
Live wallpaper rendering has a habit of running even when nobody is looking. Every time you return to the home screen, switch apps, or lock the phone, the render thread keeps going unless you have designed it not to. As an indie developer, I rebuilt the render loop of my wallpaper app and cut battery use by around 60%. This is that design, and I leaned on an Antigravity agent to grind through the measurements.
Why live wallpapers really burn power
Most sample code drives draw() on a fixed interval with Handler.postDelayed. It looks fine while running, but from a power standpoint it has three holes.
The first is drawing without regard to visibility. If your loop keeps going after onVisibilityChanged(false), it renders even while another app fully covers the home screen. The second is redrawing frames whose content has not changed. When the stars come to a complete rest, or an animation loops back to an identical image, faithfully redrawing at 60fps still keeps the GPU and the display compositor busy. The third is ignoring thermal state: if the device throttles under heat but you keep requesting a high frame rate, the system drops clocks and the cost per frame actually rises — a vicious loop.
In other words, live wallpaper power is decided less by how complex the art is and more by when you decide to stop drawing. I started from that premise and rebuilt the engine so that the conditions for stopping are explicit.
Working backward from a power budget
Set the budget first. My rule of thumb: 60fps while the screen shows the wallpaper, zero drawing while static or idle, and an average added draw of no more than 30mAh per hour of screen-on time. On a 3,000–4,000mAh device that is roughly 1% per hour. Fix the ordering up front: if you cross that ceiling, you drop the frame rate even at the cost of the art.
To hold the budget, insert three gates into the render loop. A visibility gate (do not draw if not visible), an idle gate (do not draw if nothing changed), and a thermal gate (lower the frame rate when hot). All three only ever push in the direction of drawing less. By never adding a path that increases work, you prevent runaway draw structurally.
Gate
Signal
Effect
Visibility
onVisibilityChanged
Fully stop the render thread while hidden
Idle
Frame diff / input presence
Do not request the next frame if unchanged
Thermal
PowerManager thermal callbacks
Step down 60→30→10fps under heat
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A WallpaperService.Engine that stops drawing based on three axes: visibility, idle, and thermal state
✦Adaptive frame pacing that steps down 60→30→10fps, measured to cut power draw by up to 62%
✦A measurement pipeline that hands adb and dumpsys batterystats to an Antigravity agent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Here is the foundation. It uses Choreographer to align drawing with the display's vsync, and when visibility is lost it removes the callback so the render thread sleeps. The key move is dropping the fixed Handler delay.
class SkyWallpaperService : WallpaperService() { override fun onCreateEngine(): Engine = SkyEngine() inner class SkyEngine : Engine(), Choreographer.FrameCallback { private val choreographer = Choreographer.getInstance() private var visible = false private var scheduled = false private val renderer = SkyRenderer() // Visibility gate: only request frames while visible override fun onVisibilityChanged(v: Boolean) { visible = v if (v) requestFrame() else stop() } override fun onSurfaceDestroyed(holder: SurfaceHolder) { stop() super.onSurfaceDestroyed(holder) } private fun requestFrame() { if (visible && !scheduled) { scheduled = true choreographer.postFrameCallback(this) } } private fun stop() { scheduled = false choreographer.removeFrameCallback(this) } override fun doFrame(frameTimeNanos: Long) { scheduled = false if (!visible) return val dirty = renderer.advance(frameTimeNanos) // did anything change? if (dirty) drawOnce() // Idle gate: only chain the next frame while animating if (renderer.isAnimating) requestFrame() } private fun drawOnce() { val holder = surfaceHolder var canvas: Canvas? = null try { canvas = holder.lockHardwareCanvas() renderer.render(canvas) } finally { if (canvas != null) holder.unlockCanvasAndPost(canvas) } } }}
The heart of it is the tail of doFrame. The moment renderer.isAnimating becomes false, it does not request another frame. Once the stars settle into place, the render thread naturally goes to sleep. When a tap or a sensor kicks the animation back to life, that event handler calls requestFrame() to wake it. A stretch that a fixed loop would have spun through continuously drops to zero here.
Using lockHardwareCanvas is also deliberate. Its GPU path is shorter than the software canvas, and for simple alpha blends and gradients it lowered the per-frame cost in my tests.
Dropping the frame rate to fit the situation
Choreographer fires at the display's refresh rate by default. Drawing at that on a 90Hz or 120Hz panel is overkill for a wallpaper. So give it a skip factor — how many vsyncs to wait between actual draws — and switch that factor based on thermal state and battery level.
class FramePacer(private val power: PowerManager) { // Minimum nanoseconds to leave between draws, per target fps private var minIntervalNanos = nanosFor(60) private var lastDrawNanos = 0L fun onThermalStatus(status: Int) { val targetFps = when { status >= PowerManager.THERMAL_STATUS_SEVERE -> 10 status >= PowerManager.THERMAL_STATUS_MODERATE -> 30 else -> 60 } minIntervalNanos = nanosFor(targetFps) } fun onBatterySaver(enabled: Boolean) { if (enabled) minIntervalNanos = maxOf(minIntervalNanos, nanosFor(15)) } // May we actually draw on this frame? fun shouldDraw(frameTimeNanos: Long): Boolean { if (frameTimeNanos - lastDrawNanos < minIntervalNanos) return false lastDrawNanos = frameTimeNanos return true } private fun nanosFor(fps: Int): Long = 1_000_000_000L / fps}
Put FramePacer at the top of doFrame; on frames where shouldDraw returns false, skip both compositing and the canvas lock. Choreographer still wakes on every vsync, but most of those returns are just a check, so the cost is barely more than a single function call.
Subscribe to thermal state with PowerManager.addThermalStatusListener. Combined with battery-saver detection through a registerReceiver, you get a plain, honest behavior: the harder the device is working, the lower the frame rate drops automatically. What matters here is not adding logic that ramps the rate back up. Snapping straight back to 60fps the instant heat subsides makes the device oscillate. I let it recover in steps, only after thermal state has stayed at NONE for a while.
Measured: power draw by frame rate
On the same device (a Pixel 8a-class phone, fixed brightness, airplane mode), I carved the wallpaper process out of dumpsys batterystats and compared. The scene: 300 stars, alpha fade only.
Configuration
Actual fps
Per hour of screen-on
Reduction
Fixed loop (old)
60 fixed
~79mAh
baseline
Visibility gate only
60 (visible)
~61mAh
23% less
Visibility + idle
variable
~44mAh
44% less
All three gates + 30fps cap
30–10
~30mAh
62% less
The idle gate mattered most in practice. A wallpaper of slowly drifting stars looks "always moving" to the eye, but the number of frames that actually need to update each second is limited. That one-line decision — don't draw if there's no diff — shaved off more than twenty percent versus the fixed loop. Dropping 60fps to 30fps is almost imperceptible for this kind of gentle motion.
Handing the measurement loop to an Antigravity agent
This kind of measurement is a dull cycle: change a setting, run adb, wait tens of minutes, parse batterystats. I handed that cycle to an Antigravity agent. It feeds in builds with different frame-rate caps and star counts, measures, and tabulates — all non-interactively.
# Core of the measurement script I gave the agent (one condition)adb shell dumpsys batterystats --resetadb shell settings put system screen_off_timeout 3600000adb shell input keyevent KEYCODE_WAKEUP# Apply the wallpaper preview and leave it for a fixed durationadb shell am start -a android.service.wallpaper.CHANGE_LIVE_WALLPAPER \ --es android.service.wallpaper.extra.LIVE_WALLPAPER_COMPONENT \ "com.dolice.sky/.SkyWallpaperService"sleep 1800# Extract per-process draw and dump it outadb shell dumpsys batterystats --charged com.dolice.sky \ | grep -E "Uid .* com.dolice.sky|Foreground|Wake lock" > "run_${1}.txt"
I told the agent to measure each condition three times, take the median, and comment on the delta from the previous run. The parts where a human runs out of focus are exactly the ones suited to walk-away automation. One caution: I did not let the agent round the numbers with its own judgment. Keep the raw batterystats text as an artifact and delegate only the aggregation; verify the interpretation of the numbers with your own eyes.
What you can safely hand an Antigravity agent is deterministic, verifiable, repetitive work like this. The decision of which frame rate ships as the product default has to be weighed against reviews and your own sense of aesthetics, so I kept that as human work.
Pitfalls I hit in production
A few things that tripped me up along the way.
On some devices, calling lockCanvas after onVisibilityChanged(false) throws IllegalStateException. The last frame callback that arrives right after visibility is lost tries to draw onto an already-destroyed surface. The fix is simple: recheck visible at the top of doFrame and return immediately if false. That is the if (!visible) return in the code above.
On the preview screen (the wallpaper picker thumbnail), isPreview is true. Running the same high frame rate there as in production produced the odd behavior of the phone heating up just from opening the picker. Dropping to a fixed low frame rate in preview, or settling for a single still image, is the safer choice.
One more: forget to remove the Choreographer callback and it leaks — the reference outlives the destroyed Engine. Be sure to call removeFrameCallback in both onSurfaceDestroyed and onVisibilityChanged(false). I initially removed it only in onVisibilityChanged, which produced a double-registration bug on screen rotation.
Wrapping up
Live wallpaper power responds better to "add moments where you don't draw" than to making the art lighter. Insert three gates — visibility, idle, thermal — into the render loop, and let every one of them push only toward drawing less. After moving to this structure, my wallpaper app's battery use dropped by about 60% versus the fixed loop.
As a next step, try adding just the visibility and idle gates to your own Engine first. Compare before and after with dumpsys batterystats and you will see, in numbers, which gate helps your particular wallpaper. Hand the measurement grind to an agent, and keep the default-value decision for your own eyes. That division of labor has been, for me, the shortest path to a wallpaper that is easy on the battery and still beautiful.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.