Net-Base Журнал

14.06.2026

Delphi WebView2 в FMX: корректно инициализировать, создать JS-Bridge, контролировать загрузки и отладку

WebView2 в FireMonkey звучит как «просто встроить браузер», но на практике даёт сбои при инициализации, при событиях навигации, в JS↔Delphi-мосте, при обработке загрузок и отладке. Этот фрагмент исходного кода демонстрирует надёжный паттерн с чётким разграничением ответственности...

14.06.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Если в существующее бизнес‑приложение внезапно нужно «быстро» внедрить современные веб‑контенты, то по 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».

Delphi
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, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.

Обсудить проект или задачу по модернизации с Net-Base.

Следующий шаг

Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.

Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.

  • Текущее состояние, целевое состояние и технические риски оцениваются совместно.
  • REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
  • Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.