🚀

アプリ起動時間を40%短縮したプロセス(Cold Start最適化事例)

に公開

本記事では、フィンテックアプリの起動時間を40%改善した実例をもとに、
どのように問題を分析し、どのような最適化を行ったのかを紹介します。

この記事でわかること

  • TTI(Time To Interaction)指標を設計し、起動シーケンス全体を可視化する方法
  • TTI計測をもとに、どの処理がボトルネックかを特定するプロセス
  • 起動時に同期的に実行されていた処理を、非同期・並列化して最適化した手法
  • Cold Start直後に不要な初期化処理を、Lazy Initializationで後ろへ移動して最適化した方法

背景:なぜ起動が遅かったのか

フィンテックアプリの特性上、アプリ起動時にさまざまな初期化処理やセキュリティチェックが走ります。

  • 各種状態チェックAPI
  • アプリ整合性チェック(Integrity Checker)
  • 実験データ取得(Device Experiment / User Experiment)

そのためスプラッシュからメイン画面まで4〜5秒かかる状態で、
低スペック端末ではさらに遅延が発生していました。

改善のため、まずはどこで何秒かかっているのかを正確に可視化する必要がありました。

Step 1.TTI指標を設計し、起動シーケンスを可視化する

まず最初に行ったのは、アプリ起動シーケンス全体を可視化するためのTTI指標を設計することでした。

当時のアプリはフィンテック特有の「初期化処理 × セキュリティチェック × 各種API呼び出し」が複雑に絡み合っており、
開発メンバーの誰もが “どの処理がどれくらい時間を使っているのか” を正確に把握できていない状況でした。

そのため、まずは起動シーケンスをイベント単位で分解し、下記のようなTTI イベントを定義しました。

//------------------------------------------------------------
//  Splash TTI(スプラッシュ画面の起動指標)
//------------------------------------------------------------

// アプリ起動 → スプラッシュ画面が生成されるタイミング
APP_LAUNCHED
    ↓
SPLASH_SCREEN_CREATED


// スプラッシュ生成直後に並列で実行される各APIリクエスト
//(起動時の初期状態チェックやセキュリティ検証)
SPLASH_SCREEN_CREATED → XXX_API_REQUESTED

// 各APIのリクエスト → レスポンス
//(起動時間が最もブロックされやすいポイント)
XXX_API_REQUESTED → XXX_API_RESPONDED


// スプラッシュ画面を離れる条件
// 複数の初期化処理/APIレスポンスの完了を待って遷移するケースが多い
SPLASH_SCREEN_CREATED → SPLASH_SCREEN_LEFT
XXX_API_RESPONDED     → SPLASH_SCREEN_LEFT

//------------------------------------------------------------
//  Main TTI(メイン画面の生成〜表示まで)
//------------------------------------------------------------

// ※iOSの場合:Main画面の生成タイミングが明確
MAIN_SCREEN_LAUNCHED
    ↓
MAIN_SCREEN_CREATED


// 画面の描画処理が完了し、ユーザーが実際に操作可能になる状態
MAIN_SCREEN_CREATED
    ↓
MAIN_SCREEN_SHOWED

//------------------------------------------------------------
//  Tab Contents TTI(各タブの表示 → コンテンツ準備)
//------------------------------------------------------------

// タブ画面が表示されてから、必要なデータ・ViewModelが準備完了するまで
XXX_TAB_SCREEN_SHOWED → XXX_TAB_SCREEN_CONTENTS_READY

ここでいうTTI(Time To Interaction)は、
ユーザーが “実際に操作可能な状態” になるまでの時間を意味します。

イベント設計のポイント

  • 各フェーズを可能な限り細かく分割する
  • 「画面が生成された時」と「ユーザーが触れる状態になった時」を分けて記録
  • 各APIは Request/Responseを別イベントとして記録
  • AndroidiOSの双方で 同じ名称・同じタイミング でイベントを発火する

これにより、起動のどの部分が遅いのかをOS間で比較・分析できるようになります。

