Svelte + Tauriでデスクトップ開発

デスクトップアプリを開発するにあたり、Svelte + Tauri(Rust)という組み合わせを検証しています。理由はシンプルで、SvelteのDXの良さと、Rustの実行速度・安全性を組み合わせれば、デスクトップ開発がもっと楽になるのではないか、という見立てがあったためです。以下では、この組み合わせを実際に使って得られたメリット・デメリットを整理します。

ElectronではなくTauriにした理由

技術的にはSvelte + Electronでも同じアプリは作れます。ただし今回のアプリの核(RAFのRAWデコード・デモザイク)は数千万画素規模のピクセル処理で、これをどの言語で書くかが選定の分かれ目になりました。

  • Electronはフロントエンド(Chromiumレンダラー)もバックエンド(メインプロセス)もJS/TSです。重いピクセル処理をNode.js(V8)でそのまま書くと、GCの介在・SIMDやメモリレイアウトを直接制御できない制約があると言われています。worker_threadsはありますが、RustのOSネイティブスレッド + rayonのワークスティーリングスケジューラほど軽くはないと思われます(この記事はTauri側の実装・計測が中心で、Electron版は作っていません)。
  • 「速くしたければネイティブアドオン(N-API/node-gyp、実質C/C++かRust)を書く」ことになりがちで、その場合「Electronなら全部TypeScriptで完結する」というメリット自体が失われます。
  • 今回使ったrawler(RAFデコード用crate)はRust製です。Electronから使うには「別プロセスの実行ファイルとしてstdio越しに呼ぶ」か「Rust→Node.jsのFFIブリッジ(napi-rs等)を書く」必要があり、単にCargo.tomlに1行足すだけのTauriより手間が増えます。
  • Tauriはフロントエンド(WebView)とバックエンド(Rust)が最初から分業されているので、重い処理はRustにそのまま書けます。「RAWデコードを本気でやる」時点で、この分業構造が効いてきました。

配布サイズ・メモリフットプリントの違いも実務上は無視できません。Electronは Chromium + Node.jsを丸ごと同梱するため空のアプリでも80〜150MB超が基本、TauriはOSのネイティブWebView(macOSならWKWebView)を使い回すためインストーラーは数MB〜十数MBで済む、というのは一般に言われる数字です(今回このアプリ自体でElectron版をビルドして実測比較したわけではありません)。

一方で、Electronに分があると思われる点は次の通りです。(実際に試したわけではないので一般論として)

  • ElectronはどのOSでも同じ版のChromiumを同梱するため、CSS/JSの挙動が環境でブレないと思われます。TauriはOSごとのネイティブWebView(macOS=WebKit、Windows=WebView2、Linux=WebKitGTK)に依存するため、ブラウザ間差異がそのままアプリの差異になり得ます。こちらはaccent-colorの件(後述)として、実際に今回のmacOS環境で発生しました。
  • Node.jsのnpmエコシステムはRustのcrates.ioより「アプリ的な便利ライブラリ」の層が厚いと思われます。
  • 全部TypeScriptで完結できるので、Rust/TS間のコンテキストスイッチや型の二重管理(後述)が発生しません。これは後述する型の二重管理の実装経験から導かれる結論です。

Svelteで開発するメリット

  • Svelte 5のRunes($state/$bindable/$effect/$derived)でボイラープレートが少ない。3ペインレイアウトのリサイズ処理や、スライダー+数値入力の双方向バインドのような小さいUIコンポーネントを、余計な状態管理ライブラリなしで簡潔に実装できました。実際のスライダーコンポーネントの実装は次の通りです。
<script lang="ts">
  interface Props {
    label: string;
    value: number;
    min: number;
    max: number;
    step?: number;
  }

  let { label, value = $bindable(), min, max, step = 1 }: Props = $props();

  function handleSliderInput(event: Event) {
    value = Number((event.target as HTMLInputElement).value);
  }

  function handleNumberInput(event: Event) {
    const next = Number((event.target as HTMLInputElement).value);
    if (!Number.isNaN(next)) {
      value = Math.min(Math.max(next, min), max);
    }
  }
</script>

<input class="slider-field__number" type="number" {min} {max} {step} {value} oninput={handleNumberInput} />
<input class="slider-field__range" type="range" {min} {max} {step} {value} oninput={handleSliderInput} />

