雑誌のテーマからプロジェクト実践へ
該当記事に関連するサービス・技術ページ
既存の業務ソフトに「ちょっとだけ」モダンなWebコンテンツを埋め込みたい場合、Windows では大抵WebView2に行き着きます。Delphi WebView2 FMX WebView2 FMXにおいて、根本的な問題はURLを表示すること自体ではなく、FireMonkeyの表示層(FMX)へのきれいな組み込み、非同期かつCOMベースの確実な初期化、そしてUser-Dataディレクトリ、ダウンロード、デバッグ、堅牢なJS↔Delphi通信を巡るEdge特有の落とし穴です。
このソース断片は、私が保守可能なアプリケーションに好んで使うパターンを示します:WebView2のライフサイクルを制御するカプセル化された「Host」オブジェクトと、どこでも好き勝手にExecuteScriptを呼ぶ代わりにJSONベースのWebMessageによる定義済みのブリッジです。目的はデモコードではなく、成長したクライアント内で生き残る実用的なビルディングブロックです。
Warum WebView2 in FMX anders ist als „Browser-Component drop”
WebView2はCOM/WinRTに近いAPIであり、初期化は非同期です。FireMonkeyはWindowsハンドルを抽象化しますが、最終的には実際の親ウィンドウ(HWND)と、サイズ変更/フォーカスの伝播を制御する仕組みが必要です。同時に、イベントは常にFMX側で期待するスレッド上で発生するとは限りません。ここを「手早く済ませよう」とすると、典型的に次のような問題が発生します:
- フォーム閉鎖時に断続的なAV(Destroy後にコールバックが到着する)
- 誤ったスレッドコンテキストから発生するナビゲーションイベント
- UserDataFolder戦略が不明確なための信頼できない永続化/キャッシュ問題
- ダウンロードが発生しない、またはダウンロードダイアログが「ハング」する
- 意図したリモートデバッグ設定ではなく、運任せのデバッグしかできない
対処法は明確なライフサイクル: Create → InitializeAsync → Attach → Navigate → Detach/Dispose と、UIとブラウザエンジンの間に定義された境界です。
Source-Schnipsel: WebView2Host für Delphi WebView2 FMX
以下のコードは、(1) WebView2のEnvironment設定を作成し、(2) ControllerオブジェクトをHWNDにバインドし、(3) ナビゲーションおよびダウンロードイベントを配線し、(4) WebMessageReceived経由でJSONベースのJSブリッジを提供する、カプセル化されたHostクラスのスケッチです。コードは意図的にアーキテクチャ対応であり:COM参照をカプセル化し、Destroy後のコールバック後追いを防ぎ、ユーザ単位やマシン単位などの運用境界に応じてUserDataFolderを分離できるようにしています。
unit WebView2Host;
interface
uses
System.SysUtils, System.Classes, System.IOUtils, System.JSON,
Winapi.Windows, Winapi.ActiveX,
FMX.Types,
WebView2, WebView2_TLB; // セットアップに応じて: WebView2.pas または Import-TLB
type
TWebView2JsonMessage = record
Name: string;
CorrelationId: string;
Payload: TJSONObject;
class function TryParse(const Json: string; out Msg: TWebView2JsonMessage): Boolean; static;
end;
TOnWebMessage = reference to procedure(const Msg: TWebView2JsonMessage);
TOnDownload = reference to procedure(const FileName, MimeType: string; TotalBytes: Int64);
TWebView2Host = class
private
FParentHwnd: HWND;
FUserDataFolder: string;
FEnvironment: ICoreWebView2Environment;
FController: ICoreWebView2Controller;
FWebView: ICoreWebView2;
FDestroyed: Boolean;
FOnWebMessage: TOnWebMessage;
FOnDownload: TOnDownload;
procedure EnsureNotDestroyed;
function MakeUserDataFolder: string;
// Event handler
procedure HookEvents;
procedure UnhookEvents;
procedure OnWebMessageReceived(
const sender: ICoreWebView2;
const args: ICoreWebView2WebMessageReceivedEventArgs);
procedure OnDownloadStarting(
const sender: ICoreWebView2;
const args: ICoreWebView2DownloadStartingEventArgs);
public
constructor Create(AParentHwnd: HWND; const AUserDataFolder: string = “);
destructor Destroy; override;
procedure InitializeAsync;
procedure Navigate(const Url: string);
procedure Resize(const Bounds: TRect);
procedure PostJsonToWeb(const Obj: TJSONObject);
procedure SetDevToolsEnabled(const Enabled: Boolean);
property WebView: ICoreWebView2 read FWebView;
property OnWebMessage: TOnWebMessage read FOnWebMessage write FOnWebMessage;
property OnDownload: TOnDownload read FOnDownload write FOnDownload;
end;
implementation
{ TWebView2JsonMessage }
class function TWebView2JsonMessage.TryParse(const Json: string; out Msg: TWebView2JsonMessage): Boolean;
var
V: TJSONValue;
O: TJSONObject;
begin
Result := False;
Msg.Name := “;
Msg.CorrelationId := “;
Msg.Payload := nil;
V := TJSONObject.ParseJSONValue(Json);
try
if not (V is TJSONObject) then Exit;
O := TJSONObject(V);
Msg.Name := O.GetValue(’name‘, “);
Msg.CorrelationId := O.GetValue(‚cid‘, “);
// payload は存在しないか null の可能性がある
if O.TryGetValue(‚payload‘, Msg.Payload) then
Msg.Payload := TJSONObject(Msg.Payload.Clone)
else
Msg.Payload := TJSONObject.Create;
Result := Msg.Name <> “;
finally
V.Free;
end;
end;
{ TWebView2Host }
constructor TWebView2Host.Create(AParentHwnd: HWND; const AUserDataFolder: string);
begin
inherited Create;
FParentHwnd := AParentHwnd;
FUserDataFolder := AUserDataFolder;
FDestroyed := False;
end;
destructor TWebView2Host.Destroy;
begin
FDestroyed := True;
// COM オブジェクトを解放する前にイベントを解除する
UnhookEvents;
FWebView := nil;
FController := nil;
FEnvironment := nil;
inherited;
end;
procedure TWebView2Host.EnsureNotDestroyed;
begin
if FDestroyed then
raise EInvalidOperation.Create(‚WebView2Host は既に破棄されています。‘);
end;
function TWebView2Host.MakeUserDataFolder: string;
begin
if FUserDataFolder <> “ then
Exit(FUserDataFolder);
// 実務上: アプリごと + Windows-ユーザーごと、プログラムディレクトリには置かない
Result := TPath.Combine(TPath.GetHomePath, ‚AppDataLocalMyCompanyMyAppWebView2‘);
ForceDirectories(Result);
end;
procedure TWebView2Host.InitializeAsync;
var
UserData: string;
Opt: ICoreWebView2EnvironmentOptions;
begin
EnsureNotDestroyed;
UserData := MakeUserDataFolder;
// オプション: ここに追加のブラウザ引数(例: リモートデバッグ)を指定可能
Opt := TCoreWebView2EnvironmentOptions.Create;
// 非同期 CreateEnvironment
OleCheck(CreateCoreWebView2EnvironmentWithOptions(
nil, PWideChar(UserData), Opt,
TCoreWebView2CreateCoreWebView2EnvironmentCompletedHandler.Create(
procedure (errorCode: HRESULT; const createdEnvironment: ICoreWebView2Environment)
begin
if FDestroyed then Exit;
OleCheck(errorCode);
FEnvironment := createdEnvironment;
// コントローラを親 HWND にバインドする
OleCheck(FEnvironment.CreateCoreWebView2Controller(
FParentHwnd,
TCoreWebView2CreateCoreWebView2ControllerCompletedHandler.Create(
procedure (errorCode2: HRESULT; const createdController: ICoreWebView2Controller)
begin
if FDestroyed then Exit;
OleCheck(errorCode2);
FController := createdController;
OleCheck(FController.get_CoreWebView2(FWebView));
HookEvents;
// 初期表示を有効にする
FController.put_IsVisible(1);
end)));
end)));
end;
procedure TWebView2Host.HookEvents;
var
TokenMsg, TokenDl: EventRegistrationToken;
begin
if (FWebView = nil) then Exit;
// WebMessageReceived (JS->Delphi)
TokenMsg.value := 0;
OleCheck(FWebView.add_WebMessageReceived(
TCoreWebView2WebMessageReceivedEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2WebMessageReceivedEventArgs)
begin
if FDestroyed then Exit;
OnWebMessageReceived(sender, args);
end), TokenMsg));
// DownloadStarting
TokenDl.value := 0;
OleCheck(FWebView.add_DownloadStarting(
TCoreWebView2DownloadStartingEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2DownloadStartingEventArgs)
begin
if FDestroyed then Exit;
OnDownloadStarting(sender, args);
end), TokenDl));
// 注意: 安定したイベント解除のためにトークンを保存しておくべきです。
// 多くのプロジェクトでは、ホストがフォームと同じライフタイムであればこれで十分です。
end;
procedure TWebView2Host.UnhookEvents;
begin
// より堅牢な方法: トークンを保持し remove_* を呼ぶ
// ここではコメントとして残す。Import ユニットの構成やトークン管理はラッパーによって異なるため。
end;
procedure TWebView2Host.OnWebMessageReceived(
const sender: ICoreWebView2;
const args: ICoreWebView2WebMessageReceivedEventArgs);
var
Json: PWideChar;
S: string;
Msg: TWebView2JsonMessage;
begin
Json := nil;
OleCheck(args.TryGetWebMessageAsString(Json));
try
S := Json;
finally
CoTaskMemFree(Json);
end;
if Assigned(FOnWebMessage) and TWebView2JsonMessage.TryParse(S, Msg) then
begin
try
FOnWebMessage(Msg);
finally
Msg.Payload.Free;
end;
end;
end;
procedure TWebView2Host.OnDownloadStarting(
const sender: ICoreWebView2;
const args: ICoreWebView2DownloadStartingEventArgs);
var
Dl: ICoreWebView2DownloadOperation;
Uri, Mime, ResultFile: PWideChar;
Total: Int64;
FileName: string;
begin
Uri := nil;
Mime := nil;
ResultFile := nil;
OleCheck(args.get_DownloadOperation(Dl));
OleCheck(Dl.get_TotalBytesToReceive(Total));
// 実務上: ResultFileName は初期空の場合があり、ソースによる
OleCheck(Dl.get_ResultFilePath(ResultFile));
OleCheck(Dl.get_MimeType(Mime));
OleCheck(Dl.get_Uri(Uri));
try
FileName := ExtractFileName(string(ResultFile));
if FileName = “ then
FileName := ‚download.bin‘;
if Assigned(FOnDownload) then
FOnDownload(FileName, string(Mime), Total);
// 任意: 独自ダウンロード UI を使用する場合は Handled を設定する
// args.put_Handled(1);
finally
CoTaskMemFree(Uri);
CoTaskMemFree(Mime);
CoTaskMemFree(ResultFile);
end;
end;
procedure TWebView2Host.Navigate(const Url: string);
begin
EnsureNotDestroyed;
if FWebView = nil then
raise EInvalidOperation.Create(‚WebView2 はまだ初期化されていません。‘);
OleCheck(FWebView.Navigate(PWideChar(Url)));
end;
procedure TWebView2Host.Resize(const Bounds: TRect);
var
R: tagRECT;
begin
if FController = nil then Exit;
R.Left := Bounds.Left;
R.Top := Bounds.Top;
R.Right := Bounds.Right;
R.Bottom := Bounds.Bottom;
OleCheck(FController.put_Bounds(R));
end;
procedure TWebView2Host.PostJsonToWeb(const Obj: TJSONObject);
var
S: string;
begin
EnsureNotDestroyed;
if FWebView = nil then Exit;
S := Obj.ToJSON;
OleCheck(FWebView.PostWebMessageAsString(PWideChar(S)));
end;
procedure TWebView2Host.SetDevToolsEnabled(const Enabled: Boolean);
var
Settings: ICoreWebView2Settings;
begin
if (FWebView = nil) then Exit;
OleCheck(FWebView.get_Settings(Settings));
OleCheck(Settings.put_AreDevToolsEnabled(Ord(Enabled)));
end;
end.
アプローチの目的
- ライフサイクルのカプセル化: FMXフォームはCOMの詳細を扱わず、「Initialize/Navigate/Resize」のみを意識します。
- 契約に基づくブリッジ:
name、オプションのcid(Correlation-ID)、およびpayloadを持つJSONメッセージは保守性とテスト性に優れます。 - 運用に耐える永続化: 管理された
UserDataFolderによりキャッシュの衝突や権限問題を防ぎ、「開発者環境では動くが本番では動かない」といった事態を回避できます。
JS↔Delphi-Bridge: なぜ WebMessage は ExecuteScript より安定しているか
WebView2 は複数の通信手段を提供します。実務では ExecuteScript が魅力的に見えますが、バージョン管理が難しい点に問題があります。文字列をインタプリタに投げ込み、明確な応答チャネルや堅牢なエラー対応が確立されないからです。対照的に PostWebMessageAsString / WebMessageReceived は定義されたチャネルです。
企業環境でよく起きる境界ケース: Webフロントエンド(例: 社内ポータル)から Delphi ワークフロー(印刷、デバイスアクセス、レガシー統合)を起動する必要がある場合、次が必要になります:
- メッセージ名のホワイトリスト
- 非同期応答のための相関ID(Correlation-IDs)
- ペイロードを検証する中央の処理(例: 必須フィールド、サイズ制限)
ホスト側ではその受け口が OnWebMessageReceived です。実際の検証は上位層(例: Application-Service)に置き、UI/WebView2技術とビジネスロジックを分離します(典型的なレイヤーアーキテクチャ: UI → Application → Domain → インフラストラクチャ)。
ダウンロードとファイル格納: 運用でよく問題になる点
WebView2 のダウンロードは ICoreWebView2DownloadOperation を介して行われます。ソースによっては ResultFilePath が早期には空で、後で設定されることがあります。加えて、多くの企業はエンドユーザーが無制限にフォルダへ保存することを望みません。
推奨パターン:
- DownloadStarting をフックする と
args.put_Handled(1)により UI 側で処理を引き取れる(独自パス、命名規則、隔離フォルダなど)。 - ファイルサイズ制限 と MIME タイプチェックを実装し、「誤って 4 GB のログファイルを保存してしまう」といった事態を避ける。
- 監査: ダウンロードのメタデータ(URI、MIME、バイト数)をログに記録し、ファイル内容自体は記録しない。
承認やトレーサビリティなど規制されたプロセスがある場合、イベントを介した処理はブラウザの挙動を運用ルールに組み込む唯一の箇所になります。
デバッグ: DevTools、リモートデバッグポート、再現可能な状態
WebView2 のデバッグは状態が再現できないことが原因で失敗することが多いです。助けになる2つの調整点:
- DevTools の有効/無効切替 を
ICoreWebView2Settings(コード上ではSetDevToolsEnabled)で管理する — リリースでは通常オフ、サポート時に限定してオンにする。 - 安定した UserDataFolder: サポートが不具合を再現する際、明確なパスは非常に役立ちます。フォルダを保存/圧縮して(注意: データ保護/PII)状態を比較できます。
オプションとして(ラッパー次第で)EnvironmentOptions に追加のブラウザ引数を設定でき、例えばリモートデバッグポートなどが指定できます。これはローカル開発ツールがないテスト環境での解析に有用です。ただし本番環境では適切に許可および文書化されていないと不要な攻撃面を作るため、慎重に運用してください。
Delphi WebView2 FMX の落とし穴: COM、スレッド、フォームのライフサイクル
1) クローズ後のコールバック
非同期の CompletedHandler はフォームが既に閉じた後に到着することがあります。スニペットでは FDestroyed が解放済みオブジェクトへのアクセスを防いでいます。さらに堅牢にするには次を行います:
- イベントのトークンを保存し、
Destroy内で確実にremove_*を呼び出す - InitializeAsync を一度だけ許可する(ステートマシン: Created/Initializing/Ready/Disposed)
2) スレッドコンテキスト
多くのハンドラは「UI寄り」で来ますが、直接 FMX コントロールに書き込めると当てにしないでください。OnWebMessage で UI を更新する場合、TThread.Queue(nil, ...) が安全です。私は分離することを好みます: ホストがイベントを収集し、アプリケーションサービスが判断し、UI は専ら Queue 経由で更新される、という形です。
3) DPI/Resize und FMX-Layouts
FMX は論理単位で計算し、WebView2 はピクセル矩形を期待します。実運用では、FMX コントロールの Bounds を実際のピクセルに変換する明確な箇所が必要です。スニペットは TRect を受け取ります。フォーム内でそれを WinAPI 座標(例: FMX.Platform.Win と Handle API を使って)に変換すべきです。アプリがモニタ DPI によってスケーリングされる場合は、モニタ間の切り替えをテストしてください。WebView2 は純粋な FMX コントロールよりもここで敏感です。
FMX における WebView2 が有効な場合 — 有効でない場合
WebView2 は、成長した Delphi クライアントアプリケーション内で Web 技術を意図的に利用したいケースに向いています: 埋め込み型の管理ビュー、OAuth/OIDC ログインフロー、HTML レポート、社内ポータル、または制御された「Micro-Frontends」などです。責務を明確に切り分け、ブリッジをビジネスロジックへの制御不能な裏口にしない限り、モダナイゼーションの橋渡しとしても実用的です。
アプローチの限界:
- Plattform: このパターンは Windows 中心です。FMX はマルチプラットフォーム対応ですが、WebView2 はそうではありません。macOS/iOS/Android 向けには別の WebView や抽象化レイヤが必要です。
- Security/Hardening: 外部コンテンツを読み込む場合、ナビゲーション、許可するドメイン、ダウンロード先をより厳格に制限する必要があります。これは „後で“ ではなく要件に含めるべきです。
- Support: UserDataFolder とランタイム依存(WebView2 Runtime)は運用/ロールアウト計画の一部でなければなりません。
結論
Delphi WebView2 FMX は単なる UI ガジェットではなく、独自のライフサイクルを持つ統合コンポーネントです。初期化、イベント処理、UserDataFolder、JS-Bridge を構造的にカプセル化すれば、WebView2 はデジタル企業向けソリューションの安定した構成要素になります: Web-UI は適切な場所に、Delphi ロジックは適切な場所に配置する。逆にスクリプトを無統制に実行し、パスを放置し、イベントを切り離さないと、いわゆる「現場で散発的に発生する」不具合が発生し、時間を浪費し信頼を損ねます。
既存の Delphi アプリケーションに WebView2 を適切に統合する、またはモダナイゼーションの境界を技術的に評価したい場合は、弊社にご相談ください:
専門領域においては、統合、データフロー、継続的な開発を整合させる必要がある場面で、Webview2 Firemonkey と Delphi Fmx Edge Browser も重要な役割を果たします。
次のステップ
テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早い段階でまとめて検討するべきです。
私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。
- 既存環境、目標像、技術的リスクを一体として評価します。
- REST、データアクセス、ポータル、ロールアウトは後工程として先送りされることはありません。
- 早期に、どのアプローチが経済的かつ運用面で実行可能かを判断できます。