アプリの起動時間を計測する

Rust の run()・setup・on_page_load と、ページの Navigation Timing・performance.mark() を同じ時間軸にそろえ、起動のどこが遅いかを測る。リリースビルドで結果を残す方法も示す。

パフォーマンス 対象: Tauri 2.x 更新日: 読了目安: 約9分 perf-005
目次
  1. 前提条件
  2. 1. Rust 側で区切りを記録する (Rust)
  3. 2. ページ側の区切りを送る (TypeScript)
  4. 動作確認
  5. 結果の読み方と速くする工夫
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

「起動が遅い」と感じても、アイコンを押してから操作できるまでのどこで時間を使っているかが分からなければ、直す場所を決められません。Tauri アプリの起動は、Rust 側(プラグインの初期化、ウィンドウの作成、setup)と WebView 側(ページの読み込み、最初の描画、アプリの初期化)の 2 段に分かれます。このレシピでは両方の区切りで時刻を記録して 1 本の時間軸に並べ、リリースビルドでも結果をファイルに残します。遅い場所が分かった後の対策は後半にまとめています。

前提条件

プラグインや権限の追加は要りません。計測はリリースビルド(npm run tauri build)で行います。tauri dev は Rust がデバッグビルドで、ページも開発サーバーからファイルごとに変換しながら届くため、本番よりかなり遅く出ます。開発中の数字は、変更の前後を比べる目安にとどめます。

区切りと測る場所は次のとおりです。設定ファイルのウィンドウは setup の直前に作られるので、「build 完了」から「setup 開始」までにはウィンドウと WebView の作成が入ります。

区切り測る側使うもの
run() の開始(0 ms)RustInstant
build() の完了Rustbuild() の戻り
setup の開始・終了Rustsetup フック
ページの読み込み開始・完了Ruston_page_load
HTML の受信完了・DOMContentLoadedJSNavigation Timing
最初の描画JSPaint Timing(FCP)
アプリの初期化完了JSperformance.mark()

1. Rust 側で区切りを記録する (Rust)

run() の先頭を 0 ms とし、区切りごとに経過時間を記録します。setup より前にも印を付けたいので、記録は State ではなく static に置きます。ページ側の時刻を後で同じ軸に並べるため、基準点では壁時計の時刻(UNIX 時刻のミリ秒)も控えておきます。

use std::sync::{Mutex, OnceLock};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use tauri::webview::PageLoadEvent;
use tauri::Manager;

/// 起動の基準点(経過時間用の Instant と、JS とそろえるための壁時計のミリ秒)
static START: OnceLock<(Instant, f64)> = OnceLock::new();
/// (起動からのミリ秒, 名前)
static MARKS: Mutex<Vec<(f64, String)>> = Mutex::new(Vec::new());

fn epoch_ms() -> f64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs_f64() * 1000.0)
        .unwrap_or(0.0)
}

/// 起動からの経過時間に名前を付けて記録する(最初の呼び出しが 0 ms)
fn mark(name: &str) {
    let (start, _) = START.get_or_init(|| (Instant::now(), epoch_ms()));
    let ms = start.elapsed().as_secs_f64() * 1000.0;
    if let Ok(mut marks) = MARKS.lock() {
        marks.push((ms, name.to_string()));
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JsMark {
    name: String,
    epoch_ms: f64, // Date.now() と同じ基準の時刻
}

/// ページ側の印を受け取り、Rust 側の印と時刻順に並べて保存する
#[tauri::command]
fn report_startup(app: tauri::AppHandle, marks: Vec<JsMark>) -> Result<String, String> {
    let (_, start_epoch) = *START.get().ok_or("mark() が一度も呼ばれていません")?;
    let mut all = MARKS.lock().map_err(|e| e.to_string())?.clone();
    all.extend(marks.into_iter().map(|m| (m.epoch_ms - start_epoch, format!("[JS] {}", m.name))));
    all.sort_by(|a, b| a.0.total_cmp(&b.0));

    let report: String = all.iter().map(|(ms, name)| format!("{ms:8.1} ms  {name}\n")).collect();
    println!("{report}");
    // リリースビルドでも読めるよう、ログ用のフォルダーにも書く
    let dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    std::fs::write(dir.join("startup.txt"), &report).map_err(|e| e.to_string())?;
    Ok(report)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    mark("run() 開始");
    let app = tauri::Builder::default()
        .setup(|_app| {
            mark("setup 開始");
            // 設定の読み込みなど、起動時の初期化
            mark("setup 終了");
            Ok(())
        })
        .on_page_load(|webview, payload| {
            if webview.label() == "main" {
                match payload.event() {
                    PageLoadEvent::Started => mark("ページ読み込み開始"),
                    PageLoadEvent::Finished => mark("ページ読み込み完了"),
                }
            }
        })
        .invoke_handler(tauri::generate_handler![report_startup])
        .build(tauri::generate_context!())
        .expect("error while building tauri application");
    mark("build 完了");
    app.run(|_app, _event| {});
}

テンプレートの .run(...) を .build() と app.run() に分けているのは、その間に印を付けるためです。on_page_load は再読み込みのたびに呼ばれるので、DevTools で再読み込みすると印が増えます。計測は毎回アプリを起動し直して行います。

2. ページ側の区切りを送る (TypeScript)

performance.now() などページ側の時刻は、そのページの読み込み開始を 0 とした値で、アプリの起動からの時間ではありません。そこで Date.now() - performance.now() でページの 0 地点を壁時計の時刻に直してから送り、Rust 側で起動時刻との差を取ります。初期化の後は requestAnimationFrame を 2 回待ってから印を付けると、組み上がった画面が実際に描かれた時点に近くなります。

import { invoke } from '@tauri-apps/api/core';

type JsMark = { name: string; epochMs: number };

// performance.now() の 0(このページの読み込み開始)を壁時計のミリ秒に直す
const pageOrigin = Date.now() - performance.now();
const toEpoch = (t: number) => pageOrigin + t;

// 次の描画が済むまで待つ
const nextPaint = () =>
  new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));