value = $bindable()にしておくだけで、呼び出し側は<SliderField label="Exposure" bind:value={exposure} min={-3} max={3} />のように書け、親の状態とスライダー・数値入力の3方向が自然に同期します。

  • bind:selectedFileのような双方向バインドが素直。ファイル一覧で選択したファイルパスを、親経由でプレビューペインに伝える、という典型的な「複数コンポーネント間でstateを共有する」パターンが、Reactのようにコールバックを手で配線しなくても済みます。
  • コンポーネントスコープのSCSS(<style lang="scss">)がビルドインで使え、sass-embeddedを入れるだけでネスト記法などが使えました。CSS設計に別のライブラリ(CSS-in-JS等)を持ち込む必要がありませんでした。
  • 仮想DOMを使わないコンパイル方式なので、ランタイムのオーバーヘッドが小さいと言われています。「重い処理をネイティブ側に逃した後、フロントエンド側も軽くしたい」という今回の設計方針との親和性は高いと考えられます。

Svelteのメリットというわけではありませんが、GUIをWebベースにすることで、WebGL2がそのまま使えるので、GPUを用いた画像処理が簡単にできる、というのも大きな利点です。RustからネイティブにOpenGL/wgpuを扱おうとすると、ウィンドウシステムとのバインディングやコンテキスト管理を自前で組む必要がありますが、TauriのWebViewでは<canvas>要素からgetContext("webgl2")を呼び出すだけでGPUコンテキストを取得できます。後述するリニアプレビューのWebGL2実装は、この特性を活用したものです。

実装上の課題

#[tauri::command]のデフォルトはメインスレッドで同期実行される

これは今回の実装で最も影響の大きかった問題です。asyncを付けない普通の#[tauri::command]関数は、IPCメッセージを受け取ったスレッド上でそのまま同期的に呼び出されます(tauri-macrosの生成コードを実際に読んで確認しました。バックグラウンドスレッドへの自動ディスパッチは一切ありません)。

macOSではこのスレッドがWebView自体のイベントループ・描画も担っているため、重い処理(今回はRAFデコード+デモザイク)を書くと、Rust側の処理が終わるまでウィンドウ全体の描画が止まります。問題は、JS側のstate自体は実際には即座に更新されているのに、画面に反映されるのはRust処理完了後になる、という点です。「サイドメニューの選択ハイライトが、画像の切り替わりと同じタイミングでしか反映されない」「ローディングスピナーが一瞬も見えない」という、原因が直感的に分かりにくい症状として現れました。rayonでRust側の処理自体を並列化しても直らず(呼び出し元スレッドが待たされている、という問題の本質は変わらないため)、async fn + tauri::async_runtime::spawn_blockingで重い処理を別スレッドに逃がして初めて解決しました。

#[tauri::command]
async fn decode_raf_preview(
    path: String,
    max_dimension: u32,
    adjustments: raw_decode::Adjustments,
) -> Result<String, String> {
    tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
        // RAWデコード・デモザイクという重い処理はここで実行される。
        // 呼び出し元(WebViewのイベントループを兼ねるスレッド)は
        // ブロックされない。
        let image = raw_decode::decode_and_demosaic(&path)?;
        let rendered = raw_decode::render_with_adjustments(&image, &adjustments)?;
        raw_decode::to_png_data_url(&rendered, max_dimension)
    })
    .await
    .map_err(|e| e.to_string())?
}

「重いコマンドはasync + spawn_blocking」はTauri公式ドキュメントにも明記されているプラクティスですが、デフォルト(asyncを付けない)の書き方はこの問題を誘発しやすい構造になっています。

さらに、rayonのグローバルスレッドプール自体もコア数いっぱいまで使うとWebViewの描画スレッドを圧迫するため、コア数-2に制限する調整も入れました。

fn configure_rayon_thread_pool() {
    let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
    let worker_threads = cores.saturating_sub(2).max(1);
    rayon::ThreadPoolBuilder::new()
        .num_threads(worker_threads)
        .build_global()
        .expect("failed to configure rayon thread pool");
}

デバッグビルドは体感できるレベルで遅い

cargo tauri devはデフォルトで最適化なしビルドです。ピクセル単位のループ処理(積分画像構築等)は最適化の有無で数倍〜数十倍体感が変わり得ます。[profile.dev] opt-level[profile.dev.package."*"]でチューニングする必要がありました(ただし今回のケースではopt-levelよりrayonでの並列化の方が効果が大きい結果でした。メモリアクセスが支配的なコードだったためです。実測では6336×4182の画像で「積分画像構築+デモザイクループ」が並列化前は約1.3秒、並列化後は約0.5秒でした)。

ネイティブWebView依存によるブラウザ差異

