[AI時代のFlutter開発スペシャル] 「Flutter デスクトップアプリで遊んでみたら意外となんでもできた」 で発表しました

[AI時代のFlutter開発スペシャル] 「Flutter デスクトップアプリで遊んでみたら意外となんでもできた」 で発表しました

Flutter Desktopで意外となんでもできる!——その可能性を7つの機能デモと2つの実践アプリ、そしてVSCode拡張との連携を通じて紹介します。モバイル開発者の視点から、Desktop環境ならではのハマりどころも含めた実装ノウハウをお届けします。
2026.05.16

こんにちは。きんくまです!
リテールアプリ共創部マッハグループ所属です。

2026年5月11日 クラスメソッド主催の AI時代のFlutter開発スペシャル というイベントで、Flutter デスクトップアプリで遊んでみたら意外となんでもできた というタイトルで発表しました!

スライドです

https://speakerdeck.com/tasukumaedacm/flutter-desukutotupuapurideyou-ndemitarayi-wai-tonandemodekita

この記事は、その発表内容を1本にまとめた書き起こし版です。発表ではデモ中心だったので、ブログでは実装のポイントを順に追っていきます。

長めの記事ですが、各デモごとに完結しているので、気になる所だけ読んでいただいても大丈夫です〜

この記事で扱う3つのデモ

パート 内容
デモ1 Flutter Desktop でためした 7つの機能 を紹介
デモ2 デモ1の機能を組み合わせた 本格的な画像ビューア アプリ
デモ3 VSCode 拡張 × Claude × VOICEVOX × Flame で ずんだもんソースコード解説 ジェネレータ

デモ1のソースコードは GitHub に公開しています。

https://github.com/cm-tsmaeda/flutter-desktop-meetup-20260511

ご注意

  • すべて macOS 版のみ です。Windows では検証していません
  • デモはすべて Claude Code で作成したものです
  • なので概要や自分が疑問に思った部分は調べたりCCに聞いたりして理解していますが、実装の細かい部分まで把握しきれていない箇所があります。ご了承ください

Flutter Desktop のはじめかた

ふだんはモバイルアプリ開発をやっている私ですが、今回はじめて Flutter Desktop を試してみました!

プロジェクト作成

# macOS 用プロジェクトを作成
fvm flutter create --platforms=macos myapp
cd myapp

ふだんは Flutter SDK の管理に fvm を使っているので fvm 付きで書いていますが、もちろん fvm なしでも大丈夫です。

iOS / Android 開発と同じ感覚で始められます〜

Debug と Release

# Debug 起動(ホットリロード可)
fvm flutter run -d macos

# Release ビルド
fvm flutter build macos --release
# → build/macos/Build/Products/Release/myapp.app
  • Debug はホットリロードが効くので、開発中はこっち
  • Release ビルドは .app バンドルが書き出されて、Finder からダブルクリックで起動できる

ご注意!

Debug では動くのに Release(Finder 起動)でうまく動かない、という現象に何度か遭遇しました。デモ1のハマりどころで具体例が出てきます〜


デモ1:Flutter Desktop でためした7つの機能

260516_demo1-feature-list

まずデモ1です。Flutter Desktop で「こんなことができるよ!」というのを7つの機能で紹介していきます。

  1. Window — ウィンドウの操作・制御
  2. System Tray — メニューバーに常駐
  3. Drag & Drop — ファイルを D&D で受け取る
  4. File Explorer — ローカルファイルを閲覧
  5. Local Server — HTTP サーバーを起動
  6. curl — HTTP リクエストを実行
  7. Claude — Claude CLI を呼び出す

実際の動きは動画で見ていただくのがいちばん早いです。UI・ファイル系と通信・外部連携系の2本に分けています。

デモ動画① UI・ファイル系(Window / System Tray / Drag & Drop / File Explorer)

https://youtu.be/0UcUY6jr2Lc

デモ動画② 通信・外部連携系(Local Server / curl / Claude)

