От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
Сцена знакома: FMX-клиент для индивидуального корпоративного ПО на компьютере разработчика работает корректно – и разваливается при первом реальном использовании. На Surface в доке всё подходит, в режиме планшета сайдбар перекрывает детальную область, а на мониторе с 125%-DPI интерфейс начинает «дрожать» при масштабировании окна. К тому моменту возникает вопрос, который никто не хочет слышать: «Как мы строим Responsive Layouts FMX, чтобы дизайнер не взорвался от пяти вариантов?»
FireMonkey поставляет множество блоков для компоновки (Align/Anchors, TFlowLayout, TGridPanelLayout, TScaledLayout). Чего не хватает — так это декларативной системы брейкпоинтов, как в вебе. На практике это быстро превращается в разбросанные if Width < ...-блоки, трудно воспроизводимые каскады ресайза и параллельно поддерживаемые деревья компоновки, которые со временем расходятся.
Этот обзор опирается на шаблон, который в зрелых клиентах более стабилен: Layout-Router, который централизованно вычисляет брейкпоинты и перемещает существующие UI-блоки между подготовленными слотами. Это не «UI-Framework im Framework», а небольшая тестируемая прослойка, которая целенаправленно решает те крайние случаи, где FMX на практике даёт сбой: штормы ресайза, смена DPI, поворот, побочные эффекты фокуса/HitTest.
Анализ сценария: что именно ломается при Responsive Layouts FMX?
Искусственно, но реалистично: экран «Aufträge» имеет наверху панель фильтров, слева — навигацию (Status, Zeiträume), в центре — список результатов, справа — детальную область. На десктопе это должно быть в три колонки, на планшете — в две (список + детали, навигация как Drawer), на телефоне — одна колонка (Navigation → Liste → Details как Stack).
Типичная эволюция в команде выглядит так:
- Создают два дерева компоновки в дизайнере (Desktop vs. Mobile) и переключают
Visible. - Потом появляются LiveBindings, таймеры, «Refresh bei Fokus» — внезапно логика дублируется.
- Позже «ещё один» форм‑фактор (Split-Screen/маленький ноутбук), и всё становится непригодным для ревью.
Основная ошибка — не Align или Anchors. Основная ошибка — дублирование состояния: две панели фильтров, два списка, два состояния селекции. Как только состояния расходятся, отладка превращается в угадывание.
Почему FMX-брейкпоинты не работают как в CSS
В вебе брейкпоинты декларативны (Media Queries), рендеринг‑пайплайн рассчитан на это. В FMX решения по компоновке чаще императивны и событийно управляемы: срабатывает OnResize, код переключает состояние. Плюс добавляются специфические для FMX триггеры:
- Логические единицы вместо физических пикселов:
Width/ClientWidthизмеряются в «FMX‑пунктах», на которые влияет масштабирование. Per-Monitor-DPI на Windows может изменить решения по лэйауту, не меняя «ощутимого» размера окна. - Проходы лэйаута и реэнтрантность: изменение Parent/Align может вызвать дополнительные события Resize/Layout. Без защитного механизма возникает петля.
- Z-Order/HitTest как побочный эффект: смена родителя изменяет Z-Order; невидимый клиент‑контейнер с
HitTest=Trueможет перехватывать клики.
Вывод: если вы хотите корректные брейкпоинты в FMX, нужно уделять «когда» (Debounce, Guard, порядок) столько же внимания, сколько и «что» (какой блок куда).
Архитектура: слоты + перемещение вместо переключения Visible
Базовая прочная единица — Slot: контейнер (TLayout, иногда TPresentedControl), который в макете выполняет определённую роль, например slotTop, slotLeft, slotCenter, slotRight, slotOverlay. UI-блоки (панель инструментов, боковая панель, список, детали) для каждого Breakpoint назначаются соответствующему Slot и при выполнении перемещаются.
Почему перемещение элементов в бизнес‑клиентах часто оказывается лучшим решением:
- Одна инстанция — одно состояние: текст в полях редактирования, позиции прокрутки и выделения в списках сохраняются.
- Меньше риска ошибок при подключении: события, действия, биндинги привязаны ровно к одному дереву контролов.
- Читаемость при ревью: правила Breakpoint читаются в одном месте (конфигурация роутера), а не разбросаны по формам.
Фрагмент исходного кода: роутер макета с Breakpoints, Debounce и „Transaction“-Apply
Следующий роутер намеренно небольшой, но не наивный: он агрегирует Resize-события (Debounce), защищает от Re-Entrancy и допускает заменяемую логику Breakpoint. Важно: он работает с перемещением существующих инстанций. Именно это предотвращает типичный «State Drift», возникающий из параллельных деревьев макета.
unit NB.FMX.LayoutRouter;
interface
uses
System.Classes, System.SysUtils, System.Types, System.Generics.Collections,
FMX.Types, FMX.Controls;
type
TNBLayoutBreakpoint = (bpXS, bpSM, bpMD, bpLG, bpXL);
TNBRoute = record
Control: TControl;
TargetSlot: TControl;
Align: TAlignLayout;
end;
TNBRouteList = TList<TNBRoute>;
// Callback, damit Breakpoints nicht „hart“ an Breite gekoppelt sind.
TNBGetBreakpointEvent = reference to function(const AClientSize: TSizeF): TNBLayoutBreakpoint;
// Optional: Hook nach Apply, um Fokus wiederherzustellen oder datengetriebene Aktionen zu entkoppeln.
TNBApplyEvent = reference to procedure(const ABp: TNBLayoutBreakpoint);
TNBLayoutRouter = class(TComponent)
private
FRoot: TControl;
FPending: Boolean;
FUpdating: Boolean;
FCurrent: TNBLayoutBreakpoint;
FOnGetBreakpoint: TNBGetBreakpointEvent;
FOnAfterApply: TNBApplyEvent;
FRoutes: TObjectDictionary<Integer, TNBRouteList>;
function KeyOf(const ABp: TNBLayoutBreakpoint): Integer;
procedure RootResized(Sender: TObject);
procedure ApplyPending;
procedure ApplyRoutes(const ABp: TNBLayoutBreakpoint);
function DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure AttachRoot(const ARoot: TControl);
procedure DefineRoute(const ABp: TNBLayoutBreakpoint;
const AControl, ASlot: TControl;
const AAlign: TAlignLayout = TAlignLayout.Client);
procedure Invalidate;
property Current: TNBLayoutBreakpoint read FCurrent;
property OnGetBreakpoint: TNBGetBreakpointEvent read FOnGetBreakpoint write FOnGetBreakpoint;
property OnAfterApply: TNBApplyEvent read FOnAfterApply write FOnAfterApply;
end;
implementation
constructor TNBLayoutRouter.Create(AOwner: TComponent);
begin
inherited;
FRoutes := TObjectDictionary<Integer, TNBRouteList>.Create([doOwnsValues]);
FCurrent := bpMD;
end;
destructor TNBLayoutRouter.Destroy;
begin
if Assigned(FRoot) then
FRoot.OnResize := nil;
FRoutes.Free;
inherited;
end;
procedure TNBLayoutRouter.AttachRoot(const ARoot: TControl);
begin
if FRoot = ARoot then Exit;
if Assigned(FRoot) then
FRoot.OnResize := nil;
FRoot := ARoot;
if Assigned(FRoot) then
FRoot.OnResize := RootResized;
Invalidate;
end;
procedure TNBLayoutRouter.DefineRoute(const ABp: TNBLayoutBreakpoint; const AControl,
ASlot: TControl; const AAlign: TAlignLayout);
var
LKey: Integer;
LList: TNBRouteList;
LRoute: TNBRoute;
begin
if (AControl = nil) or (ASlot = nil) then
raise EArgumentNilException.Create('Control/Slot не могут быть nil');
LKey := KeyOf(ABp);
if not FRoutes.TryGetValue(LKey, LList) then
begin
LList := TNBRouteList.Create;
FRoutes.Add(LKey, LList);
end;
LRoute.Control := AControl;
LRoute.TargetSlot := ASlot;
LRoute.Align := AAlign;
LList.Add(LRoute);
end;
function TNBLayoutRouter.KeyOf(const ABp: TNBLayoutBreakpoint): Integer;
begin
Result := Ord(ABp);
end;
procedure TNBLayoutRouter.RootResized(Sender: TObject);
begin
Invalidate;
end;
procedure TNBLayoutRouter.Invalidate;
begin
if (FRoot = nil) or FUpdating then Exit;
// Debounce: mehrere Resize-Events zu einem Apply bündeln.
if FPending then Exit;
FPending := True;
TThread.ForceQueue(nil,
procedure
begin
ApplyPending;
end);
end;
procedure TNBLayoutRouter.ApplyPending;
var
LBp: TNBLayoutBreakpoint;
LSize: TSizeF;
begin
if (FRoot = nil) or (not FPending) then Exit;
FPending := False;
LSize := TSizeF.Create(FRoot.Width, FRoot.Height);
if Assigned(FOnGetBreakpoint) then
LBp := FOnGetBreakpoint(LSize)
else
LBp := DefaultBreakpoint(LSize);
if LBp = FCurrent then Exit;
ApplyRoutes(LBp);
FCurrent := LBp;
if Assigned(FOnAfterApply) then
FOnAfterApply(LBp);
end;
procedure TNBLayoutRouter.ApplyRoutes(const ABp: TNBLayoutBreakpoint);
var
LList: TNBRouteList;
LRoute: TNBRoute;
begin
if FUpdating then Exit;
FUpdating := True;
try
if not FRoutes.TryGetValue(KeyOf(ABp), LList) then
Exit;
// Parent-Wechsel beeinflusst Z-Order.
// Reihenfolge wird durch DefineRoute-Aufrufe festgelegt.
for LRoute in LList do
begin
if LRoute.Control.Parent <> LRoute.TargetSlot then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align nach Parent setzen.
LRoute.Control.Align := LRoute.Align;
LRoute.Control.Visible := True;
end;
finally
FUpdating := False;
end;
end;
function TNBLayoutRouter.DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
var
W, H: Single;
begin
W := AClientSize.cx;
H := AClientSize.cy;
// Kombiniert: sehr geringe Höhe behandelt wie „kleiner“, z. B. Split-Screen oder Kiosk.
if (W < 480) or (H < 360) then Exit(bpXS);
if (W < 768) or (H < 480) then Exit(bpSM);
if W < 1024 then Exit(bpMD);
if W < 1440 then Exit(bpLG);
Result := bpXL;
end;
end.
Использование в форме: определение слотов, маршрутизация блоков
В дизайнере вам нужны только слоты и блоки. Маршрутизатор переназначает блоки соответствующим образом. Это снижает хаос в работе дизайнера и сохраняет правила в Delphi-коде проверяемыми.
procedure TFrmOrders.FormCreate(Sender: TObject);
var
LPrevFocus: IControl;
begin
FRouter := TNBLayoutRouter.Create(Self);
FRouter.AttachRoot(layRoot); // TLayout Align=Client
// Breakpoint XS: Stack
FRouter.DefineRoute(bpXS, layToolbar, slotTop, TAlignLayout.Top);
FRouter.DefineRoute(bpXS, layNav, slotContent, TAlignLayout.Top);
FRouter.DefineRoute(bpXS, layList, slotContent, TAlignLayout.Client);
FRouter.DefineRoute(bpXS, layDetails, slotContent, TAlignLayout.Bottom);
// Breakpoint MD: 3-Spalten
FRouter.DefineRoute(bpMD, layToolbar, slotTop, TAlignLayout.Top);
FRouter.DefineRoute(bpMD, layNav, slotLeft, TAlignLayout.Client);
FRouter.DefineRoute(bpMD, layList, slotCenter, TAlignLayout.Client);
FRouter.DefineRoute(bpMD, layDetails, slotRight, TAlignLayout.Client);
// Fokus-Falle entschärfen: vor/nach Apply RESTaurieren.
FRouter.OnAfterApply :=
procedure(const ABp: TNBLayoutBreakpoint)
begin
// Beispiel: Fokus wiederherstellen, wenn FMX ihn beim Parent-Wechsel verliert.
if Assigned(Screen) and Assigned(Screen.Focused) then
LPrevFocus := Screen.Focused
else
LPrevFocus := nil;
if (LPrevFocus <> nil) and (LPrevFocus.GetObject is TFmxObject) then
(LPrevFocus.GetObject as TControl).SetFocus;
end;
// Optional: eigene Logik, wenn Sie Portrait/Landscape härter trennen wollen.
FRouter.OnGetBreakpoint :=
function(const S: TSizeF): TNBLayoutBreakpoint
begin
if (S.cx < 820) or (S.cy < 500) then
Result := bpSM
else
Result := bpMD;
end;
end;Подводные камни: где подход может дать сбой
Штормы Resize: почему Debounce в FMX обязателен
FMX вызывает OnResize не только при действиях пользователя, но и при смене Parent-/Align или при изменении масштабирования. Без Debounce вы в худшем случае будете маршрутизировать несколько раз за кадр. Поэтому маршрутизатор сводит всё через TThread.ForceQueue к «одному Apply на цикл сообщений». Это прагматичный компромисс: вы получаете стабильность без таймерной логики или искусственных задержек.
Z-Order и HitTest: невидимые блокировщики кликов
Смена родительского контейнера меняет Z-Order. Если после переключения breakpoint’а кнопки перестают реагировать на клики, часто дело не в том, что «Visible» установлено неправильно, а в том, что какой-то контейнер сверху перехватывает события. В FMX ключевое свойство — HitTest: прозрачный лейаут с HitTest=True блокирует мышь/тач. Для оверлеев (Drawer, Flyouts) предусмотрите отдельный slotOverlay, который намеренно находится «сверху», и проверяйте значения HitTest по умолчанию после смены стиля.
TGridPanelLayout и процентные столбцы: усилители рефлоу
Если вы используете TGridPanelLayout с процентными значениями, лучше перемещать целые блоки грида, а не отдельные дочерние элементы грида. Перерасчёты процентов + цепочки Align + смены родителя — типичная причина нестабильных промежуточных состояний. Альтернативой TGridPanelLayout в некоторых экранах может быть простая система слотов с фиксированной шириной для боковой панели/деталей, поскольку она более детерминирована.
Сравнение: «Visible-Layouts» vs. переназначение через Router
| Критерий | Несколько деревьев макета (переключение Visible) | Layout-маршрутизатор (переподключение элементов управления) |
|---|---|---|
| Состояние (редактирование/прокрутка/выбор) | Риск дрейфа, из‑за дублирования экземпляров | Стабильно, поскольку один экземпляр на блок |
| Отладка | Ошибки видны только в «Варианте B» | Единое дерево элементов управления, воспроизводимое применение изменений |
| Сопровождение | Удобно для дизайнера, в дальнейшем дорого обходится | Код поддаётся ревью, правила централизованы |
| FMX-пограничные случаи (Resize/DPI) | Часто мерцание и проблемы с таймингом | Debounce/Guard управляемы |
Классификация с источниками: почему группировка «UI-Tick» технически обоснована
Шаблон «собирать дорогие обновления UI и выполнять их контролируемо» актуален не только для Layout, но и для всего, что бурно реагирует на UI‑события (Requests, Logs, Bindings). Тот, кто в клиенте помимо Layout хочет аккуратно вести сетевую или отладочную телеметрию, выигрывает от той же дисциплины: чёткие триггеры, чёткие шлюзы, отсутствие неукротимого потока событий. В этом смысле принцип Debounce/Guard хорошо сочетается с надёжными операционными подходами, как описано в фрагментах Net-Base по Overload‑Gates (контроль параллелизма вместо «всё одновременно») — см. материал про Concurrency‑Gate в Delphi-REST-Server: High Performance REST Server in Delphi: Concurrency-Gate, 429/503 und sauberes Overload-Verhalten.
А если в FMX‑клиенте вы дополнительно используете встроенный веб‑интерфейс (например для аутентификации, просмотра документов или портальных элементов), дисциплина жизненного цикла так же критична: инициализация, Dispose, отписка от событий. Это концептуально похоже на «чёткую изоляцию смены Parent». Практическая точка опоры: Delphi WebView2 в FMX: путь принятия решений для жизненного цикла, Bridge, загрузок и стабильной логики Dispose.
Однозначная редакционная оценка
С редакционной точки зрения Layout-Router не «Nice-to-have», а осмысленная инвестиция, как только экран должен корректно обслуживать более двух форм‑факторов: он вынуждает принять явное решение по Layout и делает вопросы состояния видимыми. Цена — необходимость сознательно разбивать UI‑блоки и поддерживать чистоту структуры слотов; кто этого не сделает, просто создаст дополнительный слой хаоса.
Вывод: когда имеет смысл использовать Router для адаптивных Layout в FMX — и границы применения
Router особенно оправдан, если вы модернизируете FMX‑приложение в долгосрочной перспективе, обслуживаете несколько классов устройств и должны поддерживать стабильные состояния (вводы, выделения, позиции прокрутки). Он также полезен в ситуациях с legacy‑кодом, поскольку централизует правила точек перелома (breakpoints) и снижает риск распределённо встроенных особых случаев.
Ограничения: если требуется очень «fluid» макет (плиточные стены, Masonry, динамические карты), специализированные классы компоновки или собственный алгоритм размещения элементов будут целесообразнее, чем slot‑маршрутизация. И если экран состоит из очень большого числа отдельных элементов управления, список маршрутов становится нечитабельным: сначала рефакторьте (соберите в более крупные блоки), затем применяйте маршрутизацию.
Если вам нужно в существующем Delphi/FMX-решении стабильно поддержать макет на нескольких форм-факторах, не перенося при этом эксплуатационных проблем (фокус, HitTest, Resize-штормы) в повседневную работу, это часто можно быстро локализовать в ходе технического ревью: свяжитесь с нами.
Для этой темы также важны Delphi Fmx Breakpoints и Firemonkey Layout. В статье эти аспекты систематизированы в понятной форме и показано, на что следует обращать внимание в повседневной практике.
Следующий шаг
Если из темы становится реальный проект, архитектуру, существующее состояние и эксплуатацию следует рассматривать совместно на ранней стадии.
Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.
- Текущее состояние, целевое состояние и технические риски оцениваются совместно.
- REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
- Вы заранее видите, какой путь экономически и операционно жизнеспособен.