アプリのバンドルサイズを分析する

配布物を実行ファイル・埋め込まれたフロントエンド・インストーラーに分けて測り、Vite の出力、cargo bloat、cargo tree、埋め込みアセットの一覧で大きい原因を突き止める。

パフォーマンス 対象: Tauri 2.x 更新日: 読了目安: 約10分 perf-001
目次
  1. 前提条件
  2. 1. 3 つの大きさを並べる
  3. 2. フロントエンドの内訳を調べる (TypeScript)
  4. 3. Rust の実行ファイルの内訳を調べる (Rust)
  5. cargo bloat でクレート別に見る
  6. 依存クレートを cargo tree でたどる
  7. 埋め込まれたフロントエンドを一覧する
  8. 4. インストーラーだけが大きいとき
  9. 動作確認
  10. よくあるエラーと対処法
  11. OS ごとの違いと注意点
  12. 関連レシピ

Tauri のインストーラーは小さいと言われますが、依存クレートや画像が増えると 1 桁大きくなることもあります。配布物の中身は、Rust の実行ファイル、その中に圧縮して埋め込まれるフロントエンド(frontendDist の中身)、bundle.resources で同梱したファイル、インストーラー形式ごとの追加分の 4 つです。どれが大きいかで打つ手が変わるので、削る前に分けて測ります。削る手段は アプリを軽量化する、画像は 画像を圧縮してサイズを減らす で扱います。

前提条件

プラグインや権限は不要です。Rust 側の内訳を見る cargo-bloat を一度だけ入れます。

cargo install cargo-bloat

比べるのは必ず npm run tauri build の出力です(本番用にアプリをビルドする)。--debug 付きのビルドは Rust の最適化がかからず、Tauri が勧める Vite の設定では最小化も止まるので、何倍も大きく出ます。

1. 3 つの大きさを並べる

フロントエンド(dist/)、実行ファイル、インストーラーの大きさを並べます。Windows なら次のとおりです。

# プロジェクトのルートで、tauri build の直後に実行する
$dist = (Get-ChildItem dist -Recurse -File | Measure-Object Length -Sum).Sum
"{0,8:N0} KB  dist/" -f ($dist / 1KB)
Get-ChildItem src-tauri/target/release/*.exe, src-tauri/target/release/bundle/*/* -File |
  ForEach-Object { "{0,8:N0} KB  {1}" -f ($_.Length / 1KB), $_.Name }