https://youtu.be/uy3PfaNYOOg

ではここから、1つずつ実装を見ていきます。

1. Window — ウィンドウの操作・制御

260516_demo1-01-window

ウィンドウのサイズ・位置・常時最前面・透明度を制御できます。さらに「自プロセスをもう1つ起動」して別ウィンドウを開くこともできます(別アプリ扱いになります)。

実装

📦 window_manager を使います。

// ウィンドウ操作
await windowManager.setSize(const Size(800, 600));
await windowManager.setAlwaysOnTop(true);
await windowManager.setOpacity(0.8);

// 「新しいウィンドウ」ではなく、自プロセスをもう1つ起動
// これは window_manager ではなく dart:io の機能
await Process.start(Platform.resolvedExecutable, []);

ハマりどころ

2. System Tray — メニューバーに常駐

260516_demo1-02-system-tray

macOS のメニューバーにアイコンを常駐させられます。サブメニュー付きのメニューも作れて、各 MenuItem のクリックも受け取れます。

実装

📦 tray_manager を使います。

class _State extends State<MyPage> with TrayListener {
  Future<void> _setupTray() async {
    await trayManager.setIcon('assets/tray_icon.png'); // Flutter assets のパスを渡す
    await trayManager.setContextMenu(Menu(items: [
      MenuItem(label: 'あいさつ', key: 'greet'),
      MenuItem(label: 'モード', submenu: Menu(items: [/* ... */])),
    ]));
  }

  @override
  void onTrayIconMouseDown() => trayManager.popUpContextMenu();
}

ハマりどころ(Directory.current 罠)

これがさっき書いた「Debug と Release で挙動が違う」のひとつ目です。Debug 起動と Finder 起動でカレントディレクトリが変わるので、絶対パスを組み立てて引数に渡そうとすると、Release だけクラッシュします!

3. Drag & Drop — ファイルを D&D で受け取る

260516_demo1-03-drag-drop

ファイル / ディレクトリの D&D を受け取れます。複数ファイル同時、ドラッグ中の hover フィードバックにも対応できます。

実装

📦 desktop_drop を使います。

DropTarget(
  onDragEntered: (_) => setState(() => _isDragging = true),
  onDragExited:  (_) => setState(() => _isDragging = false),
  onDragDone: (DropDoneDetails details) {
    for (final xFile in details.files) {
      final file = File(xFile.path); // 絶対パスが取れる
      // ...
    }
  },
  child: /* ドロップエリア */,
)

ハマりどころ

4. File Explorer — ローカルファイルを閲覧

260516_demo1-04-file-explorer

ローカルファイルの一覧・閲覧ができて、テキストファイルは シンタックスハイライト付き で表示しています。

実装

📦 dart:io(一覧・読込)
📦 highlight(パース)+ flutter_highlight(Monokai テーマだけ拝借)

// ディレクトリ一覧(フォルダ優先で名前順ソートなどができる)
final entries = Directory(path).listSync()
  ..sort((a, b) { /* ... */ });

// ファイル読込
final content = await File(path).readAsString();

// シンタックスハイライト
final result = highlight.parse(content, language: lang);
return SelectableText.rich(_toSpans(result.nodes, theme));

ハマりどころ

ふつうにシンタックスハイライトを見せたいだけなら HighlightView ですごく簡単に実装可能です。なのですが、コードをコピーしたかったので、AST = Abstract Syntax Tree(抽象構文木) から自前で TextSpan を組み立てる方式にしました。

5. Local Server — HTTP サーバーを起動

260516_demo1-05-local-server

Flutter アプリの中で HTTP サーバーが立ちます。普通のブラウザアプリ(外部のChrome)や curl からアクセスできるし、リクエストログをアプリ画面に流すこともできます。

実装

📦 dart:ioHttpServer追加パッケージ不要!

final server = await HttpServer.bind(
  InternetAddress.loopbackIPv4, 8080,
);
server.listen((HttpRequest request) {
  request.response
    ..headers.contentType = ContentType.json
    ..write(jsonEncode({'message': 'Hello from Flutter'}))
    ..close();
});

