自分で作ったアプリ「イラスト英熟語」の、自分で作った演出が出ない。
しかも以前は出ていた気がする。そんなときほど原因が見つからないものはありません。今回は、犯人がまさかのバッテリー設定だったという話です。
ファンファーレは鳴った。でも紙吹雪が舞わない
先日、飛行機の乗り換えの待ち時間を持て余して、自分の作った英熟語アプリを開いて解いていたんです。
そしてついに、全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問すべて正解された方は、ぜひ最後のお祝い画面を見てみてください。今度はちゃんと、派手に舞います。




