От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
Если в существующее бизнес‑приложение внезапно нужно «быстро» внедрить современные веб‑контенты, то по Windows вы натолкнётесь на WebView2. В Delphi WebView2 FMX основная проблема редко заключается в отображении URL; гораздо важнее корректная встраиваемость в интерфейс FireMonkey (FMX), надёжная инициализация (асинхронная и основанная на COM), а также подводные камни Edge, связанные с каталогами пользовательских данных, загрузками, отладкой и устойчивой JS↔Delphi‑коммуникацией.
Этот фрагмент исходного кода демонстрирует шаблон, который я предпочитаю для сопровождаемого ПО: инкапсулированный «Host»-объект, контролирующий жизненный цикл WebView2, и определённый мост через WebMessage (JSON) вместо произвольного «ExecuteScript везде». Цель — не демонстрационный код, а строительный блок, который переживёт эволюцию существующих клиентов.
Почему WebView2 в FMX отличается от «Browser-Component drop»
WebView2 — это API, близкая к COM/WinRT, с асинхронной инициализацией. FireMonkey абстрагирует Windows-дескрипторы, тем не менее для WebView2 в конце концов требуется настоящее родительское окно (HWND) и контролируемая переадресация изменений размера/фокуса. При этом события не всегда выполняются там, где их ожидают в FMX. Если начать здесь «quick and dirty», вы обычно получите:
- спорадические AV при закрытии формы (обратные вызовы приходят после Destroy)
- события навигации из неверного контекста потока
- ненадёжную персистентность/проблемы с кэшем из‑за неясной стратегии UserDataFolder
- отсутствие загрузок или «зависающие» диалоги загрузки
- отладку по счастливому стечению обстоятельств вместо настроенной конфигурации удалённой отладки
Противоядие — чёткий жизненный цикл: Create → InitializeAsync → Attach → Navigate → Detach/Dispose — и определённая граница между UI и движком браузера.
Source‑Schnipsel: WebView2Host für Delphi WebView2 FMX
Ниже приведён код, набрасывающий инкапсулированный класс хоста, который (1) создаёт конфигурацию окружения WebView2, (2) привязывает объект контроллера к HWND, (3) проводит проводку событий навигации и загрузки и (4) предоставляет основанный на JSON JS‑мост через WebMessageReceived. Код сознательно «architekturfähig»: он инкапсулирует COM‑ссылки, предотвращает появление последующих колбэков после Destroy и позволяет на эксплуатационном уровне разделять UserDataFolder для режимов вроде «pro User» или «pro Maschine».
unit WebView2Host;
interface
uses
System.SysUtils, System.Classes, System.IOUtils, System.JSON,
Winapi.Windows, Winapi.ActiveX,
FMX.Types,
WebView2, WebView2_TLB; // je nach Setup: WebView2.pas oder 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 kann fehlen oder null sein
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;
// Events lösen, bevor COM-Objekte freigegeben werden
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);
// Praxis: pro App + pro Windows-User, nicht in Programmverzeichnis
Result := TPath.Combine(TPath.GetHomePath, 'AppDataLocalMyCompanyMyAppWebView2');
ForceDirectories(Result);
end;
procedure TWebView2Host.InitializeAsync;
var
UserData: string;
Opt: ICoreWebView2EnvironmentOptions;
begin
EnsureNotDestroyed;
UserData := MakeUserDataFolder;
// Options: hier können zusätzliche Browser-Argumente rein, z.B. Remote-Debug
Opt := TCoreWebView2EnvironmentOptions.Create;
// Async CreateEnvironment
OleCheck(CreateCoreWebView2EnvironmentWithOptions(
nil, PWideChar(UserData), Opt,
TCoreWebView2CreateCoreWebView2EnvironmentCompletedHandler.Create(
procedure (errorCode: HRESULT; const createdEnvironment: ICoreWebView2Environment)
begin
if FDestroyed then Exit;
OleCheck(errorCode);
FEnvironment := createdEnvironment;
// Controller an Parent HWND binden
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;
// Initial sichtbar machen
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));
// Hinweis: Für robustes Unhooking sollten Sie die Tokens speichern.
// In vielen Projekten ist das ausreichend, wenn der Host nur mit dem Form lebt.
end;
procedure TWebView2Host.UnhookEvents;
begin
// Robust-Variante: Tokens merken und remove_* aufrufen.
// Hier als Kommentar, weil das Import-Unit-Setup und Token-Verwaltung je nach Wrapper variiert.
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));
// In der Praxis: ResultFileName initial leer, je nach Quelle.
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);
// Optional: eigene Download-UI, dann Handled setzen
// 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 знает только «Initialize/Navigate/Resize», не детали COM.
- Мост с контрактом: JSON-сообщения с
name, опциональнымcid(Correlation-ID) иpayloadподдерживаемы и тестируемы. - Операционно безопасная персистентность: контролируемый
UserDataFolderпредотвращает конфликты кэша, проблемы с правами и ситуацию «работает на машине разработчика, но не в эксплуатации».
JS↔Delphi-Bridge: почему WebMessage стабильнее, чем ExecuteScript
WebView2 предоставляет несколько каналов для коммуникации. На практике ExecuteScript выглядит привлекательно, но плохо версионируется: вы отправляете строки в интерпретатор без явных каналов ответа и без надежного сопоставления ошибок. PostWebMessageAsString / WebMessageReceived — это определённый канал.
Краевой случай, часто встречающийся в корпоративной среде: нужно из веб‑фронтенда (например, внутреннего портала) запустить workflow Delphi (печать, доступ к устройствам, интеграция с легаси). Тогда вам потребуются:
- белый список имён сообщений
- Correlation‑ID для асинхронных ответов
- центральное место валидации payload’ов (например, обязательные поля, лимиты по размеру)
В хосте такой точкой является OnWebMessageReceived. Собственно валидация должна выполняться в слое выше (например, в application‑service), чтобы разделить UI/WebView2‑технику и бизнес‑логику (классическая слоистая архитектура: UI → Application → Domain → Infrastruktur).
Загрузки и хранение файлов: что в эксплуатации часто удивляет
Загрузки в WebView2 обрабатываются через ICoreWebView2DownloadOperation. В зависимости от источника ResultFilePath может быть сначала пустым и заполниться позднее. Кроме того, многие компании не хотят, чтобы конечные пользователи сохраняли файлы в неконтролируемые папки.
Проверенные шаблоны:
- Перехватывать DownloadStarting и через
args.put_Handled(1)брать управление UI на себя (собственный путь, соглашение по именованию, карантинная папка). - Ограничения по размеру файлов и проверки MIME‑типа, чтобы избежать «случайной загрузки 4 GB логов».
- Аудит: записывать в лог метаданные загрузки (URI, MIME, байты), а не содержимое файла.
Если у вас регламентированные процессы (например, утверждения, трассируемость), то обработка через события — единственная точка, где вы можете интегрировать поведение браузера в ваши операционные правила.
Отладка: DevTools, Remote Debug Port и воспроизводимые состояния
Отладка WebView2 часто ломается из‑за того, что состояния нельзя воспроизвести. Две точки управления помогают:
- Включение/отключение DevTools через
ICoreWebView2Settings(в коде:SetDevToolsEnabled) — в релизе чаще отключены, в случае поддержки включаются целенаправленно. - Стабильный UserDataFolder: если служба поддержки должна воспроизвести ошибку, предопределённый путь очень ценен. Папку можно заархивировать/сохранить (внимание: GDPR/PII) и сравнивать состояния целенаправленно.
Опционально (в зависимости от обёртки) вы можете добавить в EnvironmentOptions дополнительные аргументы браузера, например порт для remote‑debug. Это полезно, когда нужно проанализировать приложение на тестовой системе без локальных инструментов разработчика. Ограничения: в продуктивных средах это должно быть явно разрешено и задокументировано, иначе вы создаёте лишнюю поверхность атаки.
Подводные камни в Delphi WebView2 FMX: COM, потоки и жизненный цикл формы
1) Обратные вызовы после закрытия
Асинхронные CompletedHandler могут сработать после того, как форма уже закрывается. В примере FDestroyed предотвращает доступ к освобождённым объектам. Более надёжным будет дополнительно:
- Сохранять токены для событий и в
Destroyкорректно вызыватьremove_* - Разрешать
InitializeAsyncтолько один раз (машина состояний: Created/Initializing/Ready/Disposed)
2) Контекст потока
Многие обработчики приходят «близко к UI», но не полагайтесь на то, что вы можете напрямую писать в FMX-контролы. Если вы в OnWebMessage обновляете UI, безопасный вариант — TThread.Queue(nil, ...). Я предпочитаю разделение: Host собирает событие, Application-Service принимает решение, UI обновляется исключительно через очередь.
3) DPI/Изменение размера и FMX-Layouts
FMX оперирует логическими единицами, WebView2 ожидает Pixel-Rects. На практике нужна чёткая точка, где вы переводите bounds FMX-контролов в реальные пиксели. В примере используется TRect; в вашей форме вы должны из него вывести WinAPI-координаты (например, через FMX.Platform.Win и Handle-API). Если приложение масштабируется по DPI монитора, протестируйте переключение между мониторами: WebView2 здесь чувствительнее, чем чистые FMX-контролы.
Когда WebView2 в FMX имеет смысл — а когда нет
WebView2 имеет смысл, если в развитом Delphi-клиентском приложении вы хотите целенаправленно использовать веб-технологии: встроенные административные представления, OAuth/OIDC-потоки входа, HTML-отчёты, внутренние порталы или контролируемые «Micro-Frontends». Также это практичный мост при модернизации, пока вы чётко разграничиваете зоны ответственности и не превращаете мост в неконтролируемую заднюю дверь для бизнес-логики.
Ограничения подхода:
- Платформа: Шаблон ориентирован на Windows. FMX — кроссплатформенный, WebView2 — нет. Для macOS/iOS/Android потребуется другой WebView или слой абстракции.
- Security/Hardening: Как только загружаются внешние ресурсы, необходимо жёстко ограничивать навигацию, разрешённые домены и цели загрузки. Это должно быть отражено в требованиях, а не «потом».
- Support: UserDataFolder и зависимости рантайма (WebView2 Runtime) должны быть частью вашей концепции эксплуатации/раскатки.
Вывод
Delphi WebView2 FMX — это не UI-игрушка, а интеграционная компонента с собственным жизненным циклом. Если вы структурированно инкапсулируете инициализацию, Eventing, UserDataFolder и JS-Bridge, WebView2 станет стабильным элементом цифровых корпоративных решений: веб-UI там, где это имеет смысл, и Delphi-логика там, где ей место. Если же вы неконтролируемо испускаете скрипты, оставляете пути на волю случая и не декуплируете события, вы получите те самые «спорадические в полях» ошибки, которые съедают время и подрывают доверие.
Если вы хотите аккуратно интегрировать WebView2 в существующее Delphi-приложение или технически оценить вариант модернизации, свяжитесь с нами:
В профессиональном контексте также важную роль играют Webview2 Firemonkey и Delphi Fmx Edge Browser, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.
Следующий шаг
Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.
Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.
- Текущее состояние, целевое состояние и технические риски оцениваются совместно.
- REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
- Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.