実は iOS / Android でも HTTP サーバーは立てられるのですが、外部からアクセスしづらいです。デスクトップアプリなら、同じマシンの別ブラウザから普通にアクセスできるのが良いなって思いました。

ハマりどころ

6. curl — 外部コマンドを実行

260516_demo1-06-curl

外部コマンドの curl を Flutter から実行して、結果を画面に表示します。HTTP クライアント(dioとか)を使わなくても外の世界と通信できる、というネタです。

実装

📦 dart:ioProcess.run

final result = await Process.run('curl', ['-s', url]);
final body = result.stdout as String;

ハマりどころ

7. Claude CLI 連携

260516_demo1-07-claude

Claude Codeを claude -p を呼び出して、レスポンスを Markdown でレンダリングします。Flutter から AI を呼べる! という、デモ3 への伏線でもあります。

実装

📦 dart:ioProcess.run + flutter_markdown_plus(表示)

// Finder ダブルクリック起動でも PATH を解決するため zsh -ilc 経由で呼ぶ
final result = await Process.run(
  '/bin/zsh',
  ['-ilc', 'claude -p "\$1"', 'flutter-app', prompt],
);

// Claude の応答(Markdown)をレンダリング
return Markdown(
  data: result.stdout as String,
  selectable: true,                 // 選択コピー対応
  styleSheet: MarkdownStyleSheet(/* h1 / code 等のスタイル指定 */),
);

ハマりどころ

これも「Debug と Release で挙動が違う」現象でした。VSCode から flutter run で起動するとシェル経由で立ち上がるので PATH がそのまま引き継がれるんですが、Finder からダブルクリックすると 何も無い PATH で起動します。いわゆるPATHが通っている、通ってない問題がおこるのでそこだけ注意です!


デモ2:画像ビューアアプリ

デモ1で紹介した機能を組み合わせて、実用的な画像ビューアアプリを作ってみました。

デモ動画

https://youtu.be/Q7jnEAr-kF4

どんなアプリ?

260516_demo2-image-viewer

  • 入力: Drag&Drop または macOS のメニューから NSOpenPanel
  • 対応形式: PNG / JPEG / GIF / WebP / ZIP
  • 並び順: 名前順 / 更新日時
  • 表示: 全体 / 画面いっぱい / 実寸
  • shared_preferences で設定と最終ソースを永続化(次回起動時に自動復元)

いくつか実装で工夫したポイントを紹介!

4つのこだわり実装ポイント

  1. ファイル選択MethodChannelNSOpenPanel を直接叩く(ファイル / ディレクトリを 1 ダイアログで両対応)
  2. マウスホイール対応 — スクロール / ピンチ操作
  3. キーボードショートカット対応 — ページ送り / 表示モード切替などをキー1つで
  4. 画像 ZIP 対応archive パッケージで ZIP 内画像を直接 ImageProvider

それぞれ実装の中身を見ていきます。

① ファイル選択(NSOpenPanel を直接叩く)

macos/Runner/MainFlutterWindow.swift
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true        // ← ファイル「も」ディレクトリ「も」OK
panel.allowsMultipleSelection = true
panel.allowedFileTypes = ["png","jpg","jpeg","gif","webp","zip"]
panel.beginSheetModal(for: self) { _ in result(panel.urls.map { $0.path }) }
lib/data/services/file_dialog_service.dart
final paths = await MethodChannel('image_viewer/file_dialog')
    .invokeListMethod<String>('openPaths');

file_picker ライブラリだとすごく簡単に実装できます。
ですが、こちらだとファイルだけを開くダイアログ。ディレクトリだけを開くダイアログと別々のダイアログになってしまいます。
NSOpenPanel を直接叩くと 1 つのダイアログで両対応 にできるので、ユーザー体験的にこっちにしたかったのでそうしました。
モバイルでもよくやるMethodChannelがmacにも対応してます!