Firebase/Amplitudeを使ったログ収集

指標を設計したあと、AndroidチームとiOSチーム全体で共有し、
Firebase PerformanceAmplitude を使って実際にイベントを記録していきました。

TTI指標を蓄積する目的はシンプルです。

「どの処理が起動時間のボトルネックになっているのかを可視化する」

ここを明確にできない限り、
“どこから改善すべきか” も分からず、最適化施策も当てずっぽうになってしまいます。

次のStepでは、このTTIデータからどのようにボトルネックを特定したかを解説します。

Step 2.計測データからボトルネックを特定する

TTI(Time To Interaction)の計測イベントをアプリ全体に実装した後、
Firebase PerformanceAmplitudeに蓄積されたデータをもとに
「どの処理が起動時間を最も遅くしているのか」 を分析しました。

測定してみると、起動の遅さは “直感” ではなく
特定の処理が明確に時間を支配していることが分かりました。

分析のポイント

計測データを見る際は、以下の3つの観点でチェックしました:

  1. 各APIの Request/Response所要時間
  2. メインスレッドを塞いでいる同期処理
  3. 画面生成 → 描画完了までのレンダリング時間

特に、フィンテックアプリ特有のセキュリティ処理が
Cold Startの大部分を占めていたことが明確になりました。

実際に時間がかかっていたポイント

  • アプリ整合性チェック

    • 端末によっては 数百ms〜1秒以上
    • メインスレッド上で同期的に実行されていた
  • 各種状態チェックAPI

    • ネットワーク条件により 300〜800ms 程度のばらつき
    • Splashの終了をブロックしている状態
  • 実験データ取得(Device Experiment / User Experiment)

    • 本来は起動直後でなくても良い処理
    • しかし起動時に同期実行されていたため TTI を押し上げていた
  • Main画面 ViewModel 初期化

    • 大量の初期化ロジックが詰め込まれており 描画遅延の原因

なぜこれが問題だったのか?

最大の問題は、

  • “本当に起動直後に必要な処理” と
  • “後から実行しても問題ない処理”

が区別されず、
すべてが同期的に実行されていた ことでした。

その結果:

  • メインスレッドが長時間ブロックされる
  • スプラッシュを離れるタイミングが不必要に遅くなる
  • TTI(操作可能になるまでの時間)が大きく伸びる

といった悪影響が発生していました。

Step 3.ボトルネックに対する具体的な最適化

計測によって「どこが遅いのか」が見えてきたので、次のステップとして
実際にそれぞれのボトルネックに対して最適化を行いました。

ここでは、主に以下の3つの観点で改善しています。

  1. 起動時の同期処理を Coroutines を使って非同期・並列化
  2. 無欠性検証(Integrity Check)処理の短縮(ベンダーとの調整)
  3. Main画面ViewModelのLazy初期化(Hilt を活用)

3-1.RxJavaベースの同期処理をCoroutines化

当時の起動処理は、一部がRxJavaで組まれたチェーンになっており、
実質的に「起動時に同期的に実行される一連の処理」として振る舞っていました。

例えば、以下のような流れです。

  • 起動時に複数のAPIを順番に呼ぶ
  • その結果が揃うまでSplashを抜けない
  • 結果として、すべての処理が終わるまでユーザーは何もできない

これをCoroutinesに置き換えることで、

  • それぞれのAPIasync/awaitAllで並列実行
  • UIスレッドをブロックしないように Dispatchers.IO で実行
  • 起動直後に必須でない処理は後段に回す

といった形にリファクタリングしました。

viewModelScope.launch {
    val serviceStatus = async { fetchServiceStatus() }
    val integrity     = async { checkIntegrity() }
    val experiments   = async { fetchExperiments() }

    // 本当に必要なものだけ await する
    val statusResult = serviceStatus.await()
    val integrityResult = integrity.await()

    // 必須ではない実験系は後から使うタイミングまで遅延させる
    launch { experiments.await() }
}

