どうも、CoeFont でフロントエンドエンジニアをやってる uzimaru です。
フロントエンドエンジニアなんですが、最近は Tauri でデスクトップアプリアプリを作ってます。
そこで最近、Tauri でデスクトップアプリを開発する際に UI の開発やレビューのサイクルを高速化するために Bridge パターンを導入してみたので、その話をまとめようと思います。
デスクトップアプリ開発の悩み
Web フロントエンド開発と比べて、デスクトップアプリ開発には特有の悩みがあると感じています。
確認の手間
PM やデザイナーに変更内容を確認してもらうために、毎回アプリケーションをビルドする必要があります。
しかし、Web アプリケーションのようにビルドが速くなかったり、アプリケーションのバイナリを配布する必要があるため、この作業が大きな負担となります。
Storybook の限界
UI コンポーネントカタログツールである Storybook は非常に便利ですが、アプリケーション全体の挙動を確認するのは難しいという課題があります。
小さなコンポーネント単位での確認や実装には Storybook が有効ですが、実際の API との接続部分の動作確認は容易ではありません。
リリースの大変さ
Web アプリケーションのように修正後すぐに全ユーザーへ反映できるわけではない点もデスクトップアプリ開発の難しさの一つです。
小さな修正ごとにリリースするフローを取るのは難しく、ユーザーが常に最新バージョンを使ってくれるとも限りません。そのため、頻繁なリリースが難しい場合があり、これはモバイルアプリ開発に近い感覚かもしれません。
これらの課題、特に確認の手間は開発スピードに大きく影響します。なんとか改善したいと考えました。
解決策: Bridge パターンの導入
そこで、 Bridge パターン を導入することにしました。
具体的には、Tauri の API に直接依存している部分を抽象化されたインターフェース(Bridge)に依存するように変更します。
interface の名前は tauri が invoke
で IPC をするので Command
という名前にしています。
このようにすることで、以下のようなメリットがありました。
Tauri への依存排除
UI コードから Tauri 固有の API 呼び出しがなくなります。これにより、UI 部分を Tauri 環境がなくても動作させやすくなります。
Web ブラウザでの動作確認
Tauri 固有の API を Web API ( MediaDevices
や LocalStorage
など) を使った実装に差し替えることで、Web ブラウザ上でもある程度アプリケーションの動作確認が可能になります。例えば、マイクやスピーカーを使う機能を、Tauri 環境ではネイティブ API で、Web ブラウザでは Web API で動作させるといったことができます。
これにより、UI の変更確認のために毎回ビルドする手間が省け、開発サイクルを高速化できます。
実装方法
Bridge パターンの実装方法はいくつか考えられます。
- React Context を使う
フロントエンドが React なので、Context API を使って Bridge の実装を注入する方法です。ただ、今回は React コンポーネント外のピュアな JavaScript/TypeScript モジュールからも利用したかったため、採用しませんでした。 - モジュールスコープで実装を切り替える
アプリケーションの初期化時に、実行環境(Tauri か Web か)を判定し、適切な Bridge の実装を読み込む方法です。
今回は 2. のモジュールスコープで実装を切り替える 方法を採用しました。
具体的には、Vite の import.meta.env.MODE
などを利用して環境を判定し、 dynamic import
を使って必要な実装だけを読み込むようにしました。これにより、不要なコードがバンドルされるのを防ぎます。
import { useMemo } from "react";
import type { AudioCommand } from "./audio";
import type { CoreCommand } from "./core";
import type { LogCommand } from "./log";
import type { OsCommand } from "./os";
import type { UpdaterCommand } from "./updater";
type Command = {
audio: AudioCommand;
core: CoreCommand;
log: LogCommand;
os: OsCommand;
updater: UpdaterCommand;
};
let command: Command | null = null;
export const setCommand = (c: Command) => {
command = c;
};
export const getCommand = T extends keyof Command>(cmd: T): Command[T] => {
if (command === null) {
throw new Error("command is not set");
}
return command[cmd];
};
export const useCommand = T extends keyof Command>(cmd: T): Command[T] => {
const command = useMemo(() => {
return getCommand(cmd);
}, [cmd]);
return command;
};
導入時の問題点と解決策
この方法を導入する際に、一つ問題が発生しました。
Bridge の実装が dynamic import
で非同期に読み込まれるため、モジュールのトップレベルで Bridge の関数を直接呼び出すようなコードがあると、初期化が完了する前に呼び出されてしまいエラーになります。
import { getCommand } from "./bridge";
const osInfo = getCommand("os").type();
export const config = {
os: osInfo,
};
この問題は、Bridge の関数呼び出しを、初期化が完了した後に行われるように遅延させることで解決しました。具体的には、トップレベルでの直接呼び出しをやめ、関数やクラスのメソッド内など、アプリケーションの実行フローの中で呼び出すようにします。
import { getCommand } from "./bridge";
export const getConfig = () => {
const osInfo = getCommand("os").type();
return {
os: osInfo.type,
};
};
ボイラープレートの自動化
Bridge パターンを導入することで、新しい機能を追加するたびに対応する Bridge インターフェースとその実装(Tauri 用と Web 用)を作成する必要が出てきました。この作業はパターン化されており、ボイラープレートコードが多くなりがちです。
そこで、plop というコード生成ツールを導入して、これらのボイラープレートコードを自動生成できるようにしました。
export default (
plop
) => {
plop.setGenerator("bridge-context", {
description: "Generate a new context + type for a Bridge feature.",
prompts: [
{
type: "input",
name: "name",
message: "Bridge name:",
},
],
actions: [
{
type: "add",
path: "src/bridge/{{kebabCase name}}/index.ts",
templateFile: "plop-templates/bridge/type.hbs",
},
{
type: "add",
path: "src/bridge/{{kebabCase name}}/tauri.ts",
templateFile: "plop-templates/bridge/impl.hbs",
},
{
type: "add",
path: "src/bridge/{{kebabCase name}}/web.ts",
templateFile: "plop-templates/bridge/impl.hbs",
},
],
});
};
テンプレートの例:
// plop-templates/bridge/type.hbs
export type
Command = {
// ここにメソッドや型を記述します
}
// plop-templates/bridge/impl.hbs
import type {
Command
} from '.';
export const command: Command = {
// ここに実装を記述します
};
これにより、以下のようなコマンドで新しい Bridge コマンドを簡単に追加できるようになりました:
$ bun generate:bridge
? Bridge name: Example
✔ ++ /src/bridge/example/index.ts
✔ ++ /src/bridge/example/tauri.ts
✔ ++ /src/bridge/example/web.ts
このアプローチには、以下のようなメリットがあります:
- 開発スピードの向上
- 新機能のためのコードスケルトンを素早く生成できます
- 一貫性の確保
- 全ての Bridge コマンドが同じパターンで実装され、一貫性が保たれます
- ミスの低減
- 必要なファイルやインポート文の追加漏れなどのミスを減らせます
Web 環境と Tauri 環境の実装差異
これでフロントエンド側には Tauri の API との依存がなくなったためブラウザで動かすことができるようになりました。
しかし、Tauri の環境でしか動かせない機能(FileSystem にアクセスするなど)もあるため完全に再現はできません。
再現できた機能
オーディオ関連の機能は、Web ブラウザでも Web Audio API を利用することで Tauri 環境と同様の挙動をある程度再現できました。
export interface AudioIOManager {
connectDevice: (id: string) => PromiseAudioNode>;
disconnect: () => void;
}
export type AudioCommand = {
createInput: (audioContext: AudioContext) => AudioIOManager;
createOutput: (audioContext: AudioContext) => AudioIOManager;
};
再現が難しい機能
一方で、以下のような機能は Web ブラウザでの再現が難しいものでした
OS 情報の取得
import { type } from "@tauri-apps/api/os";
export const command: OsCommand = {
type,
};
export const command: OsCommand = {
type: async () => {
console.warn("Web環境では OS type の取得はサポートされていません");
return "macos";
},
};
ファイルシステムへのアクセス
import { writeTextFile } from "@tauri-apps/api/fs";
export const command: FsCommand = {
writeTextFile,
};
export const command: FsCommand = {
writeTextFile: async () => {
console.log("Web環境ではファイルへの書き込みはサポートされていません");
},
};
このように、Web ブラウザの制約によって再現できない機能については、適切なログメッセージを出力したり、可能な範囲で代替実装を提供したりすることで対応しました。開発中はこのような差異があることを理解した上で、UI の確認を進めることができます。
導入してみての感想
Bridge パターンを導入した結果、以下のような効果がありました。
開発スピードの向上
UI の変更確認のために Tauri アプリをビルドする必要がなくなり、Web ブラウザでほとんどの確認が完結するようになりました。これにより、待ち時間が大幅に削減されました。
テスト導入への期待
これまでは Tauri 環境でしか動作確認が難しかった部分も、Web ブラウザで動作するようになったことで、Playwright のようなブラウザベースの E2E テストツールを導入しやすくなりました。
まだ、導入していませんが今後導入をしてリリース前の挙動の担保や意図しない変更を CI で防げるようにしたいと思っています。
まとめ
今回は、Tauri アプリ開発の課題を解決するために Bridge パターンを導入した話を紹介しました。
フロントエンド開発ではあまり意識しないかもしれませんが、依存性逆転の原則のようなソフトウェア設計の基本原則が、このような場面で非常に役立つことを再認識しました。
CoeFont ではデスクトップアプリの他にもWebアプリケーションを一緒に作ってくれる仲間を募集しています!
興味のある方は下の応募フォームからご連絡ください!
Views: 1