② マウスホイール対応

📦 Flutter 標準の Listener + PointerScrollEvent

lib/viewer_page.dart
Listener(
  onPointerSignal: (event) {
    if (event is! PointerScrollEvent) return;
    if (event.scrollDelta.dy > 0) {
      notifier.showNext();      // ↓ スクロール = 次の画像
    } else if (event.scrollDelta.dy < 0) {
      notifier.showPrevious();  // ↑ スクロール = 前の画像
    }
  },
  child: /* ... */,
)

マウスホイールで「画像送り」ができます。トラックパッドのスクロールでも同じ動きをします。
最初は、スクロール量でいろいろやろうとしてみたのですが、結局スクロールを検知したら1枚進めるって方が体感としてはよかったです。

③ キーボードショートカット

ショートカットは 2系統 で実装しました。

lib/menus/app_menu_bar.dart
// ① macOS メニューと連動(メニューに ⌘O が表示される)
PlatformMenuBar(menus: [
  PlatformMenu(label: 'ファイル', menus: [
    PlatformMenuItem(
      label: '開く...',
      shortcut: const SingleActivator(LogicalKeyboardKey.keyO, meta: true),
      onSelected: notifier.openFromMenu,
    ),
  ]),
]);
lib/widgets/viewer_page.dart
// ② メニューに出さないキーは Focus で直接処理
Focus(onKeyEvent: (node, event) {
  if (event.logicalKey == LogicalKeyboardKey.arrowLeft)  { notifier.showPrevious(); return KeyEventResult.handled; }
  if (event.logicalKey == LogicalKeyboardKey.arrowRight) { notifier.showNext();     return KeyEventResult.handled; }
  // = / - / 0 でズーム操作
  return KeyEventResult.ignored;
}, child: /* ... */);
  • ① の PlatformMenuBar はメニューバーにショートカットが表示されるので、使い手にも「このキーが効くよ」と伝えられる
  • ② の Focus は矢印キー連打みたいな、メニューに出すほどでもない操作向き

上の方はmacの画面の上の方に出るメニューですね。下の方は、いわゆるキーボードショートカットです。自分でいろいろ定義できるので良いですね。

④ 画像 ZIP 対応

📦 archive パッケージ + 自前の ImageProvider

// ZIP を開く(中身の解凍は遅延、find したものだけ展開される)
final input = InputFileStream(zipPath);
final archive = ZipDecoder().decodeStream(input);
Uint8List readBytes(String entryName) => archive.find(entryName)!.content;

// ImageProvider を継承して、ZIP 内のエントリを画像として返す
class ArchiveImageProvider extends ImageProvider<ArchiveImageProvider> {
  Future<Codec> _loadAsync(/* ... */) async {
    final bytes = source.readBytes(entryName);                  // ZIP から取り出す
    final buffer = await ImmutableBuffer.fromUint8List(bytes);  // メモリへ
    return decode(buffer);                                      // → Flutter の Image へ
  }
}
  • ZIP の中身は一度に全展開しない(表示時に必要なエントリだけ readBytes して画像化)
  • ImageProvider を実装してあるので、Image()image: に渡せて、上位は 「ZIP かどうか」を意識しなくていい

Claude Codeに全部やってもらいましたが、ZipファイルをFlutterから扱えたのは結構びっくりで、こんなことできるんだ〜!って感じでした。
ImageProvider っていう抽象クラスを継承してあげると、Image ウィジェットに渡せる形になります。


デモ3:VSCode 拡張 × ずんだもん解説

最後はデモ映えするネタを作ってみました!
VSCode 拡張機能の中で Flutter Web を動かして、選択中のコードをずんだもん × 四国めたんが掛け合いで解説してくれる というやつです。

補足するとここだけ Flutter Web で、Flutter Desktop ではありません!ただデスクトップ連携の技術をたくさん使っているので、今回の発表に入れてみました。

デモ動画

https://youtu.be/3qwrJnNTdg8

