新しい端末に壁紙アプリを入れ直してタップした最初の一回だけ、ホームのグリッドが出るまでに半呼吸の間があく。二回目以降は引っかかりません。リリースビルドでだけ、しかも「入れたて」のときだけ重い、という症状に半年ほど薄く悩まされていました。
個人開発で同じアプリを長く運営していると、自分の端末ではプロファイルが育ちきっているので、この遅さに気づけません。気づいたのは、レビューに「最初だけ重い」と書かれていたのを見たときでした。原因は JIT と Cloud Profile の届くタイミングにあり、Baseline Profile を入れることで実測で縮められました。その手順を、測る土台づくりから順に残しておきます。
なぜ「入れたて」のときだけ遅いのか
Android アプリのコードは、インストール直後の段階ではほとんどが解釈実行か JIT(実行時コンパイル)で動いています。よく通る経路は使われるうちに ART が少しずつ AOT 化していくので、使い込んだ端末では起動が速くなります。これが「二回目以降は引っかからない」正体です。
Google Play には Cloud Profiles という仕組みがあり、多くのユーザーの実行プロファイルを集約して、新規インストール時にある程度ウォームな状態を配ってくれます。ただしこれは、リリースから時間が経って母数が集まるまで効きません。リリース直後の新規インストールと、アプリ更新直後(プロファイルがリセットされた状態)では、この恩恵が薄いのです。
Baseline Profile は、起動とよく使う画面の「ホットパス」を開発側であらかじめ列挙し、APK / AAB に同梱して、インストール時に AOT コンパイルさせる仕組みです。Cloud Profiles が育つのを待たずに、最初の一回目から温まった状態を配れます。私の症状はまさにこの「最初だけ冷えている」状態だったので、噛み合いました。
先に「測れる土台」を作る — Macrobenchmark モジュール
最適化に手をつける前に、まず数値で再現できる状態を作ります。これをやらずにプロファイルだけ入れると、効いたのか気のせいなのか永久に分かりません。私自身、最初に Baseline Profile を入れたときは体感でしか語れず、説得力のないメモになってしまいました。
計測には Macrobenchmark を使います。アプリ本体とは別に、計測専用の com.android.test モジュールを足します。
// :macrobenchmark モジュールの build.gradle.kts
plugins {
id ( "com.android.test" )
id ( "org.jetbrains.kotlin.android" )
}
android {
namespace = "com.example.wallpaper.macrobenchmark"
compileSdk = 35
defaultConfig {
minSdk = 24
targetSdk = 35
// 物理端末で実行する。テスト対象はリリースに近い構成
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
// 計測ビルドは難読化済みのリリース寄りを使う
experimentalProperties[ "android.experimental.self-instrumenting" ] = true
}
dependencies {
implementation ( "androidx.test.ext:junit:1.2.1" )
implementation ( "androidx.test.uiautomator:uiautomator:2.3.0" )
implementation ( "androidx.benchmark:benchmark-macro-junit4:1.3.3" )
}
計測対象のアプリ側は、ベンチマークから起動・計測できるよう profileable を有効にします。デバッグビルドではなく、リリースに近い benchmark ビルドタイプを切る方が現実に即した数字になります。
<!-- app/src/main/AndroidManifest.xml -->
< application ...>
< profileable
android:shell = "true"
tools:targetApi = "29" />
</ application >
起動時間そのものを測るテストはこう書きます。StartupTimingMetric が、タップから最初の描画までの時間(Time To Initial Display)を中央値つきで出してくれます。
// StartupBenchmark.kt
@RunWith (AndroidJUnit4:: class )
class StartupBenchmark {
@get : Rule val rule = MacrobenchmarkRule ()
private fun measure (mode: CompilationMode ) = rule. measureRepeated (
packageName = "com.example.wallpaper" ,
metrics = listOf ( StartupTimingMetric ()),
iterations = 15 , // 分散を見たいので回数は多めに
startupMode = StartupMode.COLD,
compilationMode = mode,
) {
pressHome ()
startActivityAndWait ()
}
// プロファイルなしの素の状態(最悪値の基準)
@Test fun coldStartupNone () = measure (CompilationMode. None ())
// Baseline Profile を必須として使った状態
@Test fun coldStartupBaselineProfile () =
measure (CompilationMode. Partial (BaselineProfileMode.Require))
}
iterations を 15 にしているのは、コールドスタートは端末の状態に左右されて分散が大きいからです。1〜2 回では中央値が安定せず、改善幅を見誤ります。
CompilationMode で Before / After を比較する
ここが今回の肝です。CompilationMode.None() はプロファイルを一切使わない最悪基準、CompilationMode.Partial(BaselineProfileMode.Require) は Baseline Profile を「必須」として組み込んだ状態を表します。同じテストを二つのモードで回せば、プロファイルが効いた差分だけを切り出せます。
Require を指定しているのは意図的です。BaselineProfileMode.Enable(既定)だと、プロファイルが無くても警告なしで素通りしてしまい、「組み込めていないのに測れてしまう」事故が起きます。計測時は Require にして、プロファイルが本当に適用された状態だけを測るのが安全です。私はここで一度、プロファイル未適用のまま「効果なし」と誤判定して半日溶かしました。
実行は Android Studio のガターからでも、コマンドラインからでも構いません。CI に載せるならコマンドの方が扱いやすいです。
./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
-P android.testInstrumentationRunnerArguments.class=com.example.wallpaper.macrobenchmark.StartupBenchmark
Baseline Profile を生成する
測れる土台ができたら、プロファイル本体を生成します。生成も計測と同じく専用モジュール(com.android.test)で行い、androidx.baselineprofile プラグインに任せます。
// :baselineprofile モジュールの build.gradle.kts
plugins {
id ( "com.android.test" )
id ( "org.jetbrains.kotlin.android" )
id ( "androidx.baselineprofile" )
}
dependencies {
implementation ( "androidx.test.ext:junit:1.2.1" )
implementation ( "androidx.test.uiautomator:uiautomator:2.3.0" )
implementation ( "androidx.benchmark:benchmark-macro-junit4:1.3.3" )
}
生成器は、実際にユーザーがたどる経路を再現します。起動して、グリッドを少しスクロールして、詳細を開く——よく使う導線を素直になぞるのがコツです。ここで列挙した経路がそのまま AOT コンパイルの対象になります。
// BaselineProfileGenerator.kt
@RunWith (AndroidJUnit4:: class )
class BaselineProfileGenerator {
@get : Rule val rule = BaselineProfileRule ()
@Test fun generate () = rule. collect (
packageName = "com.example.wallpaper" ,
// 起動時のクラスは dex レイアウトも最適化させる
includeInStartupProfile = true ,
) {
pressHome ()
startActivityAndWait ()
// 実際の主要導線を再現する
val grid = device. findObject (By. res (packageName, "wallpaper_grid" ))
grid. fling (Direction.DOWN)
grid. fling (Direction.UP)
device. findObject (By. res (packageName, "thumbnail_0" )). click ()
device. wait (Until. hasObject (By. res (packageName, "detail_image" )), 5_000 )
}
}
includeInStartupProfile = true を付けると、起動経路のクラスを dex の先頭に寄せる Startup Profile も同時に作られます。これは AOT とは別軸の最適化で、起動時に読むクラスのディスク上の局所性を上げます。地味ですが、低スペック端末ほど効きます。
生成コマンドはこれです。完了すると、リリースのソースセットにテキスト化されたプロファイルが書き出されます。
./gradlew :app:generateReleaseBaselineProfile
# 出力例: app/src/release/generated/baselineProfiles/baseline-prof.txt
生成したプロファイルをアプリに組み込む
アプリ側には二つだけ追加します。ひとつは androidx.profileinstaller。これがインストール時にプロファイルを ART に渡す役目を負います。Jetpack の多くのライブラリが推移的に引き込みますが、明示しておく方が安心です。
// app/build.gradle.kts
plugins {
id ( "com.android.application" )
id ( "org.jetbrains.kotlin.android" )
id ( "androidx.baselineprofile" )
}
dependencies {
implementation ( "androidx.profileinstaller:profileinstaller:1.4.1" )
baselineProfile ( project ( ":baselineprofile" ))
}
baselineProfile {
// 生成物を release ソースセットに自動マージする
automaticGenerationDuringBuild = false
}
automaticGenerationDuringBuild を false にしているのは個人開発ならではの判断です。毎ビルドでプロファイルを再生成すると、物理端末が必須になり CI もビルドも重くなります。私は「リリース前にだけ手動で再生成して、生成物をコミットする」運用にしています。生成物 baseline-prof.txt を Git に入れておけば、再生成しないビルドでも常に最新のプロファイルが同梱されます。
ここまでできたら、リリースの AAB をビルドして、assets/dexopt/baseline.prof が含まれているかを確認します。これが入っていなければ、何を測っても効果は出ません。
./gradlew :app:bundleRelease
unzip -l app/build/outputs/bundle/release/app-release.aab | grep baseline.prof
# base/assets/dexopt/baseline.prof と baseline.profm が見えれば組み込み成功
実測値 — 私の壁紙アプリでどれだけ縮んだか
個人で運営している壁紙アプリのうち、起動時にグリッドを組み立てるものを対象に、Pixel 6a 実機でコールドスタートを 15 回ずつ測りました。数字は Time To Initial Display の中央値(P50)と、ばらつきを見るための P90 です。
計測条件 P50 (ms) P90 (ms) 素の状態比
CompilationMode.None(プロファイルなし) 724 918 基準
Baseline Profile 適用(Require) 503 612 約 30% 短縮
使い込んだ端末(Cloud Profile 成熟後) 486 574 参考値
注目したいのは、Baseline Profile を入れた「入れたて」の数字(503ms)が、使い込んでプロファイルが育った端末(486ms)にほぼ並んだ点です。つまり Baseline Profile は、Cloud Profile が時間をかけて到達する状態を、インストールの初回から先取りしているわけです。P90 の縮みも大きく、最悪値が落ち着いたのは、レビューに書かれた「最初だけ重い」に直接効く改善でした。
数字の出し方で一つ注意点があります。StartupTimingMetric の TTID は「最初のフレームが出るまで」なので、画像の読み込みが終わって本当に使える状態になるまでを測りたい場合は、画面側で reportFullyDrawn() を呼び、Time To Full Display も併せて見てください。私は壁紙のサムネイル読み込みが重い画面だけ TTFD を主指標にしています。
つまずいた点と、個人開発で続けるための回し方
最初に詰まったのは、プロファイルが「入っているのに効かない」ように見える現象でした。原因は計測を CompilationMode.Partial(BaselineProfileMode.Enable) で回していたことで、プロファイル未適用でも素通りしていただけでした。計測時は必ず Require にする、という一点を守れば回避できます。
二つ目は、プロファイルの鮮度です。画面構成を大きく変えたあとに生成し直さないと、古いホットパスのまま最適化され、肝心の新しい経路が冷えたままになります。私は壁紙の詳細画面を作り替えたときにこれを忘れ、改善したはずの起動がまた重く感じる、という遠回りをしました。リリース前のチェックリストに「Baseline Profile 再生成」を一行足してからは安定しています。
ロールアウトは、起動指標を壊さないか確かめながら段階的に進めます。私はいつも 5% から始めて、Play Console のスタートアップ時間・ANR 率に加え、本番での AdMob の表示が崩れていないかも見ながら 25% → 50% → 100% と広げます。プロファイル由来の不具合は稀ですが、profileinstaller のインストール失敗がごく一部の端末で起きることがあり、段階公開ならそこで止められます。同じ Android のリリース最適化では、難読化まわりでリリースだけ落ちる R8 と Gson の調べ方 も併せて踏むと、リリース固有の罠を一通り押さえられます。多言語対応を進めているなら端末設定と切り離したアプリ内言語切り替え も、起動経路に絡むので生成器の導線に含めておくとよいです。
まず試すなら、計測モジュールを一つ足して CompilationMode.None と Partial(Require) を 15 回ずつ回し、自分のアプリの P50 がどれだけ離れているかを見てください。その差が、Baseline Profile で取り戻せる伸びしろです。お読みいただきありがとうございました。