RxJavaを使っていた頃と比べると、
処理の見通しがよくなっただけでなく、「どこを並列化できるか」をコード上で明示しやすくなった のもメリットでした。

3-2.無欠性検証処理の短縮(ベンダーとの連携)

フィンテックアプリでは、起動時に アプリ無欠性検証(Integrity Check) を行う必要があります。
計測したところ、この処理が端末によっては 500ms〜1秒以上 かかり、
Cold Startの大きなボトルネックになっていることが分かりました。

そこで、ライブラリを提供しているベンダーと直接やり取りしながら、

  • 起動直後に実行しなければならないチェック
  • 画面遷移後でも問題ないチェック

の切り分けを行い、起動時の負荷を最小限にするよう調整 しました。

さらに、ライブラリはコールバックベースのAPIだったため、
Coroutinesと統一的に扱えるよう suspendCancellableCoroutineでラップしました。

suspend fun checkIntegrity(): IntegrityResult =
    suspendCancellableCoroutine { cont ->
        integrityClient.check(
            onSuccess = { result -> cont.resume(result) },
            onError = { e -> cont.resumeWithException(e) },
        )
    }

これにより、無欠性検証も他の起動処理と同様に
asyncを使って並列実行できるようになり、
起動時間の大幅な短縮につながりました。

3-3.Android/iOS起動ロジックの統一

最適化を進める中で、AndroidiOS
起動シーケンスや初期化する条件が異なっている ことが分かりました。

両OSの実装差異は、以下の問題を引き起こします。

  • 起動時間の比較が正確にできない
  • 片方のOSだけ挙動が異なり、不具合調査が難しくなる
  • プロダクトとして一貫したUXを提供できない

そこで、以下のメンバーと合同でレビューを実施し、
両OSで起動ロジックを統一する 方針を取りました。

  • サーバーエンジニア
  • セキュリティエンジニア
  • Android/iOS エンジニア

議論したポイントは次の通りです。

  • どのAPIは「起動直後に必須」なのか
  • どの処理は「非同期化しても安全」なのか
  • どの初期化は「画面遷移後に回しても良い」か

OS 間で起動フローを統一したことで、

  • 起動時間を正確に比較できる
  • 不具合調査の効率が大幅に向上
  • 開発チーム全体で同じ前提で最適化ができる

といったメリットが得られました。

3-4.Main ViewModelのLazy初期化(Hilt)

最後に、Main画面のViewModelで行っていた
重い初期化処理を Lazy 化(遅延初期化) しました。

起動直後に必要のない処理まで同期的に行われていたため、
Cold Startの後半に大きな負担がかかっていました。

HiltのLazy<T>を使うことで、

「本当に必要になった画面(タブ)で初めて初期化する」

という構成に変更しました。

@HiltViewModel
class MainViewModel @Inject constructor(
    private val heavyInitializer: Lazy<HeavyInitializer>,
) : ViewModel() {

    fun onHomeTabVisible() {
        viewModelScope.launch {
            // 初回アクセス時に初期化が行われる
            heavyInitializer.get().prepare()
        }
    }
}

Lazy 初期化で気をつけた点

  • Lazy<T>は “生成を遅らせるだけ” であり、
    不適切な場所でget()を呼ぶと起動時実行に逆戻りする
  • UIスレッドでしか扱えない処理は、呼び出しスレッドに注意
  • ViewModel が破棄された後にget()を実行するとクラッシュの原因になる

Lazy を使うことが目的ではなく、
“どのタイミングでget()を呼ぶか” を設計することが最も重要だと感じました。

Step 4.最適化の結果とColdStartの改善効果

一連の最適化(TTI可視化 → 同期処理の非同期化 → 無欠性検証の短縮 → Lazy初期化)
を行った結果、アプリの起動時間(Cold Start)は大きく改善されました。

改善効果の測定には Google Play Console の起動パフォーマンスデータ
および Firebase Performance を使用し、実際のユーザー端末ベースで検証しています。


改善前:Cold Start に課題があった状態