async function initApp() {
  // 設定の読み込み、最初の画面の組み立てなど
}

window.addEventListener('DOMContentLoaded', async () => {
  try {
    await initApp();
  } finally {
    await nextPaint();
    const ready = performance.mark('app-ready');
    const marks: JsMark[] = [];
    const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
    if (nav) {
      marks.push({ name: 'HTML の受信完了', epochMs: toEpoch(nav.responseEnd) });
      marks.push({ name: 'DOMContentLoaded', epochMs: toEpoch(nav.domContentLoadedEventEnd) });
    }
    const fcp = performance.getEntriesByName('first-contentful-paint')[0];
    if (fcp) marks.push({ name: '最初の描画 (FCP)', epochMs: toEpoch(fcp.startTime) });
    marks.push({ name: '初期化完了', epochMs: toEpoch(ready.startTime) });
    console.log(await invoke<string>('report_startup', { marks }));
  }
});

initApp() が例外で止まっても報告が届くよう、finally で送ります。"visible": false で起動して準備ができてから show() している場合(ウィンドウを表示・非表示にする)は、show() の後に印を付けると、ユーザーが画面を目にした時点になります。

動作確認

npm run tauri build で作った実行ファイルを起動すると、ログ用フォルダーの startup.txt(tauri dev ではターミナルと DevTools のコンソールにも)に次のような表が出ます。数値は一例です。

     0.0 ms  run() 開始
    31.6 ms  build 完了
   148.2 ms  setup 開始
   149.0 ms  setup 終了
   183.4 ms  ページ読み込み開始
   196.9 ms  [JS] HTML の受信完了
   287.5 ms  [JS] DOMContentLoaded
   294.1 ms  [JS] 最初の描画 (FCP)
   318.8 ms  ページ読み込み完了
   402.3 ms  [JS] 初期化完了

報告を送った時点より後の印(初期化が早く終わったときの「ページ読み込み完了」など)は表に入りません。ページの中の内訳は、DevTools の Performance で記録しながら再読み込みすると分かります。

結果の読み方と速くする工夫

隣り合う印の差がいちばん大きいところから手を付けます。

間が長いところ主な中身対策
run() 開始〜build 完了プラグイン・メニュー・トレイの初期化使っていないプラグインを外す
build 完了〜setup 開始ウィンドウと WebView の作成最初に要らないウィンドウは "create": false にして後で作る
setup 開始〜終了setup の中の処理時間のかかる準備は別スレッドへ(rust-012、rust-013)
読み込み開始〜DOMContentLoadedJS の読み込みと実行バンドルを小さくし、すぐ使わない画面は import() で後から読む(perf-001)
DOMContentLoaded〜初期化完了初期化中の invoke や計算順に await している invoke を Promise.all でまとめる。重い計算は Web Worker へ

体感を良くする工夫もあります。起動直後の白い画面が気になるなら、ウィンドウの backgroundColor をページの背景色に合わせるか、"visible": false で起動して準備ができてから表示します(win-011)。後者は画面が出るまでの時間がむしろ延びるので、最初の画面は軽く作り、重い部分は表示後に読み込む形と組み合わせます。

よくあるエラーと対処法

  • 「Command report_startup not found」: generate_handler! への登録漏れです。
  • リリースビルドで表がどこにも出ない: テンプレートの main.rs は windows_subsystem = "windows" で、Windows のリリースビルドはコンソールを持ちません。エクスプローラーなどから起動すればターミナルもないので、println! は見えません。startup.txt を開きます(場所は次の節)。
  • JS の行だけ数十年分のマイナスになる: epochMs に performance.now() の値をそのまま入れています。toEpoch() で壁時計の時刻に直してから送ります。
  • 「最初の描画 (FCP)」の行が出ない: FCP は文字や画像など何かが描かれたときにだけ記録され、記録しない WebView もあります。コードは無ければ飛ばし、「初期化完了」は必ず残るようにしています。

OS ごとの違いと注意点

  • startup.txt の場所: app_log_dir() は、Windows では %LOCALAPPDATA%\<identifier>\logs、macOS では ~/Library/Logs/<identifier>、Linux ではローカルデータのフォルダー(通常は ~/.local/share)の <identifier>/logs です。
  • 数値のぶれ: PC の起動直後やインストール直後の 1 回目は、ファイルがまだ読み込まれていないので遅く出ます。1 回目は分けて見て、残りは 5 回ほど測った中央値で比べます。
  • 測れない部分: run() より前(実行ファイルの読み込み)は含まれません。クリックからの体感とずれるときは、画面を録画して比べます。
  • 時計: JS の行は壁時計の時刻で並べるので、計測中に OS が時刻を合わせるとずれます。
  • 起動は速いのに、使ううちに重くなる場合は メモリリークしていないか検査する で調べます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する