操作フローはこんな感じです。

  1. 適当なコードを範囲選択(左下にチップが自動表示)
  2. 質問を入力 → 「解説して!」 ボタン
  3. ずんだもんが進捗を実況:「Claude さんに依頼中なのだ ...」「合成中 1/8 ...」
  4. 「準備できたのだ〜!画面をクリックで再生開始するのだ!▼」 で告知
  5. ステージクリック → 字幕 + 口パク + 音声同期で会話劇スタート

1:14ぐらいから同じ内容を繰り返しているのですが、ここは途中で一時停止もできるという機能紹介となります。

今回画像は、ChatGPTのImages 2.0で作成しました。Sprite Sheet(1枚に何枚も画像をのせるやつ)で作ってます。
ゲームボーイアドバンス(GBA)風のドット感のあるスタイルにしました〜。

何をしているの?

260516_demo3-vscode-zundamon

  • VSCode の 下パネル に解説タブを追加
  • コードを範囲選択 → 質問を書いて 「解説して!」 ボタン
  • ずんだもん × 四国めたんが 掛け合いで解説(動的に作成した音声 + 字幕 + 口パクをリアルタイムで再生)
  • プロジェクトの CLAUDE.md も Claude に読まれる → コードベースに即した解説 になる

VSCode の拡張機能を作ってみました。エディタの選択状態を読み取って、自動でパネルに反映します。またCLAUDE.md も Claude が読んでくれるので、プロジェクトの文脈に沿った解説が出てきます。

前提知識① VSCode 拡張機能はどう作る?

今回はじめてVSCode 拡張機能(extension)を作ってみたんですが、中身はこんな構成になっています。

  • 本体(拡張ホスト): Node.js / TypeScript で動く
    • VSCode の API(エディタ操作、コマンド登録、選択範囲取得など)を呼べる
  • GUI: WebView で HTML / JS / CSS を表示するのが標準
    • 拡張ホスト ↔ WebView は postMessage で双方向通信

WebView は SafariViewController みたいなもので、HTML を独自にホストしているイメージです。

→ 今回は この WebView の中身を Flutter Web で作りましたflutter build web の成果物を WebView に読ませる形です。Flutter Webもはじめて作ったのですが、なんとか動いてくれて良かったです!(最初は動かすまでいろいろとハマった、、)

前提知識② VOICEVOX とは