インスペクターのスライダーでaccent-colorを茶色系に設定しようとしたところ、WebKit(Safari/Tauri on macOS)はネイティブのつまみに独自のハイライト・グラデーション処理を加えるため、指定した色より明るく・オレンジっぽく表示されてしまう問題がありました。Chromiumを同梱するElectronならこの手のズレは起きにくいと考えられます。Tauriでは::-webkit-slider-thumb等でthumb/trackを自前スタイリングして回避するか、色相・彩度を妥協するかの選択を迫られます。

Rust⇄TypeScript間の型の二重管理

invoke("resolve_raf_drop", { path })のようなIPC呼び出しは、Rust側の構造体とTypeScript側のインターフェースを手動で二重管理する必要があります(コマンド名も文字列指定で、コンパイル時にtypoを検知できません)。これは「2つの別言語」であるTauriならではの制約で、`tauri-specta`のようなコード生成ツールを挟まない限り解消できません。Electronならメインプロセス・レンダラーとも同じTypeScriptなので、型定義ファイルを1つ共有するだけで済みます(ただしチャンネル名の型安全性についてはelectron-trpc等が別途必要で、完全に無罠というわけではありません)。今回は依存とビルドステップが増えることを避け、tauri-spectaの導入は見送って手動管理のままにしています。

Tauriのバイナリレスポンスによる転送最適化

インスペクターのスライダー(露出・コントラスト等)を動かすたびにRustへフルパラメータで処理をやり直す実装だと、デモザイク結果自体はキャッシュしていても数百ms〜1秒の往復が発生していました。初回だけRustでデコードし、以降の調整はフロントエンド(WebGL2)で完結させる方針とし、調査した結果、TauriのコマンドはString(JSON化される)だけでなくtauri::ipc::Responseを返せば、生バイト列をそのままフロントに渡せることが分かりました。

#[tauri::command]
async fn decode_raf_linear_preview(path: String, max_dimension: u32) -> Result<tauri::ipc::Response, String> {
    tauri::async_runtime::spawn_blocking(move || -> Result<tauri::ipc::Response, String> {
        let image = raw_decode::decode_and_demosaic(&path)?;
        let (pixels, width, height) = raw_decode::linear_preview(&image, max_dimension);
        Ok(tauri::ipc::Response::new(raw_decode::to_linear_rgb_bytes(&pixels, width, height)))
    })
    .await
    .map_err(|e| e.to_string())?
}

フロントエンド(@tauri-apps/api/coreinvoke())は、レスポンスのContent-Typeがapplication/jsonでもtext/plainでもない場合、自動的にresponse.arrayBuffer()を使う実装になっています(Tauri本体のソースで確認済み)。つまりinvoke<ArrayBuffer>(...)と書くだけで、JSON変換なしに生バイトを受け取れます。数千万画素のRGB float配列をJSON配列にすると著しく速度が低下するため、これは実質的に必須の選択でした。受け取ったArrayBufferはそのままWebGL2のRGB32Fテクスチャにアップロードでき、以降の露出・ホワイトバランス調整はシェーダー側で完結します。

「TauriはWebViewとRustの間でJSONしかやり取りできない」と誤解されることがありますが、実際には大きなバイナリペイロードも考慮された設計になっています。

まとめ

「GUI側はSvelteで開発速度、コア処理はRustで処理速度」という2つの利点を両立できる点がTauriを選ぶ最大の理由であり、今回のRAWデコード・デモザイクのような用途で特に効果を発揮します。一方で、そのメリットの裏返しとして「WebViewとネイティブシェルが密結合しているがゆえの罠(コマンドのブロッキング)」「OSネイティブWebView依存によるブラウザ差異」「Rust⇄TS間の型の二重管理」という、Electronでは(別の形の罠はあれど)そのままでは踏まない類の落とし穴も存在しました。Svelte自体は、Runesによる状態管理の軽さとビルトインのSCSSにより、「重い処理をネイティブ側に逃し、フロントエンドは薄く保つ」という今回の設計方針とよく合致しました。Svelteで重い処理を伴うネイティブアプリを開発する場合、Tauriは有力な選択肢となりますが、以上の制約を理解した上で採用する必要があります。

今回開発していたのは、FUJIFILMのRAWファイル形式(RAF)を現像するデスクトップアプリです。RAFのデコードや、数千万画素規模のピクセルデータをフルカラー画像に変換するデモザイク処理を、ここまで扱ってきたTauri×Svelteの構成の上に実装していました。最終的には、画質を追い込むためのデモザイクアルゴリズムの実装難易度が高く、アプリの完成自体は断念しています。ただし、そこに至るまでの過程でTauri×Svelteの技術選定・実装から得られた知見は、この記事で扱った通りです。