雑誌のテーマからプロジェクト実践へ
該当記事に関連するサービス・技術ページ
「ちょっとWeb-UIを埋め込むだけでいい——ログイン、レポート、小さな社内ポータル。」まさにこの段階で、Delphi WebView2 FMXは実際のWindowsクライアントで定期的に不安定になります。最初のURLのレンダリング時ではなく、ライフサイクル(非同期、COMベース)、フォームのクローズ時、制御されないダウンロード、そしてサポート時に追跡可能である必要があるJS↔Delphi通信の場面で問題が発生します。
想定されたが典型的なモダナイゼーションの状況:既存のFMXクライアント(Windows)にOAuth/OIDCログインフローを組み込み、その後Reactベースの「Admin」領域を表示させる。並行して、印刷、ファイルエクスポート、デバイスアクセスなどのレガシーワークフローが存在し、これらをWebフロントエンドから起動したい。最初のイテレーションは開発者のマシンで動作する。しかし2週間後には、フォームを閉じる際の断続的なアクセス違反(AV)や「ダウンロードが消える」といった症状が発生し、サポートはマシンごとに異なるキャッシュ/ストレージのために再現できない。
このソーススニペットは意思決定パスとして構成されています:WebView2をFMXアプリにどの深さで統合するかを意図的に選択します。本質は、ステートマシンを備えたカプセル化されたホスト、定義されたUserDataFolder戦略、WebMessage経由のJSONブリッジ、そして稼働中に驚きを与えないダウンロードフックです。
意思決定パス:実際にどれだけの「Delphi」 WebView2 FMXが必要か?
コードを書く前に、明確な判断をする価値がある。WebView2は「埋め込み表示」から「完全な統合層」まで範囲がある。
- 表示のみ(Read-only): URLへナビゲートし、ほとんど相互作用が不要な場合。多くの場合、最小限の初期化で足りるが、それでも安定したUserDataFolderを設定し、Disposeを確実に実装するべきである。
- 相互作用(Commands, Datenübergabe): Web-UIがDelphiワークフローを起動する場合、契約を持つブリッジ(メッセージスキーマ、ホワイトリスト、相関)とビジネスロジックからの分離(レイヤー: UI → Application → Domain → Infrastruktur)が必要である。
- 運用上重要な機能(Downloads, Debugging, Support): ファイル、DevTools、リモートデバッグ、サポートでの再現が重要になったら、イベント、パス、ポリシー、ロギングをフィールドで耐えうるように設計する必要がある。
| 判断項目 | 無視すると通常起きること | 最低限の対策 |
|---|---|---|
| UserDataFolderが設定されていない | 権限問題、再現不能な状態、サポートがバグを再現できない | インストールディレクトリ外の、Windowsユーザーごとのパス |
| State-Machine/Disposeロジックがない | フォームクローズ時のAV、ハンドラがDestroy後に発火する | Disposedフラグ + トークンのアンフック + nilにする順序 |
| ExecuteScriptを「ブリッジ」として使う | バージョン管理できない文字列スクリプト、エラーハンドリングが困難、「どこか」で発生する副作用 | JSON契約とホワイトリストを用いたWebMessage |
| ダウンロードを捕捉しない | 管理されない格納、「ハング」するダイアログ、コンプライアンス上の問題 | DownloadStartingをフック、オプションでhandled扱い + 専用パス |
WebView2 in FMX:なぜそれが「Component drop」では済まないのか
WebView2はCOMに近いAPI(Component Object Model: 参照カウント、インターフェース、イベント)で、初期化は非同期です。FMX(FireMonkey)はウィンドウハンドルを抽象化しますが、最終的にWebView2は親HWNDとピクセル単位の明確な境界を要求します。加えて、イベントは自動的にFMXコントロールを安全に操作できるコンテキストで到着するわけではありません。
ここで重要なのは、二つの中核的で信頼できる拠り所です。第一に、実行時コンポーネントとしての公式WebView2アーキテクチャ(Edge Runtime + SDK)およびMicrosoftが永続化とプロファイル保存場所として説明しているUser Data Folderの概念を含むこと(Microsoft Learn: WebView2)。第二に、App↔Web通信の定義されたチャネルとして想定されたイベントおよびメッセージングモデル(WebMessageReceived、PostWebMessageAsString)(Microsoft Learn: Communication between host and web content)です。
Source-Schnipsel: Host mit State-Machine, Token-Unhook und JSON-Bridge
以下のコードは意図的に「最短」にしておらず、運用の安全性に重点を置いています。実務上やや異例ですが決定的に重要な点は、EventRegistrationTokenを保存し、Dispose時に明示的にremove_*を呼び出すことです。これにより典型的な「Callback nach Destroy」が大幅に減ります。
Hinweis: Je nach Delphi-Setup nutzen Sie ein Import der WebView2-TLB oder eine Wrapper-Unit. Die Signaturen sind sehr ähnlich, aber einzelne Typnamen können variieren.
unit NbWebView2Host;
interface
uses
System.SysUtils, System.Classes, System.IOUtils, System.JSON,
Winapi.Windows, Winapi.ActiveX,
FMX.Types,
WebView2, WebView2_TLB;
type
TWebView2State = (wvsCreated, wvsInitializing, wvsReady, wvsDisposed);
TWebView2JsonMessage = record
Name: string;
Cid: string;
Payload: TJSONObject;
class function TryParse(const S: string; out M: TWebView2JsonMessage): Boolean; static;
end;
TOnWebMessage = reference to procedure(const M: TWebView2JsonMessage);
TOnDownloadMeta = reference to procedure(const Uri, MimeType, SuggestedFileName: string; TotalBytes: Int64);
TNbWebView2Host = class
private
FParentHwnd: HWND;
FState: TWebView2State;
FUserDataFolder: string;
FEnvironment: ICoreWebView2Environment;
FController: ICoreWebView2Controller;
FWebView: ICoreWebView2;
FTokenWebMsg: EventRegistrationToken;
FTokenDownload: EventRegistrationToken;
FOnWebMessage: TOnWebMessage;
FOnDownloadMeta: TOnDownloadMeta;
procedure RequiRESTate(const Allowed: array of TWebView2State);
function EffectiveUserDataFolder: string;
procedure HookEvents;
procedure UnhookEvents;
procedure HandleWebMessage(const args: ICoreWebView2WebMessageReceivedEventArgs);
procedure HandleDownloadStarting(const args: ICoreWebView2DownloadStartingEventArgs);
public
constructor Create(AParentHwnd: HWND; const AUserDataFolder: string = “);
destructor Destroy; override;
procedure InitializeAsync;
procedure Dispose;
procedure Navigate(const Url: string);
procedure ResizePx(const BoundsPx: TRect);
procedure PostJson(const Obj: TJSONObject);
property State: TWebView2State read FState;
property WebView: ICoreWebView2 read FWebView;
property OnWebMessage: TOnWebMessage read FOnWebMessage write FOnWebMessage;
property OnDownloadMeta: TOnDownloadMeta read FOnDownloadMeta write FOnDownloadMeta;
end;
implementation
class function TWebView2JsonMessage.TryParse(const S: string; out M: TWebView2JsonMessage): Boolean;
var
V: TJSONValue;
O: TJSONObject;
P: TJSONValue;
begin
Result := False;
M.Name := “;
M.Cid := “;
M.Payload := nil;
V := TJSONObject.ParseJSONValue(S);
try
if not (V is TJSONObject) then Exit;
O := TJSONObject(V);
M.Name := O.GetValue(’name‘, “);
M.Cid := O.GetValue(‚cid‘, “);
if O.TryGetValue(‚payload‘, P) and (P is TJSONObject) then
M.Payload := TJSONObject(P.Clone)
else
M.Payload := TJSONObject.Create;
Result := M.Name <> “;
finally
V.Free;
end;
end;
constructor TNbWebView2Host.Create(AParentHwnd: HWND; const AUserDataFolder: string);
begin
inherited Create;
FParentHwnd := AParentHwnd;
FUserDataFolder := AUserDataFolder;
FState := wvsCreated;
FTokenWebMsg.value := 0;
FTokenDownload.value := 0;
end;
destructor TNbWebView2Host.Destroy;
begin
Dispose;
inherited;
end;
procedure TNbWebView2Host.RequiRESTate(const Allowed: array of TWebView2State);
var
S: TWebView2State;
Ok: Boolean;
I: Integer;
begin
Ok := False;
for I := Low(Allowed) to High(Allowed) do
if FState = Allowed[I] then
Ok := True;
if not Ok then
begin
for S := Low(TWebView2State) to High(TWebView2State) do begin end; // デバッグで Enum を可視化するためだけ
raise EInvalidOperation.CreateFmt(‚WebView2Host: 無効な状態 (%d).‘, [Ord(FState)]);
end;
end;
function TNbWebView2Host.EffectiveUserDataFolder: string;
begin
if FUserDataFolder <> “ then
Exit(FUserDataFolder);
// Windows ごとに安定したユーザーデータ; 意図的にインストールフォルダ外に配置。
Result := TPath.Combine(TPath.GetHomePath, ‚MyCompany\MyApp\WebView2UserData‘);
ForceDirectories(Result);
end;
procedure TNbWebView2Host.InitializeAsync;
var
Opt: ICoreWebView2EnvironmentOptions;
UserData: string;
begin
RequiRESTate([wvsCreated]);
FState := wvsInitializing;
UserData := EffectiveUserDataFolder;
Opt := TCoreWebView2EnvironmentOptions.Create;
OleCheck(CreateCoreWebView2EnvironmentWithOptions(
nil,
PWideChar(UserData),
Opt,
TCoreWebView2CreateCoreWebView2EnvironmentCompletedHandler.Create(
procedure(errorCode: HRESULT; const createdEnvironment: ICoreWebView2Environment)
begin
if FState = wvsDisposed then Exit;
OleCheck(errorCode);
FEnvironment := createdEnvironment;
OleCheck(FEnvironment.CreateCoreWebView2Controller(
FParentHwnd,
TCoreWebView2CreateCoreWebView2ControllerCompletedHandler.Create(
procedure(errorCode2: HRESULT; const createdController: ICoreWebView2Controller)
begin
if FState = wvsDisposed then Exit;
OleCheck(errorCode2);
FController := createdController;
OleCheck(FController.get_CoreWebView2(FWebView));
HookEvents;
OleCheck(FController.put_IsVisible(1));
FState := wvsReady;
end)));
end)));
end;
procedure TNbWebView2Host.HookEvents;
begin
if FWebView = nil then Exit;
OleCheck(FWebView.add_WebMessageReceived(
TCoreWebView2WebMessageReceivedEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2WebMessageReceivedEventArgs)
begin
if FState = wvsDisposed then Exit;
HandleWebMessage(args);
end),
FTokenWebMsg));
OleCheck(FWebView.add_DownloadStarting(
TCoreWebView2DownloadStartingEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2DownloadStartingEventArgs)
begin
if FState = wvsDisposed then Exit;
HandleDownloadStarting(args);
end),
FTokenDownload));
end;
procedure TNbWebView2Host.UnhookEvents;
begin
// 重要: remove_* は Dispose 後の残留参照を低減します。
if FWebView <> nil then
begin
if FTokenWebMsg.value <> 0 then
FWebView.remove_WebMessageReceived(FTokenWebMsg);
if FTokenDownload.value <> 0 then
FWebView.remove_DownloadStarting(FTokenDownload);
end;
FTokenWebMsg.value := 0;
FTokenDownload.value := 0;
end;
procedure TNbWebView2Host.Dispose;
begin
if FState = wvsDisposed then Exit;
FState := wvsDisposed;
UnhookEvents;
// 順序: まず WebView/Controller を解放し、その後 Environment を解放。
FWebView := nil;
FController := nil;
FEnvironment := nil;
end;
procedure TNbWebView2Host.Navigate(const Url: string);
begin
RequiRESTate([wvsReady]);
OleCheck(FWebView.Navigate(PWideChar(Url)));
end;
procedure TNbWebView2Host.ResizePx(const BoundsPx: TRect);
var
R: tagRECT;
begin
if (FState <> wvsReady) or (FController = nil) then Exit;
R.Left := BoundsPx.Left;
R.Top := BoundsPx.Top;
R.Right := BoundsPx.Right;
R.Bottom := BoundsPx.Bottom;
OleCheck(FController.put_Bounds(R));
end;
procedure TNbWebView2Host.PostJson(const Obj: TJSONObject);
var
S: string;
begin
if (FState <> wvsReady) or (FWebView = nil) then Exit;
S := Obj.ToJSON;
OleCheck(FWebView.PostWebMessageAsString(PWideChar(S)));
end;
procedure TNbWebView2Host.HandleWebMessage(const args: ICoreWebView2WebMessageReceivedEventArgs);
var
P: PWideChar;
S: string;
M: TWebView2JsonMessage;
begin
P := nil;
OleCheck(args.TryGetWebMessageAsString(P));
try
S := P;
finally
CoTaskMemFree(P);
end;
if not Assigned(FOnWebMessage) then Exit;
if not TWebView2JsonMessage.TryParse(S, M) then Exit;
try
// ここで UI 更新を直接強制しない: アプリ層では必要に応じて TThread.Queue を使用してください。
FOnWebMessage(M);
finally
M.Payload.Free;
end;
end;
procedure TNbWebView2Host.HandleDownloadStarting(const args: ICoreWebView2DownloadStartingEventArgs);
var
Dl: ICoreWebView2DownloadOperation;
Uri, Mime, ResultPath: PWideChar;
Total: Int64;
Suggested: string;
begin
Uri := nil;
Mime := nil;
ResultPath := nil;
OleCheck(args.get_DownloadOperation(Dl));
OleCheck(Dl.get_TotalBytesToReceive(Total));
OleCheck(Dl.get_Uri(Uri));
OleCheck(Dl.get_MimeType(Mime));
OleCheck(Dl.get_ResultFilePath(ResultPath));
try
Suggested := ExtractFileName(string(ResultPath));
if Suggested = “ then Suggested := ‚download.bin‘;
if Assigned(FOnDownloadMeta) then
FOnDownloadMeta(string(Uri), string(Mime), Suggested, Total);
// 別案: 独自の処理を有効にする
// args.put_Handled(1);
finally
CoTaskMemFree(Uri);
CoTaskMemFree(Mime);
CoTaskMemFree(ResultPath);
end;
end;
end.
このスニペットが「異常にきれい」である点
- State-Machine statt Bool: wvsCreated → wvsInitializing → wvsReady → wvsDisposed。これにより誤操作(Navigateの早すぎる呼び出し、Disposeの二重呼び出し)が顕在化する。
- Explizites Unhooking: EventRegistrationTokenは保存され、Disposeで解除される。成熟したクライアントで見落とされがちな点だが、実運用での真正な安定性をもたらす。
- UserDataFolder bewusst gewählt: ユーザーごと、インストールパスの外に配置する。これは「nice to have」ではなく、サポートと運用のための重要なレバーである。
例外ケース: FMXにおけるスレッディング、DPI、フォームのライフサイクル
コールバックとFMXのスレッド
多くのWebView2コールバックは「UIに近い」ように見えるが、各Bridgeメッセージはあたかも別のコンテキストから来たかのように扱え。安全なルールは: FMXのUI操作は常に TThread.Queue(nil, ...) 経由で行うこと。アーキテクチャ用語では、スレッドアフィニティはUIオブジェクトが特定のスレッドに紐付いていることを意味する。
リサイズとDPIスケーリング
FMXは論理座標で動作し、WebView2はピクセル矩形(Pixel-Rects)を要求する。アプリがモニターDPIでスケーリングされる場合(100%/150%のモニター間の切替など)、モニター切替時や最大化/復元時のリサイズを重点的にテストせよ。換算を中央で処理していないと、1ピクセルのちらつき、不正なヒットテスト、切れた余白といった問題が発生しやすい。
ダウンロード: コンプライアンス、格納先、そして「なぜ ResultFilePath が空なのか?」
WebView2のダウンロードは ICoreWebView2DownloadOperation 経由で行われる。運用上重要な点は二つある。第一に ResultFilePath は早期に有効とは限らない。第二に多くの企業は格納場所を制御したい(プロジェクトフォルダ、検疫、乱雑な「Downloads」フォルダを避ける)。
成熟したクライアントでは典型的なルールは次のとおりだ:
- サイズ制限とMIMEフィルター(例: ポリシーに応じて「application/x-msdownload」をブロックする)。
- メタデータのログ(URI、MIME、バイト数)を監査トレイルとして残す。コンテンツ自体はログに含めない。
- 必要に応じて
args.Handled = Trueと独自のパス/ファイル名を使用する(プロセス要件がある場合)。
編集部の一見解
編集と運用の観点で最も多い誤りは「WebView2の知識不足」ではなく、意思決定の欠如である。ミニプロトタイプで始め、そのまま手を加えずに業務用ソフトに移行してしまうことが原因だ。問題はWebView2自体ではなく、カプセル化不足と運用戦略の欠如にある。
限界とリスク: WebView2がFMXで適切でない場面
- Multiplatform: WebView2は Windows。もしあなたのFMXクライアントが本当に macOS/iOS/Android 上で動作する必要があるなら、WebViewの抽象化を設計するか、Web UIを別に分離すべきだ。
- Sicherheitsmodell: 外部コンテンツを読み込む場合はドメイン制限、ナビゲーションポリシー、明確なアップデート/ランタイム概念が必要である。これを怠るとクライアントに望ましくない攻撃面を作ることになる。
結論: Delphi WebView2 FMX を統合コンポーネントとして扱い、UIのギミックにしない
もし Delphi WebView2 FMX を統合コンポーネントとして扱い、State-Machine、適切な Dispose、Token-Unhooking、制御された WebView2 User Data Folder、およびバージョン管理可能な JSON-Bridge を備えれば、既存クライアントの近代化に対する堅牢なエッジになります。特に、ログインフロー、埋め込みポータル、管理ビューなどに Web 技術を限定的に用い、コアロジックを意図的に Delphi に保持する場合に有効です。
適用の限界: ブリッジを通じて Web 側から「任意の」ビジネスロジックを Delphi へ流し込むと、テスト性やバージョン管理性を失い、場合によってはセキュリティや運用の管理も困難になります。その時点で問題はもはや WebView2 ではなく、アーキテクチャの規律です。
このテーマでは Delphi Fmx Edge Browser と Webview2 Firemonkey も重要です。本稿はこれらの側面を整理し、日常運用で重視すべき点を示します。
次のステップ
テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早期に一体として検討する必要があります。
私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。
- 既存環境、目標像、技術的リスクを一体として評価します。
- REST、データアクセス、ポータル、およびロールアウトは後回しにされません。
- 早い段階で、どの選択肢が経済的かつ運用上実行可能であるかを見極められます。