親切な最適化が、機能を丸ごと殺していた話ー省電力の罠

自分で作ったアプリ「イラスト英熟語」の、自分で作った演出が出ない。

しかも以前は出ていた気がする。そんなときほど原因が見つからないものはありません。今回は、犯人がまさかのバッテリー設定だったという話です。

目次

ファンファーレは鳴った。でも紙吹雪が舞わない

先日、飛行機の乗り換えの待ち時間を持て余して、自分の作った英熟語アプリを開いて解いていたんです。

そしてついに、全500問を正解しました。

派手なファンファーレが鳴りました。トロフィーも出ました。

でも
あれ!?

紙吹雪がひとつも舞わない・・・。

スクショ:紙吹雪が出ていない状態のお祝い画面

シンプルな画面のまま演出が終わってしまいました。おかしい。審査に出す前に確認したときは、たしかに紙吹雪が舞っていたはずなのに。

「以前は出ていた」が一番やっかい

不具合調査でいちばん厄介なのが、この「以前は動いていた」パターンです。

コードを見返しても、紙吹雪まわりは何も変えていません。190問クリアの画面も、500問クリアの画面も、同じ処理を呼んでいます。違うのは紙吹雪の量と表示時間だけ。

自宅に戻って同じ操作をしてみると、今度はちゃんと舞います。再現しない。

こうなると、もうコードの表面を眺めていても答えは出ません。使っている紙吹雪ライブラリ(Flutter の confetti パッケージ)の中身を読むことにしました。

ライブラリの中に、こんな一行がありました

紙吹雪の粒を生成している処理の中に、こんなコードがありました。

dart

void update(double deltaTime, {bool pauseEmission = false}) {

  // ...(既存の粒子を動かす処理)...

  // ↓ ここ
  if (pauseEmission) return;

  if (_particleSystemStatus == ParticleSystemStatus.started) {
    // 粒子が0個なら即座に生成する(初回はかならず出す、という保証)
    if (particles.isEmpty) {
      _addParticles(_particles, number: _numberOfParticles);
      return;
    }
    // ...
  }
}

pauseEmission が true だと、その場で return して粒をひとつも作りません。

では pauseEmission はいつ true になるのか。呼び出し元を追うと、こうでした。

dart

const double kLowLimit = 1 / 60;   // ← 約 16.667 ミリ秒

// 1フレームごとに呼ばれる処理
final deltaTime = (currentTime - lastTime) / 1000;

if (deltaTime > kLowLimit) {
  // フレームレートが低いので粒子の生成を止める
  _particleSystem.update(kLowLimit, pauseEmission: widget.pauseEmissionOnLowFrameRate);
} else {
  _particleSystem.update(deltaTime);
}

pauseEmissionOnLowFrameRate というオプションで、既定値は true。

つまりこのライブラリは、「フレームレートが落ちているときに紙吹雪を増やすと、カクついて動きが悪くなってしまう。だから重いときは粒を作らないでおこう」という気づかいをしてくれているわけです。
設計思想としては、とても親切で真っ当です。

しきい値が、ちょうど60fps

問題はしきい値でした。1 / 60 秒、つまり 約16.667ミリ秒です。

60Hz の画面を持つ端末では、1フレームにかかる時間はちょうど約16.7ミリ秒。

16.7 ミリ秒 > 16.667 ミリ秒 → true

ほぼ全フレームが「フレームレートが低い」と判定されます。

しかも先ほどのコードをもう一度見てください。「粒子が0個なら即座に生成する」という初回保証の分岐は、if (pauseEmission) return; の後ろにあります。つまりその保証すら素通りされる。

結果、紙吹雪は「少なめに出る」のではなく、1粒も出ないまま演出が終わる。私が見た画面そのものです。

犯人は低電力モードでした

ここまで分かれば、あとは簡単でした。

私の iPad は ProMotion 対応で、通常は120Hz で動きます。1フレーム約8.3ミリ秒なので、しきい値には引っかかりません。だから自宅では紙吹雪が舞う。

では飛行機の中では何が違ったか。

iOS の低電力モードは、ProMotion のリフレッシュレートを60Hz に固定します。バッテリーを持たせるための、これまた真っ当な仕様です。

120Hz → 60Hz。1フレーム 8.3ミリ秒 → 16.7ミリ秒。しきい値を越える。粒子が作られない。紙吹雪が舞わない。

きれいに一本の線でつながりました。

そしてこの理屈でいくと、もうひとつ気づくことがあります。60Hz の Android 端末では、そもそも一度も紙吹雪が出ていなかった可能性が高い。 私が「動いている」と思っていたのは、たまたま手元の検証端末が120Hz だったからでした。

直し方は一行

dart

ConfettiWidget(
  // ...
  pauseEmissionOnLowFrameRate: false,
)

明示的に false にするだけです。フレームレートに関係なく、確実に舞うようになりました。

せっかくなので、全500問クリアの演出も強化しました。これまで画面上部の中央1か所から出していた紙吹雪を、左寄り・中央・右寄りの3か所に増やしています。iPad のような横に広い画面でも、全体に降り注ぐようになりました。1か所あたりの粒の数は減らしているので、描画の負荷は以前と同じくらいです。

スクショ:修正後の全500問クリアのお祝い画面(紙吹雪あり)

おまけ:500問解かずに確認する方法

今回いちばん困ったのは、じつは確認作業でした。

「全500問クリアの演出を直しました」と言っても、確かめるには500問解き直さなければいけません。さすがに現実的ではない。

そこで、ホーム画面いちばん下のバージョン番号を長押しすると、お祝い画面をプレビューできるようにしました。190問クリアと全500問クリア、どちらも1問も解かずに表示できます。学習の進捗には一切触れません。

開発用の隠し機能ですが、ブログに載せるスクショを撮るときにも重宝しています。

今回の学び

1. 親切な最適化が、機能を丸ごと殺すことがある

「重いときは控えめにする」は良い設計です。でも、しきい値の引き方ひとつで「控えめ」が「ゼロ」になってしまう。しかも例外なく、静かに。

2. 自分の検証端末は、世界の標準ではない

120Hz の端末で動いていたから大丈夫、ではありませんでした。むしろ60Hz のほうが世の中には多いはずです。

3. 省電力モードは、テスト条件のひとつになりうる

これは今回いちばんの収穫でした。アニメーションや演出を含むアプリを作っているなら、低電力モードをオンにした状態でのテストを一度やっておく価値があります。リフレッシュレートが変わるだけで挙動が変わるものは、思っている以上にありそうです。

4. 分からないときは、ライブラリの中を読む

今回の答えは、自分のコードには一行も書かれていませんでした。使っているパッケージのソースを開いて、初めて見えました。


飛行機に乗るたびに何か見つかりますね。前回は「解けた問題がまた出てくる」という不便に気づいて出題ロジックを直しました。今回は紙吹雪です。

修正版はストアに提出済みです。500問すべて正解された方は、ぜひ最後のお祝い画面を見てみてください。今度はちゃんと、派手に舞います。

目次