4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

依存DLLゼロの単体exeで配る ― Windows×Rustで「全部入り」を1ファイルにする舞台裏

4
Posted at

はじめに

(本記事は、Rustもネイティブ開発も未経験の自分がAIに任せて画像ビューワmIVを作った記録シリーズの3本目です。① Rustも未経験のままWindowsアプリを作った体験記/② 詰まったとき・失敗したときの対処

mIVは、インストール不要で、ダブルクリックすれば動く単体exeでも配布しています。これは個人的に、けっこうこだわった部分でした。やりたかったのは、たとえばUSBメモリに、画像やPDFと一緒に実行ファイルを入れておいて、ノートPCに挿せばその場でサッと見られる——そんな使い方です。インストール作業もPCへの環境構築もなしに、ファイルと一緒に"ビューワごと持ち運ぶ"イメージですね。

ところが、mIVは中身がなかなかの重量級です。PDF表示エンジン、AI推論ランタイム、AIモデル、動画再生のためのFFmpeg、さらに32bitのプラグイン用ワーカー……と、抱えている依存物が山ほどある。これを全部1つのexeに詰め込んで、しかも追加インストールゼロで動かす。今回は、その「全部入り単体exe」をどう実現したか、特にいちばんハマったFFmpegまわりの話を書きます。

(Rust + Windowsネイティブの話ですが、配布の考え方自体は他の言語でも応用が効くと思います。)

基本戦法:include_bytes! で、何もかも埋め込む

Rustには include_bytes! という、ファイルの中身をコンパイル時にexeへ丸ごと埋め込む仕組みがあります。mIVはこれを多用して、外部ファイルをexeの中に抱え込んでいます。

  • PDF表示エンジンのDLL
  • AI推論ランタイムのDLL
  • AIモデル(アップスケールやノイズ除去などの学習済みデータ)
  • 32bitのプラグイン用ワーカー(これは後で詳しく)

これらは、初回起動時にユーザーごとのデータ領域(%APPDATA%)へ書き出してから使います。

実際の書き出しは、もう少し丁寧にやっています。

  • すでに展開済みかどうかを、ファイルサイズ+中身のハッシュ(SHA256)で確認し、一致すればスキップ。だから2回目以降の起動は速い。
  • 書き出しは、いったん一時ファイルに書いてから、本来の名前に付け替える(rename)。こうすると、書き込みの途中で何かあっても、中途半端に壊れたファイルが残りにくい。

この方式の利点は、配布物がexe 1個で済むこと。そして、利用者のPCに余計なものをインストールしないことです。

VC++再頒布を消す:crt-static

Windowsでネイティブアプリを配ると、よくぶつかるのが「このアプリを実行するには Visual C++ 再頒布可能パッケージが必要です」問題です。VCRUNTIME140.dll がない、というアレ。これが出ると、利用者に「まずこれを入れてください」とお願いすることになり、"単体で動く"が崩れてしまいます。

対処は、Cランタイムを静的リンクする(crt-static)こと。これでVC++再頒布パッケージへの依存が消えます。仕上げに dumpbin /dependents でexeの依存DLLを点検し、VCRUNTIME140.dll などが並んでいないことを確認しています。

(余談:AI推論ランタイムを"動的にロードする"設定と、このcrt-staticは、組み合わせが少し繊細でした。静的リンク版だと前提が噛み合わず、動的ロードでないと両立しなかったので、ここは今も触らないようにしています。)

なぜ"プラグインだけ"別exeなのか ― 32bitの壁

mIVは、古い画像ローダの規格「Susieプラグイン」(.spi)にも対応しています。これがちょっと厄介でした。

Susieプラグインは、32bitのDLLです。一方、mIV本体は64bit。そして64bitのプロセスは、32bitのDLLを読み込めません(OSレベルの制約で、どうやっても直接は無理)。

そこで取った手が、32bitの小さなワーカーexeを別に用意し、本体から子プロセスとして起動して、画像のデコードだけを頼む形でした。本体とワーカーは、標準入出力を通したやり取り(IPC)で会話します。このワーカーexeも、もちろん include_bytes! で本体に埋め込み、初回に展開しています。

実はmIVは、この「別プロセスにして、IPCで会話する」パターンを、あちこちで使い回しています。最初がこのSusie(32bitだから仕方なく)で、その後に、

  • PDFの描画(並列化して速くするために、複数プロセスで)
  • 動画のVSTプラグインや、AIのTensorRT(万一クラッシュしても、本体を巻き込まないように)

理由はそれぞれ違いますが、「重いもの・相性の悪いもの・別世界のものは、別プロセスに隔離する」という発想は共通しています。これはこれで、AIに任せた開発の中で自然と固まっていった設計でした。

PDFは"5つのプロセス"で並列に描く

「別プロセス+IPC」がいちばん効いたのが、PDFの表示でした。

PDFの描画には PDFium(Google ChromeのPDFエンジン)を使っています。これが2つ、やっかいな性質を持っていました。1つはスレッドセーフではないこと——1つのプロセスの中で複数スレッドから同時に呼ぶと壊れます。もう1つは、立ち上がりが重いこと。最初の1ページを描くまでに、実測で1.4秒以上かかっていました。PDFがフォルダにずらっと並んでいると、サムネイルがなかなか出てこなくて、かなりのストレスです。

そこで、mIVのexe自身を"PDF描画専用モード"で5つ起動し、ワーカープロセスのプールにすることにしました。各プロセスが独立にPDFiumを1つずつ抱えるので、スレッドセーフ問題を気にせず、5枚を同時に描けます。立ち上がりの重さも、あらかじめプロセスを起動して温めておけば、2枚目以降は1ページあたり10ミリ秒ほど。最初の体感が、ほぼ一瞬になりました(cold時の約1.4秒から、99%の短縮です)。

(このワーカーは、別途専用のexeを用意しているわけではありません。mIV自身が起動時に"PDF描画モードの引数"が付いているかを見て、付いていればワーカーとして振る舞う、という作り。配布物を増やさずに済みます。)

地味ですが効いている工夫として、5つのうち1つは「今まさに開いたページ」専用に予約しています。グリッドの先読みが残り4つを全部埋めても、ユーザーがEnterで開いたPDFだけは、予約枠ですぐ描ける。待ち行列も、ただの早い者勝ちではなく優先度つきにして、「今すぐ見たいページ」が後回しにならないようにしてあります。

(余談:このPDFワーカー、最初はAIが"資源が空くまで短い待機をはさんで取り続ける"——第2回で書いた、あのビジーループで書いていました。10秒フリーズの正体は、まさにこれです。今は優先度つきの順番待ちに直してあります。)

最大の難所:FFmpegだけ、素直に埋め込めない

動画再生に使うFFmpegだけは、ここまでの「埋め込んで初回展開」が通じませんでした。ここが今回いちばんの山です。

まず前提として、FFmpegは別ファイルのDLLとして持っています。これはライセンスの都合です。mIV本体はMITですが、動画再生に使うFFmpegはLGPL。LGPLのライブラリには、「利用者が、そのライブラリ部分を自分で別のものに差し替えられるようにしておく」という条件があります。別ファイルのDLL(動的リンク)にしておけば、DLLを置き換えるだけでこの条件を満たせる。exeに静的に取り込む形も不可能ではないのですが、その場合は"あとから差し替え(再リンク)できる材料"を別途配る必要があって面倒です。なので、FFmpegはDLLのまま扱っています。

問題は、そのDLLが読み込まれるタイミングでした。mIV本体は、起動した瞬間にFFmpegのDLLを要求します。本体のコードが1行も動く前に、OSのローダがDLLを探しに行くのです。つまり「起動してから %APPDATA% に展開して、それから使う」では、間に合わない。せっかくexeの中に埋め込んでいても、それを取り出すコードが走る前に必要とされるので、意味がありません。

これは推測ではなく、ランチャーのソースの冒頭コメントにそのまま書いてあります——〈本体はプロセスのロード時にFFmpeg DLLをimportする。だからランチャーは、先にDLLを展開してから本体を起動する〉と。

("読み込みを遅らせる"仕組み(遅延ロード)も検討しましたが、Rustのビルド経由ではうまく機能せず、断念しました。)

解決:2段ロケットにする(ランチャー+本体)

最終的に取った手は、exeを2段構成にすることでした。

  1. 利用者がダブルクリックする mimageviewer.exe は、実はランチャーです。中身は軽く、FFmpegの関数を一切呼びません。だから、ローダのDLL要求にそもそも引っかからない。
  2. このランチャーが、include_bytes! で抱えている本体exe+FFmpegのDLL(6個)を、起動時に %APPDATA% のバージョンごとのフォルダへ展開します。
  3. 展開が終わったら、ランチャーは本体exeを起動(spawn)し、自分は役目を終えて消えます。

ポイントは、本体を起動する時点で、もう同じフォルダにFFmpegのDLLが揃っていること。Windowsのローダは「exeと同じ場所のDLL」を最優先で探すので、本体は何の小細工もなく、素直に読み込めます。「ローダがコードより先に動く」問題を、"FFmpegを呼ばない係(ランチャー)"と"呼ぶ係(本体)"に分業することで回避したわけです。

細かい工夫もいくつか。

  • 展開先をバージョンごとのフォルダに分けています。古い本体が動いている最中に、新しいランチャーが同じファイルを上書きしようとして「使用中」で失敗するのを避けるためです。
  • ビルドの順番も決まっています。先に本体をビルド → それをランチャーに埋め込む、の2段階。順番を間違えると、埋め込むものが無くてビルドが止まります。

ちなみに、埋め込んでいるFFmpegのDLLは6個(avcodec / avformat / avutil / avfilter / swscale / swresample)。映像・音声のコーデックや、形式変換・リサイズなどを担当しています。

ライセンス(LGPL)への対応

FFmpegを配る以上、LGPLはきちんと守る必要があります。mIVは以下を徹底しています。

  • 動的リンク(別DLL)の形を保つ:上記のとおり、DLLとして展開して読み込む。
  • DLLのファイル名を変えない:オリジナルの名前のまま展開・配布する。
  • 対応するソースを入手可能にする:ビルドに使われたFFmpegのソースを、自分のサイトに置いている。
  • GPL版を絶対に使わない:FFmpegには機能多めのGPL版ビルドもありますが、それを使うとmIV全体がGPLに縛られてしまうので、LGPL版だけを使う。

まとめ:単体exeのトレードオフ

こうして、PDFもAIも動画も全部入りで、追加インストール不要の単体exeができました。引き換えはサイズです。配布exeは約365MB。たとえばFFmpegのDLLだけで合計約110MB(うち、コーデックを担うavcodecが約63MBと飛び抜けて大きい)、そこにAIモデルやPDFエンジンが乗るので、どうしても大きくなります。

「1ファイルで完結する手軽さ」と「サイズ」はトレードオフですが、私は手軽さを取りました。配るときに「これ落として、ダブルクリックするだけ」と言えるのは、やっぱり気持ちがいいです。

ここまで3回にわたって、mIVを題材に、AIでの開発・運用・配布の話を書いてきました。専門外だらけのスタートでしたが、AIと一緒なら、配布まわりの細かい罠まで含めて、なんとか形にできるものだなと思います。少しでも、自作アプリを世に出してみたい人の参考になれば嬉しいです。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?