macOS / Linux なら du -sh dist src-tauri/target/release/bundle/*/* と ls -lh src-tauri/target/release/<アプリ名> です。

読むときの要点は、フロントエンドがビルド時に brotli で圧縮されてから実行ファイルに入ることです。最小化した JS や CSS は 3 分の 1 程度になりますが、JPEG・PNG・WebP・woff2 フォントはもともと圧縮済みなのでほぼそのまま増えます。手元の例では 643KB の JS と 1.5MB の JPEG を足すと実行ファイルが約 1.7MB 増え、その 9 割が JPEG でした。

目立って大きいもの疑う原因調べ方
dist/ライブラリ、画像、フォント、ソースマップ2 章
実行ファイル(dist/ の割に)依存クレート、Rust に埋め込んだ画像3 章
インストーラーだけWebView2 の導入方法、同梱ファイル、AppImage4 章

2. フロントエンドの内訳を調べる (TypeScript)

vite build の最後に出る表は出力ファイルごとの大きさで、JS・CSS には gzip 後の値も付きます。JS・CSS による実行ファイルの増え方は、この gzip の値より少し小さい程度です。ただし public/ のファイルはコピーされるだけなので表に出ず、どの npm パッケージが JS を膨らませているかも分かりません。ビルドの最後に集計する小さなプラグインで補います。

// vite.config.ts
import { defineConfig, type Plugin } from 'vite';

// ビルドの最後に、出力ファイルと「JS に入った量」をパッケージ別に大きい順で表示する
function sizeReport(top = 8): Plugin {
  return {
    name: 'size-report',
    apply: 'build',
    generateBundle(_options, bundle) {
      const files: Array<[string, number]> = [];
      const packages = new Map<string, number>();
      for (const out of Object.values(bundle)) {
        if (out.type === 'asset') {
          const bytes = typeof out.source === 'string' ? out.source.length : out.source.byteLength;
          files.push([out.fileName, bytes]);
          continue;
        }
        files.push([out.fileName, out.code.length]);
        for (const [id, mod] of Object.entries(out.modules)) {
          // node_modules/名前 または node_modules/@scope/名前 を 1 つのパッケージとして数える
          const hit = /node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/.exec(id);
          const name = hit ? hit[1].replace('\\', '/') : '(自分のコード)';
          packages.set(name, (packages.get(name) ?? 0) + mod.renderedLength);
        }
      }
      const kb = (n: number) => `${(n / 1024).toFixed(1).padStart(8)} KB`;
      console.log('\n[size-report] 出力ファイル(public/ からのコピーは含まない)');
      for (const [name, size] of files.sort((a, b) => b[1] - a[1]).slice(0, top)) console.log(`${kb(size)}  ${name}`);
      console.log('[size-report] JS の内訳(最小化する前の長さ。比率の目安)');
      for (const [name, size] of [...packages].sort((a, b) => b[1] - a[1]).slice(0, top)) console.log(`${kb(size)}  ${name}`);
    },
  };
}

export default defineConfig({
  plugins: [sizeReport()],
});

見落としやすいのは dist/ に紛れ込むファイルです。build.sourcemap: true のままの .map も、public/ に置いたままの元画像も、すべて実行ファイルに埋め込まれます。図で見たいなら rollup-plugin-visualizer も使えますが、beforeBuildCommand で毎回動くので分析するときだけ有効にし、レポートは dist/ の外に出します。

3. Rust の実行ファイルの内訳を調べる (Rust)

cargo bloat でクレート別に見る

src-tauri で実行します。--features tauri/custom-protocol は tauri build と条件を揃えるためで、付けないと devUrl がある構成ではフロントエンドが埋め込まれず、実際より小さく出ます。cargo bloat はデバッグ情報付きでビルドし直すので、--target-dir を分けないと target/release の実行ファイルが上書きされます。

cd src-tauri
cargo bloat --release --crates -n 10 --features tauri/custom-protocol --target-dir target/bloat

.text 列はコード部分(.text)に占める割合です。最後の行の「.text の大きさ」と「ファイルの大きさ」の差は主に埋め込まれたデータで、差が大きければ原因はクレートではなくフロントエンドや画像です。std・tauri・tokio が上位なのは普通で、目立つクレートがあれば --filter <クレート名> で関数別に見ます。LTO で関数がまたがって最適化されるので、割り当ては目安です。

依存クレートを cargo tree でたどる

cargo tree -i <クレート名> -e normal --target all   # そのクレートを誰が引き込んでいるか
cargo tree -d -e normal,no-proc-macro               # 同じクレートの複数バージョン
cargo tree -e features -i tokio --depth 1           # tokio のどの機能が有効か

cargo tree は既定では今の OS 向けの依存しか表示しないので、他 OS 専用のクレートを探すときは --target all を付けます。小さなクレートの重複は珍しくありませんが、大きなクレートが二重に入っているなら、プラグインのバージョンを揃えると 1 つにまとまることがあります。

埋め込まれたフロントエンドを一覧する

asset_resolver().iter() は、実行ファイルに入っているフロントエンドを埋め込んだ状態(圧縮後)のバイト列で返します。ファイルごとに実際に何バイト使っているかが分かります。tauri dev では埋め込まれないので、tauri build で作ったアプリで確かめます。

#[derive(serde::Serialize)]
struct EmbeddedAsset {
    path: String,
    bytes: usize,
}

/// 実行ファイルに埋め込まれたフロントエンドを、埋め込み後の大きさの順に返す
#[tauri::command]
fn embedded_assets(app: tauri::AppHandle) -> Vec<EmbeddedAsset> {
    let mut list: Vec<EmbeddedAsset> = app
        .asset_resolver()
        .iter()
        .map(|(path, bytes)| EmbeddedAsset { path: path.into_owned(), bytes: bytes.len() })
        .collect();
    list.sort_by(|a, b| b.bytes.cmp(&a.bytes));
    list
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![embedded_assets])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

リリースビルドでは開発者ツールが開けないので、結果は画面に出します。

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

type EmbeddedAsset = { path: string; bytes: number };

export async function showEmbeddedAssets() {
  const list = await invoke<EmbeddedAsset[]>('embedded_assets');
  const total = list.reduce((sum, a) => sum + a.bytes, 0);
  const lines = list.slice(0, 15).map((a) => `${(a.bytes / 1024).toFixed(1).padStart(8)} KB  ${a.path}`);
  const pre = document.createElement('pre');
  pre.textContent = [`合計 ${(total / 1024).toFixed(0)} KB(${list.length} ファイル)`, ...lines].join('\n');
  document.body.append(pre);
}

4. インストーラーだけが大きいとき

  • Windows: bundle.windows.webviewInstallMode を確かめます。既定の downloadBootstrapper はほとんど増えませんが、embedBootstrapper で約 1.8MB、offlineInstaller で約 127MB、fixedRuntime で約 180MB 増えます。
  • 同梱ファイル: bundle.resources のファイルはそのまま入ります(画像やDBファイルを配布物に同梱する)。
  • Linux: AppImage は依存ライブラリを丸ごと含むので、小さなアプリでも 70MB を超えます。

インストーラーは中身を圧縮するので実行ファイルより小さくなるのが普通で、同じくらいなら画像や動画のような縮まない中身が多いということです。

動作確認

最小構成のアプリ(opener プラグインのみ、Windows x64)で 1 章のスクリプトを実行した例です。

       0 KB  dist/
   4,182 KB  sizelab.exe
   1,333 KB  sizelab_0.1.0_x64-setup.exe

同じアプリで 3 章の cargo bloat を実行した結果です。実行ファイルの 8 割がコードで、上位は tauri・std・tokio です。

 File  .text     Size Crate
20.7%  25.3% 860.7KiB tauri
19.9%  24.2% 824.6KiB std
11.9%  14.4% 492.2KiB tokio
 4.0%   4.9% 167.8KiB sizelab_lib
 3.8%   4.6% 156.3KiB tao
 3.0%   3.7% 126.1KiB tauri_runtime_wry
 1.7%   2.1%  71.0KiB serde_json
 1.5%   1.8%  61.8KiB wry
 1.4%   1.7%  56.7KiB hashbrown
 1.2%   1.5%  50.1KiB muda
11.7%  14.2% 483.5KiB And 64 more crates. Use -n N to show more.
82.1% 100.0%   3.3MiB .text section size, the file size is 4.1MiB

dist/ に 643KB の JS と 1.5MB の JPEG を置いてビルドし、showEmbeddedAssets() を呼ぶと次のように出ます。JS は 3 分の 1 以下になり、JPEG はほとんど縮んでいません。

合計 1747 KB(3 ファイル)
  1547.5 KB  /photo.jpg
   199.2 KB  /app.min.js
     0.1 KB  /index.html

よくあるエラーと対処法

  • embedded_assets が空の配列を返す: tauri dev で動かしています。devUrl があるとフロントエンドは埋め込まれません。
  • cargo bloat の数字が tauri build の実行ファイルと合わない: --release か --features tauri/custom-protocol が抜けています。
  • 「warning: nothing to print.」: cargo tree -i で指定したクレートが今の OS 向けの依存にありません。--target all を付けるか綴りを確かめます。
  • dist/ が小さいのに実行ファイルだけ大きい: Rust の include_bytes! や include_image! で画像を埋め込んでいる可能性があります。include_image! は画素を展開した状態で入り、512px なら約 1MB です(perf-003)。

OS ごとの違いと注意点

  • Windows: インストーラーは既定で WebView2 を含まないので小さく済みます。
  • macOS: .app はフォルダーなので du -sh で測ります。universal-apple-darwin で作ると両アーキテクチャのコードが入るぶん大きくなります。
  • Linux: 形式で桁が変わるので、同じ形式どうしで比べます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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