実機を買う前に、クラウドの実機で測る — Firebase Test Lab で RTF・メモリ・発熱を採った
手元に、測りたい端末がない
いま、日本語の文章を読み上げる Android アプリを個人で作っています。音声合成をクラウドに投げず端末の中だけで動かす構成なので、速さがまるごと端末の性能に乗ります。同じアプリでも、速い端末なら読み上げが途切れず、遅い端末だと音が途中で止まる。
困ったのは、手元の開発機がハイエンド機だったことです。自分の端末では最初から快適に動いてしまう。一方、実際に使ってほしいのはミドルレンジの価格帯で、友人に配ったテスト版では「途切れ途切れになる」という報告が返ってきていました。友人の端末は Pixel 8a です。
つまり、議論の前提になる数字が、全部手元にない端末の数字でした。「Pixel 8a が遅すぎて追いつかないのか、それともこちらのバグなのか」が分からないまま、対策の議論だけが進んでしまう。
素直な解決策は買うことです。中古の Pixel 8a はフリマで 26,000〜30,000円くらい。出せない額ではありません。ただ、買ってから「そもそもアプリが起動しませんでした」となるのは避けたい。このアプリは自前でビルドしたネイティブライブラリ(.so)を積んでいて、それがちゃんとロードされるかどうかにも不安が残っていました。
そこで、買う前に潰せる問いを潰すために Firebase Test Lab を使いました。Google のデータセンターに置かれた実機(と仮想端末)にアプリを送り込んで走らせてくれるサービスです。
まず「課金され得ない」状態を作る
やりたいことより先に、こちらを片付けました。従量課金のサービスで一番怖いのは、寝ている間にテストが回り続けることです。
よくある対策は予算アラートですが、アラートは通知するだけで使用を止めてくれません。気づいたときには課金が済んでいる、という順番になります。なので通知ではなく、構造のほうで止めることにしました。
やったのは単純で、請求先アカウントを紐付けない専用プロジェクトを新しく作るだけです。請求先のないプロジェクト(Spark プラン)は、無料枠を超えたときに「課金」ではなく「実行拒否」になります。財布が繋がっていないので、原理的に請求が発生しません。無料枠は物理デバイス5回/日、仮想デバイス10回/日です。
その上で、実行用のシェルスクリプトに検査を入れました。
# 請求が有効なプロジェクトなら、既定で実行を止める
BILLING=$(gcloud billing projects describe "$PROJECT" \
--format='value(billingEnabled)')
if [ "$BILLING" = "True" ]; then
echo "ERROR: 請求が有効なプロジェクトです。中止します" >&2
exit 3
fi
これは書いた直後に実際に作動しました。gcloud の既定プロジェクトが別作業の課金ありプロジェクトになっていて、うっかりそのまま走らせるところだったんですね。ついでに、既定プロジェクトの設定は変えず、毎回 --project で明示指定する方針にしました。他の作業の既定を書き換えると、今度はそちらで事故ります。
あとは地味なところですが、
- 1回のテストで1デバイスだけ使う(マトリクスにすると時間が倍々になる)
--timeoutを必ず明示する(課金対象時間の上限になる)- 失敗時の自動リトライは付けない
- 実行前に「想定コスト」を画面に出して、目視で確認してから走らせる
このあたりを実行スクリプトに埋め込んでおきました。1回作っておけば、次からは何も考えずに叩けます。
測るものは、アプリの中に入れておく
Test Lab は「APK を渡して、向こうで走らせてもらう」形です。手元の端末に adb で繋いで後からあれこれ試す、というやり方ができません。測定手段をアプリの中に持たせておく必要があります。
そこで、アプリ自身に計測モードを仕込みました。ビルド時のフラグで有効になって、起動と同時に計測が走り、結果をログに吐いて終了する、というだけのものです。
# 5分間の計測ビルドを作る(起動と同時に走る)
flutter build apk --debug --target-platform android-arm64 \
--dart-define=NV_BENCH=300
作ってから気づいたのですが、これは端末が手に入るかどうかとは無関係に価値がありました。計測がアプリに内蔵されて端末非依存になると、計測経路を後から選べるようになります。
- 買った端末で回す
- クラウドの実機で回す
- テスターに「1回だけこれを起動して、出たログを送ってください」と頼む
このどれを選んでも同じ数字が出る。端末をどう調達するかを決める前に、先に測る仕組みを用意しておく順番で正解でした。
計測に使う文章はアプリに内蔵した16文だけにして、ネットワークにも外部サイトにも触らせていません。端末ごとに入力が変わると比較にならないですし、外部サイトを叩きに行くテストをデータセンターから何度も走らせるのも避けたかったからです。
Robo テストは90秒で勝手に終わる
ここが今回いちばんの落とし穴でした。
Test Lab で何も指定せずに使えるのが Robo テストで、これはアプリの UI を自動で巡回してくれるものです。まずこれで試したところ、タイムアウトを5分に指定しているのに、90秒ほどで完了して終わってしまいました。
理由は Robo の性質そのものでした。Robo は「UI を巡回し尽くしたら終わり」なので、こちらが裏で5分間の計測を回していようとお構いなしに切り上げます。起動直後に結果が出るような確認には十分ですが、数分かかる計測には使えません。
代わりに使ったのが game-loop テストです。名前の通りゲーム向けの仕組みで、こちらは「アプリが自分で終了するまで待つ」挙動をします。計測が終わったタイミングでアプリ側から finish() すれば、その瞬間までまるごと計測時間として使えます。
必要な準備は、マニフェストに intent-filter を1つ足すだけでした。
<intent-filter>
<action android:name="com.google.intent.action.TEST_LOOP"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/javascript"/>
</intent-filter>
この action で起動されない限り何も変わらないので、通常の利用者には影響しません。実行時は --type game-loop を指定します。
gcloud firebase test android run \
--type game-loop \
--app app-bench.apk \
--device model=akita,version=35,locale=ja_JP,orientation=portrait \
--timeout 15m \
--project myapp-testlab
model=akita が Pixel 8a です。デバイスの型番は gcloud firebase test android models list で一覧が取れます。
結果の取り出しでもうひと山
走らせたら結果を取りに行くわけですが、ここでも請求先なしプロジェクトの制約に当たりました。Cloud Storage のバケットを自分で作れないんですね。ふつうは結果の保存先バケットを指定するのですが、それができない。
ただ、Test Lab 自体は管理用のバケットに結果を置いてくれます。実行時のログに出る結果 URL、あるいは Testing API のレスポンスにある resultStorage.gcsPath を見れば保存先が分かるので、そこから直接コピーしてきます。
gcloud storage cp "gs://test-lab-xxxxxxxx/.../logcat" ./logcat
grep nvbench ./logcat # 自分の計測行だけ拾う
計測結果は独自のタグを付けて logcat に出しておくと、あとで grep 一発で拾えて楽でした。
採れた数字
Pixel 8a(API 35)で 420 秒ぶん連続で合成させた結果がこれです。126行を処理して失敗ゼロ。
| 指標 | 実測 | 意味 |
|---|---|---|
| RTF 平均 | 0.720(中央 0.726 / p90 0.839) | リアルタイム合成は成立している |
| エンジン準備 | 7891ms | 初回のモデル読み込み |
| 初回の1行 | 3154ms | 「再生」を押してから最初の音が出るまで |
| メモリピーク (PSS) | 969MB | 8GB 機なら余裕。6GB 機は厳しい |
| 発熱ドリフト | 1.195 | 後半が前半より19.5%遅い |
RTF(Real-Time Factor)は、1秒ぶんの音声を作るのに何秒かかるかの比です。0.72 なら1秒の音声を 0.72 秒で作れているので、再生しながら作り続けても追いつきます。1.0 を超えると再生が待たされます。
つまり、「Pixel 8a が遅すぎて追いつかない」という説はここで否定されました。友人の端末で途切れていたのは端末性能の問題ではなく、こちら側のバグ(読み上げ設定を切り替えたときに、作り溜めた音声を捨ててしまっていた)が主因だったと判断できました。3万円払う前に分かったのはありがたかったです。
いちばん意外だったのは、ラックの中でも熱で遅くなったこと
表の最後に置いた発熱ドリフトが、今回いちばんの収穫でした。7分の連続処理で、後半が前半より19.5%遅くなっています。
測る前は「データセンターのラックに置かれた端末なんだから、空調が効いていて熱の影響は出ないだろう。だから熱の話はクラウド実機では検証できない」と思い込んでいました。冷えているのは部屋であって、SoC の中ではないんですよね。負荷をかけ続ければ普通に熱くなって、普通にクロックが落ちる。
この数字が出たおかげで、それまで「速くする」方向で考えていた最適化の議論が、「熱で落ちる前提で、冷えているうちに作り溜める」方向に切り替わりました。この見立ての転換は、端末を買っていたら1週間くらい遅れていたはずです。
さらに、物理デバイスは1回45分まで使えるので、30分の連続稼働テストも無料枠のまま回せました。こちらは倍速再生(2.0倍速)で走らせたのですが、結果は明快でした。
[nvbench] soak 51s lines=20 在庫=3.6s 途切れ=0回
[nvbench] soak 306s lines=100 在庫=-40.7s 途切れ=0回
[nvbench] soak 931s lines=300 在庫=-139.6s 途切れ=0回
[nvbench] soak 1748s lines=560 在庫=-272.3s 途切れ=0回
[nvbench] ===== soak 結果 (話速 2.0x・1801s) =====
[nvbench] 合成行数=577 生成音声=1519s
[nvbench] **途切れ=1回 合計1736.3s**
「在庫」は作り溜めた音声の残り秒数です。序盤で 3.6 秒あった余裕が、以降ずっとマイナスのまま戻ってこない。途切れの回数は1回ですが、実態は「一度途切れたら最後まで在庫が戻らなかった」 で、その状態が経過時間の96%を占めました。倍速再生はこの端末では成立しない、と実測で確定できたわけです。
こういう「長く回さないと出てこない結論」まで、端末なしで取れたのは収穫でした。
クラウド実機の守備範囲
一通りやってみて、向き不向きがはっきりしました。
向いていること
- 起動するかどうかの確認。これが一番大きいです。今回は「16KB ページサイズの端末」でアプリが動くかを確かめました。Android 15 以降、メモリのページサイズが 16KB の端末が出てきています。ネイティブライブラリがこの前提でアライン(整列)されていないと、互換モードで無理やり動く場合はあるものの、起動時に警告が出るうえ安定性は保証されません。しかも Google Play は 2025年11月1日から、Android 15 以上をターゲットにする新規アプリと更新に 16KB 対応を求めています。手持ちの端末は全部 4KB ページなので、対応できたかどうかを手元で確認する方法が原理的にない。Test Lab の仮想端末(API 36・16KB ページ)で、自前の
.soが読み込めていることを確認できました。 - 相対比較と回帰検知。同じ端末・同じ入力で繰り返せるので、「先週より遅くなっていないか」の判定に使えます。
- 手持ちの端末構成では作れない条件。上の 16KB がまさにこれで、これができるだけでも接続する価値がありました。
向いていないこと
- 絶対値をもって「実用に足る」と判断すること。ラックの中とポケットの中では熱環境が違います。ポケットや手のひらの中はもっと厳しいはずで、クラウドで出た数字はあくまで下駄を履いた値だと考えたほうが安全です。
なので結論としては、Pixel 8a は結局買います。ただ、「買ってから確かめるつもりだったことの半分くらいは、買う前に終わった」という状態にはできました。端末を買う判断も「実機で動くらしい」と分かった上でのものになるので、心理的にもだいぶ楽です。
まとめ
端末が足りないとき、選択肢は「買う」か「諦める」の二択に見えがちですが、間に「買う前にどこまで削れるか」という工程を挟めます。
- 課金は通知ではなく構造で止める(請求先を紐付けないプロジェクト+実行前チェック)
- 測定手段はアプリに内蔵しておくと、計測経路を後から選べる
- 数分以上の計測に Robo テストは使えない。game-loop なら完走できる
- ラックの中の端末も熱で遅くなる。「クラウドだから熱は測れない」は思い込みだった
個人開発だと検証環境にお金をかけづらいのですが、無料枠の中でここまで採れるなら、端末購入の前に一度通しておく価値はあると思います。少なくとも、買った端末が届いた初日に「起動しませんでした」で終わる展開は避けられます。
参照リンク
- Firebase Test Lab — 公式ドキュメント
- 使用量、割り当て、料金 — 無料枠と従量課金の条件
- Android ゲームでの game-loop テスト — intent-filter の要件と実行方法
- Robo テスト — UI 自動巡回の仕様
- gcloud firebase test android run —
--timeoutの指定と上限(物理45分・仮想60分) - 16 KB ページサイズのサポート — Android 15 以降の要件