VOICEVOX無料で使える テキスト音声合成エンジンです。

  • ローカル PC で動く(オフライン・無料)
  • ずんだもん / 四国めたん など 30 種類以上のキャラ音声
  • ローカル HTTP API(http://127.0.0.1:50021)で操作
# テキストからクエリ生成
POST /audio_query?text=こんにちは&speaker=3

# クエリから音声合成(WAV 取得)
POST /synthesis?speaker=3

面白いのが、APIで音声を作成できるところですね。今回はワークフローの流れの中で使いました。
商用利用も条件付きで可能です。今回はキャラのセリフ生成に使いました。

前提知識③ Flame ライブラリとは

📦 Flame は Flutter 用の 2D ゲームエンジン です。

  • スプライトアニメーション / 衝突判定 / シーン管理
  • Component ベース の設計(FlameGameComponent を追加していく)
  • update(dt)各フレームごとの処理 を書ける
  • ゲームに限らず タイミング制御が必要なアニメ に向く

普通の Flutter Widget だと「秒数ベースのアニメ更新」がちょっと書きにくいんですが、Flame だと素直に書けます。今回は 口パクアニメーション字幕の文字送り に使いました。

全体構成

260516_demo3-architecture

パイプライン(処理の流れ)

ボタンを押してから動画が再生されるまでに、内部ではこんな処理が走っています。

  1. VSCode 拡張 でエディタ選択 + プロンプトを取得
  2. claude -p --output-format jsonシナリオ JSON を生成
    • [{speaker: 'zundamon', text: '...'}, {speaker: 'metan', text: '...'}, ...]
    • cwd を workspace folder に設定 → プロジェクトの CLAUDE.md を引き継ぐ
  3. VOICEVOX のローカル API で各セリフを WAV 化 → /connect_waves で 1 本に連結
  4. Flutter Web (Flame) が WebView で再生
    • AudioPlayer 1 個 + seek(offsetMs) でセリフ切替
    • 字幕送り (durationMs / textLength) + 話者で口パク切替

ハマりどころ① ブラウザの autoplay policy

最初は素直に「セリフごとに AudioPlayer を作って play() を順次呼ぶ」方式で実装したんですが…

原因を調べていくと、ブラウザは「ユーザー操作と紐付いた <audio> 要素」しか自動再生を許可しない、という仕様にぶつかります。audioplayers の Web 実装は AudioPlayer ごとに <audio> 要素を作るので、2 個目以降は別要素扱いでブロックされるわけです。

解決策はこちら。

こうすれば、最初のクリックで取った再生権限を全セリフ通して使い回せます。

ここは昔自分でコード書いてたときに、音声ファイルを扱う案件があって覚えてたノウハウですね。

ハマりどころ② WebView の音声 URL と CSP

合成した WAV を WebView から再生するには、2 つの壁 があります。

(1) localResourceRoots に書き出し先を追加

webview.options = {
  localResourceRoots: [flutterRoot, audioRoot], // ← audioRoot を追加
};
const url = webview.asWebviewUri(vscode.Uri.file(wavPath)).toString();

(2) CSP に media-src を追加(忘れると <audio> が黙殺される)

media-src ${webview.cspSource} blob:

書き出し先は context.storageUri(workspace storage)を使うのがおすすめです。VSCode 側で管理してくれるので、後片付けが楽になります。

Flame での同期タイムライン

260516_demo3-flame

1 本の WAV 上に各セリフが offsetMs から並んでいて、字幕・口パクも同じ durationMs で同期させます。

実装はこんなイメージです。

📦 flame(ゲームエンジン本体)
📦 audioplayers(音声再生、独立パッケージ。flame_audio は今回使わず)

// セットアップは 1 回だけ
final player = AudioPlayer();
await player.setSource(UrlSource(audioUrl));

// 各セリフ: seek → resume → durationMs 待つ → pause
await player.seek(Duration(milliseconds: entry.offsetMs));
await player.resume();
await Future.delayed(Duration(milliseconds: entry.durationMs));
await player.pause();
  • 口パク: SpriteAnimationComponent.playing = true/false で話者切替
  • 字幕送り: update(dt)durationMs / textLength の速度で文字を進める

flame_audio ではなく素の audioplayers を直接使っているのは、seek のタイミング制御を自前で握りたかったからです。


まとめ

Flutter Desktop は意外となんでもできた!

  • iOS / Android 開発者でも入りやすい
  • 各種ライブラリが充実している
  • ターミナルと連動できるので、いろいろ作りやすい
  • 自分の思い通りのアプリを気軽に作れる

ふだんモバイルアプリしか触っていなかったんですが、Flutter Desktop はいろいろと作れて面白かったです!ライブラリも結構充実してて、手軽にいろいろと作れます。

あと Debug と Release で挙動が違う系のハマりどころ があるので、書き出しビルドしてちゃんと動作確認した方が良いと思います。

ふだん利用しているアプリで「ここがこうだったらな〜」とか。「こういうアプリがあったら便利なのに〜」とか思ったら、Flutter Desktopでわりとすぐにちょこちょこ作れると思います。

リンクとお知らせ

ではでは。


生成AI活用はクラスメソッドにお任せ

過去に支援してきた生成AIの支援実績100+を元にホワイトペーパーを作成しました。御社が抱えている課題のうち、どれが解決できて、どのようなサービスが受けられるのか?4つのフェーズに分けてまとめています。どうぞお気軽にご覧ください。

生成AI資料イメージ

無料でダウンロードする

この記事をシェアする

関連記事