最適化前は、起動時間が 4〜5秒台 に達する端末が多く、
特に低スペック端末では 5秒以上かかるケースが 0.66% 程度存在していました。

また、以下のように APK バージョンごとに P90(90%のユーザーが到達する時間)を確認すると、

  • 22.24.x : 3.3〜3.5秒
  • 22.26.x : 3.0〜3.1秒

と、起動時間が一定以上からなかなか改善しない状態でした。


改善後:最大40%の高速化を達成

Step 1〜3の最適化を段階的に行った結果、
ColdStartのP90は大幅に短縮されました。

  • 22.34.x : 2.2〜2.3秒
  • 22.36〜38.x : 2.1〜2.2秒
  • 22.40.0 : 2.0〜2.1秒

改善幅としては、

最大 3.5秒 → 2.1秒(約 58% 改善)

という大きな効果が確認されました。

Google Play Console の起動時間レポートでも
Cold Startの分布が右側(遅い端末帯)から大きく左に寄る改善を確認しました。


改善できた理由のまとめ

改善の主な要因は以下の通りです:

  1. TTIを細かく可視化し、問題箇所を正しく特定できたこと
  2. 複数の同期APIを Coroutines によって並列化したこと
  3. 無欠性チェック(Integrity Check)の高速化と非同期化
  4. Main ViewModel 初期化の Lazy 化による不要な Cold Start 処理の排除
  5. Android / iOS / サーバー / セキュリティ で初期化要件を正しく整理したこと

これらの最適化により、
起動フローを「必要最小限」に整理することができ、
Cold Start 全体の体験が大幅に向上しました。


改善後のユーザー影響

  • 初回起動の待ち時間が体感レベルで短縮
  • 新規ユーザーの離脱ポイントが減少
  • Splash → メイン画面の遷移がスムーズになり UX 向上
  • 実機レビューでも「起動が速くなった」というフィードバックを獲得

起動速度は数値指標以上に、
“ユーザー体験の最初の印象” に直結する重要な要素だと改めて実感しました。

まとめ

本記事では、フィンテックアプリの起動時間を大幅に短縮するために行った
「可視化 → ボトルネック特定 → 非同期化 → Lazy 初期化」
までの一連のプロセスを紹介しました。

起動時間はユーザーが最初に触れる体験であり、
プロダクトの印象を大きく左右する重要な指標です。

TTI を細かく分解して可視化することで、
“なんとなく遅い” という感覚的な問題を
“どの処理が何 ms 遅いのか” という、明確な構造へ変換できました。

その結果、実際のユーザーデータ(Google Play Console / Firebase)でも
Cold Start が 3.5sec → 2.1sec(約40%短縮) と大きく改善されました。

学び

今回の最適化プロジェクトを通して、次のような学びが得られました。

1. 計測なしに最適化はできない

  • 「どこが遅いのか」を知らない状態で改善しても意味がない
  • TTI を構造的に可視化するだけで、改善方向が明確になる

2. 同期処理は “本当に必要なもの” だけにするべき

  • 起動直後に実行しなくても良い処理が多かった
  • Coroutines により安全に非同期化・並列化できる

3. 初期化処理は “必要になった瞬間に” 行うべき

  • HiltのLazyを使い、Cold Startから重い初期化を切り離した
  • Lazyは魔法ではなく「get() を呼ぶタイミングの設計」が重要

4. OS間の初期化要件を揃えることは非常に重要

  • Android/iOS の起動ロジックが揃うことで、不具合調査と最適化が劇的に効率化
  • サーバー / セキュリティとの連携が欠かせない

5. 起動速度は UX の最初の体験であり、改善の価値が大きい

  • 数百msの改善でも、ユーザー体感は劇的に変わる
  • プロダクト全体の満足度や離脱率にも影響する

起動最適化は “見えない改善” に思われがちですが、
ユーザー体験・開発効率・プロダクトの信頼性に大きく貢献する改善です。

以上が、私が行った Cold Start 最適化プロセスとその学びです。

Discussion