星がゆっくり瞬く壁紙アプリに、ある日こんなレビューがつきました。「絵はきれいだけど、設定した日から電池の減りが倍くらいになった」。星の数はわずか数百、描画も単純なアルファ変化だけ。それでも実機で確認すると、画面を消していても CPU が動き続け、端末はほんのり温かいままでした。星がきれいでも電池を削れば評価は下がり、評価が下がれば AdMob の表示機会も細ります。壁紙アプリにとって電池は、絵の美しさと同じ重さの品質です。
ライブ壁紙の描画は「見えていないときも回り続ける」性質を持っています。ホーム画面に戻るたび、アプリを切り替えるたび、画面がロックされるたびに、描画スレッドがどう振る舞うかを設計していないと、静かに電力を垂れ流します。ここでは Google Play で配信している個人開発の壁紙アプリで、実際に電池消費を約6割削った、WallpaperService の描画ループの設計をまとめます。計測の反復には Antigravity のエージェントを使いました。
ライブ壁紙が電力を食う本当の理由
多くのサンプルコードは、Engine の中で Handler.postDelayed を使い、固定間隔で draw() を呼び続けます。これは動いている間は問題なく見えますが、電力の観点では3つの穴があります。
ひとつめは、可視性を無視した描画です。onVisibilityChanged(false) が来てもループを止めない実装だと、ホーム画面が別アプリに覆われている間も描画が続きます。ふたつめは、内容が変わらないフレームまで描き直すことです。星が完全に静止する瞬間や、アニメーションが1周して同じ画になる区間でも、律儀に60fpsで再描画すれば、その分だけ GPU とディスプレイのコンポジタが働きます。みっつめは、端末が発熱してサーマルスロットリングに入っても、要求フレームレートを下げないことです。熱くなった端末で高フレームレートを要求し続けると、システムがクロックを落とし、かえって1フレームあたりのコストが上がる悪循環に入ります。
つまりライブ壁紙の電力は、絵の複雑さよりも「いつ描くのをやめるか」の設計でほぼ決まります。私はこの前提に立って、描画を止める条件を明示的に持たせる方向へ作り直しました。
描画ループを「電力予算」から逆算する
先に予算を決めます。私の目安は「画面表示中で 60fps、静止時とアイドル時はゼロ描画、平均して画面点灯1時間あたりの追加消費を 30mAh 以内」に置きました。3,000〜4,000mAh クラスの端末で、時間あたり 1% 前後に相当します。この上限を超えたら、絵を諦めてでもフレームレートを落とす、という順序を最初に固定しておきます。
予算を守るために、描画ループへ次の3つのゲートを差し込みます。可視性ゲート(見えていなければ描かない)、アイドルゲート(内容が変わらなければ描かない)、熱ゲート(熱ければフレームレートを落とす)。この3つはいずれも「描画を減らす」方向にしか働きません。増やす判断は入れないことで、暴走を構造的に防ぎます。
ゲート 判定材料 効果
可視性 onVisibilityChanged 非表示中は描画スレッドを完全停止
アイドル フレーム差分・入力の有無 変化がなければ次フレームを要求しない
熱状態 PowerManager のサーマル通知 60→30→10fps へ段階的にダウンシフト
可視性とアイドルを軸にした Engine の実装
まず土台となる Engine です。Choreographer を使ってディスプレイの垂直同期に描画を合わせ、可視性が失われたらコールバックを外して描画スレッドを眠らせます。Handler の固定ディレイをやめる点がポイントです。
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 ()
// 可視性ゲート: 見えているときだけフレームを要求する
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) // 変化があったか
if (dirty) drawOnce ()
// アイドルゲート: 変化がある間だけ次フレームを繋ぐ
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)
}
}
}
}
肝は doFrame の末尾です。renderer.isAnimating が false になった瞬間、次フレームを要求しません。星が定位置に落ち着けば、描画スレッドは自然に眠ります。タップやセンサーで再びアニメーションが始まったら、そのイベントハンドラから requestFrame() を呼んで復帰させます。固定ループなら常時回り続けていた区間が、ここでまるごとゼロになります。
lockHardwareCanvas を使っている点も意図的です。ソフトウェアキャンバスより GPU 経路が短く、単純なアルファ合成やグラデーションでは1フレームあたりのコストを抑えられました。
フレームレートを状況で落とす — 適応レート制御
Choreographer は基本的にディスプレイのリフレッシュレートで発火します。90Hz や 120Hz のパネルでそのまま描くと、壁紙には過剰です。そこで「何フレームに1回だけ実描画するか」というスキップ係数を持たせ、熱状態とバッテリー残量で係数を切り替えます。
class FramePacer ( private val power: PowerManager ) {
// 目標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 ))
}
// このフレームで実描画してよいか
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
}
FramePacer を doFrame の先頭に挟み、shouldDraw が false のフレームでは合成もキャンバスロックもスキップします。Choreographer 自体は毎 vsync で目覚めますが、その大半は判定だけで即座に戻るため、コストはほぼ関数呼び出し1回です。
熱状態の購読は PowerManager.addThermalStatusListener で行います。registerReceiver によるバッテリーセーバー検知と組み合わせると、端末が苦しくなるほど自動でフレームレートが下がる、という素直な挙動になります。ここで大事なのは、レートを「上げ直す」ロジックを入れないことです。熱が引いても即座に60fpsへ戻すと、上下動でかえって発熱が安定しません。私は熱が NONE に戻ってから一定時間の余裕を置いて段階的に戻す方式にしました。急な復帰は上下動を招くため、余裕を持った復帰を推奨します。
実測:フレームレート別の消費電力
同一端末(Pixel 8a 相当、画面輝度固定、機内モード)で、dumpsys batterystats から壁紙プロセスの消費を切り出して比較しました。星300個・アルファ変化のみという条件です。
構成 実描画fps 画面点灯1時間あたり 削減率
固定ループ(旧) 60固定 約 79mAh 基準
可視性ゲートのみ 60(表示時) 約 61mAh 23%減
可視性+アイドル 可変 約 44mAh 44%減
3ゲート全部+30fps上限 30〜10 約 30mAh 62%減
体感でいちばん効いたのはアイドルゲートでした。星がゆっくり動く壁紙は、人間の目には「常に動いている」ように見えても、実際には1秒間に画が更新される必要のあるフレームは限られています。差分がなければ描かない、という一行の判断が、固定ループ比で二割以上を削りました。60fpsを30fpsへ落としても、この種のゆったりした動きでは違いにほとんど気づけません。
Antigravity エージェントに電力計測ループを任せる
この手の計測は、設定を変えて adb を叩き、数十分放置し、batterystats を解析する、という退屈な反復です。私はこの反復を Antigravity のエージェントに任せました。フレームレート上限や星の数を変えたビルドを流し込み、計測して表にする、という一連を非対話で回します。
# エージェントに渡した計測スクリプトの中核(1条件ぶん)
adb shell dumpsys batterystats --reset
adb shell settings put system screen_off_timeout 3600000
adb shell input keyevent KEYCODE_WAKEUP
# 壁紙をプレビュー適用して所定時間放置
adb shell am start -a android.service.wallpaper.CHANGE_LIVE_WALLPAPER \
--es android.service.wallpaper.extra.LIVE_WALLPAPER_COMPONENT \
"com.dolice.sky/.SkyWallpaperService"
sleep 1800
# プロセス別の消費を抽出してJSON化
adb shell dumpsys batterystats --charged com.dolice.sky \
| grep -E "Uid .* com.dolice.sky|Foreground|Wake lock" > "run_ ${1} .txt"
エージェント側には「各条件を3回ずつ計測し、中央値をとって前回との差分をコメントする」という指示を与えました。人間がやると集中力が持たない部分こそ、放置運用に向いています。ここで気をつけたのは、計測結果をエージェントの主観で丸めさせないことです。生の batterystats テキストを成果物として残し、集計だけを任せる。数値の解釈は自分の目で確認する、という線引きにしています。
Antigravity のエージェントに任せてよいのは、こうした決定的で検証可能な反復作業までです。「どのフレームレートを製品のデフォルトにするか」という判断は、レビューの声や自分の美意識と噛み合わせる必要があり、そこは人間の仕事として手元に残しました。
本番で踏んだ落とし穴
いくつか、実装中につまずいた点を残しておきます。
onVisibilityChanged(false) の後に lockCanvas を呼ぶと、IllegalStateException になる端末がありました。可視性を失った直後に飛んでくる最後のフレームコールバックが、破棄済みのサーフェスへ描こうとするためです。対処は単純で、doFrame の冒頭で visible を再確認し、偽なら即 return する。上のコードの if (!visible) return がそれです。
プレビュー画面(壁紙選択のサムネイル)では、isPreview が true になります。ここで本番と同じ高フレームレートを回すと、選択画面を開いただけで発熱するという妙な挙動になりました。プレビューでは固定の低フレームレートに落とすか、静止画1枚で済ませるのが無難です。
もうひとつ、Choreographer のコールバックを外し忘れると、Engine が破棄されても参照が残り、リークします。onSurfaceDestroyed と onVisibilityChanged(false) の両方で確実に removeFrameCallback を呼ぶこと。私は当初 onVisibilityChanged だけで外していて、画面回転時にコールバックが二重登録される不具合を出しました。
まとめ
ライブ壁紙の電力は、絵を軽くするより「描かない瞬間を増やす」ほうが効きます。可視性・アイドル・熱状態の3つのゲートを描画ループへ差し込み、いずれも描画を減らす方向にしか働かせない。この構造にしてから、私の壁紙アプリでは電池消費を固定ループ比で約6割削れました。
次の一歩として、まずは手元の Engine に可視性ゲートとアイドルゲートだけでも入れてみてください。dumpsys batterystats で前後を比べると、どのゲートが自分の壁紙に効くかが数値で見えてきます。計測の反復はエージェントに任せ、デフォルト値の決定は自分の目で。その分担が、電池に優しく、それでいて美しい壁紙への近道だと感じています。実装の助けになれば幸いです。お読みいただきありがとうございました。