От темата в списанието към проектната практика
Подходящи страници за услуги и технологии към публикацията
Който в Delphi FireMonkey трябва да поддържа няколко форм-фактора, бързо стига до Responsive Layouts FMX – и не по-малко бързо до смес от Align-каскади, скрити layout-контейнери и дизайнерски workaround-и, които при следващата смяна на DPI или ротация се срутват. В развита бизнес софтуерна клиентска част това е особено неприятно: UI-то продължава да се развива, екипите се сменят и изведнъж логиката е закачена за визуални детайли.
Сърцевината на проблема: FireMonkey предлага много градивни елементи (напр. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), но няма „родна“ Breakpoint-система като в уеба. Възможно е да се реагира на промени в размера, но без ясна архитектура това води до „if Width < … then …“ разпилени из много форми.
Тази статия показва един Layout-Router: малък компонент, който централизира управлението на Breakpoints и прехвърля Controls (или цели layout-блокове) между предварително подготвени слотове. Целта: състоянията да се запазят, кодът да остане поддържим, а гранични случаи като ротация, вложени layout-и и re-entrancy да бъдат омекотени. Добавени са и няколко по-малко очевидни трика, които на практика правят разликата между „работи в демото“ и „работи стабилно в експлоатация“.
Защо Breakpoints в FMX са различни от тези в уеб
В уеб-лейаути Breakpoints обикновено са декларативни (CSS Media Queries). В FMX решенията за оформление обикновено се взимат императивно по време на изпълнение: при OnResize се превключва. Към това се прибавят специфики за платформата:
- Физически пиксели vs. логически пиксели:
ClientWidth/ClientHeightса в логически единици (зависят от скалирането). Смени на DPI (напр. Windows Per-Monitor-DPI) могат да предизвикат повторно пресмятане на оформленията, без „физически“ нещо да се е променило. - Ротация и Safe Areas: Мобилните платформи предоставят Insets (Notch/Safe Area) – в зависимост от ОС и устройство. „Breakpoint, базиран само на ширина“ често е късоглед, защото използваемата площ може да е по-малка от чистия размер на прозореца.
- Layout-процес: FireMonkey изчислява оформленията в етапи. Ако промените Parent/Align в неправилен момент, възникват странични ефекти (напр. многократно пренареждане или трепкащи размери).
Layout-Router адресира това, като (1) раздвързва „кога“ (Resize/Scale/Rotation) от „как“ (правила за оформление) и (2) концентрира правилата на едно място. За техническите лидери най-важният ефект е: получават ясно, проверимо централизирано решение вместо множество локални частни случаи.
Архитектура: Layout-Router със слотове вместо създаване на контроли
Чистият трик за FMX: не създавайте динамично нови Controls, а прехвърляйте съществуващите Controls между Slots. Слотът е просто контейнер (напр. TLayout), който представлява област от UI-то: Sidebar, Toolbar, Content, Footer, Details-Pane.
Предимства в индивидуален корпоративен софтуер:
- Състоянията се запазват (Edit-Text, позиция на скрол, селектирани елементи), защото инстанциите не се пресъздават.
- По-малък риск от дублирано свързване на събития, таймери или bindings.
- Правилата за оформление стават видими: „кой блок е в кой слот“ може да се проследи и прегледа за всеки Breakpoint.
Важно за практиката: Разделяйте UI-блоковете достатъчно грубо. Ако премествате 30 отделни контрола, самият списък с маршрути става източник на грешки. По-добри са контейнери като layFilterBar, layNavigation, layResultList, layDetails.
Фрагмент от изходния код: Breakpoint-Router за адаптивни оформления FMX
Следният код е предназначен като помощна единица, която можете да използвате във FMX-формуляри. Той изчислява един Breakpoint (XS/SM/MD/LG/XL) и премества дефинирани контроли в дефинирани slot-контейнери. Важни детайли:
- Debounce чрез
TThread.ForceQueue: няколко Resize-събития се обединяват в едно обновяване (по-малко трептене на UI, по-малко reflow-цикли). - Re-Entrancy-Schutz: обновяването на оформлението често само по себе си задейства Resize/Layout.
- Optional: Orientierung (Portrait/Landscape) може да бъде включена в Breakpoint-логиката.
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);
// Mapping: кое Control да се постави в кой слот (контейнер) за даден Breakpoint.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // типично TLayout или TPresentedControl
Align: TAlignLayout;
end;
TNBRouteList = TList<TNBRoute>;
TNBGetBreakpointEvent = reference to function(const AClientSize: TSizeF): TNBLayoutBreakpoint;
TNBLayoutRouter = class(TComponent)
private
FRoot: TControl;
FPending: Boolean;
FUpdating: Boolean;
FCurrent: TNBLayoutBreakpoint;
FOnGetBreakpoint: TNBGetBreakpointEvent;
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;
end;
implementation
{ TNBLayoutRouter }
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: прилагане само веднъж в рамките на Message-Loop
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) then
Exit;
if 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;
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 променя Z-Order.
// Ако редът е важен, извикайте DefineRoute в желаната последователност.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align задавайте чак след Parent, иначе Bounds може да се интерпретират по различен начин.
LRoute.Control.Align := LRoute.Align;
LRoute.Control.Visible := True;
end;
finally
FUpdating := False;
end;
end;
function TNBLayoutRouter.DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
var
W: Single;
begin
W := AClientSize.cx;
// Breakpoints умишлено груби, тъй като целевите платформи на FMX варират значително.
if W < 480 then Exit(bpXS);
if W < 768 then Exit(bpSM);
if W < 1024 then Exit(bpMD);
if W < 1440 then Exit(bpLG);
Result := bpXL;
end;
end.
Как да използвате Router-а във форма
Дефинирате слотове като TLayout (напр. layTop, layLeft, layContent) и след това регистрирате за всеки Breakpoint къде кои блокове да стоят. Типично е Sidebar и панелът с подробности при малки Breakpoint-и да се подредят един под друг.
procedure TFrmMain.FormCreate(Sender: TObject);
begin
FRouter := TNBLayoutRouter.Create(Self);
FRouter.AttachRoot(LayoutRoot); // z. B. ein TLayout, das Client-aligned ist
// XS: alles untereinander
FRouter.DefineRoute(bpXS, layToolbar, slotTop, TAlignLayout.Top);
FRouter.DefineRoute(bpXS, laySidebar, slotContent, TAlignLayout.Top);
FRouter.DefineRoute(bpXS, layDetails, slotContent, TAlignLayout.Top);
FRouter.DefineRoute(bpXS, layMain, slotContent, TAlignLayout.Client);
// MD: Sidebar links, Details rechts
FRouter.DefineRoute(bpMD, layToolbar, slotTop, TAlignLayout.Top);
FRouter.DefineRoute(bpMD, laySidebar, slotLeft, TAlignLayout.Client);
FRouter.DefineRoute(bpMD, layMain, slotCenter, TAlignLayout.Client);
FRouter.DefineRoute(bpMD, layDetails, slotRight, TAlignLayout.Client);
// Optional: eigene Breakpoint-Logik
FRouter.OnGetBreakpoint :=
function(const S: TSizeF): TNBLayoutBreakpoint
begin
if (S.cx < 700) or (S.cy < 420) then
Result := bpSM
else
Result := bpMD;
end;
end;Контекст: Защо „препривързването“ често е по-стабилно от превключване на Visible
Често срещан подход е за всяка вариация да се поддържат отделни дървета на оформлението и единствено да се превключва Visible. Това в дизайнерския изглед изглежда удобно, но има типични странични ефекти:
- Дублирано binding/събития: Две подобни контроли трябва да се поддържат синхронизирани (напр. две филтърни ленти).
- Ред на табулация и фокус: При превключване фокусът може да се изгуби или да попаднете в невидими контроли, ако TabStop/HitTest са неблагоприятно настроени.
- Разминаване на състоянието: Позициите на скрол, състоянията на селекция или редактираните текстове могат да се разминават.
Препривързването запазва инстанцията единна. Важно е да се режат блоковете на оформленията така, че да могат да се преместват независимо (напр. „страничен панел“ като собствен контейнер вместо много отделни контроли). Това се отплаща при поддръжка и анализ на грешки: дебъгвате една инстанция, а не два паралелни сенчести интерфейса.
Често срещани проблеми в практиката (и как да ги дебъгнете)
1) Поток от Resize-събития и повторно влизане (Re-Entrancy)
FMX задейства OnResize не само при промяна на размера от потребителя, но и при смяна на стил, промяна на родителя и понякога при промяна на DPI. Без дебаунс приложението може да попадне в цикли на пренареждане. Рутерът използва TThread.ForceQueue, за да отложи промените до следващия UI-тут.
Съвет за дебъг: логване (напр. чрез OutputDebugString) с брейкпойнт, размер и брояч на обновяванията помага да се открият Reflow-цикли. Ако допълнително логвате момента, в който ApplyRoutes започва и завършва, бързо ще видите дали един единствен Resize „каскадира“.
2) Z-Order, HitTest и „скрити“ блокиращи кликове
Смяната на родителя променя Z-Order. Ако оувърлеите (напр. Flyouts) вече не приемат кликове, често причината е, че над тях има контейнер с Client-aligned и HitTest е активиран. Вариант: предвидете умишлено отделен слот най-отгоре за overlay-площи и само там променяйте родителя на тези контроли. В FMX HitTest (дали контрол прихваща мишка/тъч събития) по-често е причината, отколкото видимостта.
3) TGridPanelLayout и процентни размери
TGridPanelLayout може да предизвика неочаквани презизчисления при процентни колони/редове в комбинация с Align=Client и динамично пренареждане. Ако трябва да използвате Grid, поставете Grid в слот и пренареждайте само цели Grid-блокове, не Grid-деца. Това намалява комбинаториката на проходите на оформлението.
4) Fokus, virtuelle Tastatur und „springende“ Eingabefelder
Краен случай, който се проявява в мобилни FMX-приложения и също на Windows-таблети: при пренареждане фокусирано Edit-Control може временно да загуби своя Parent. Това може да затвори виртуалната клавиатура или да нулира курсора. Практически се е доказало: преди Routing да запазите текущия фокус временно (Focused/IFMXFocusControl), след Routing (в същия UI-Tick) да възстановите фокуса. Това е особено полезно при форми за въвеждане, които превключват между „двуколонно“ (Tablet/PC) и „едноколонно“ (Phone).
Varianten: Breakpoints nach Formfaktor statt nur nach Breite
В реални мултиплатформени клиенти „ширината“ сама по себе си често не е правилният сигнал. Смислени варианти:
- Ширина и височина: много плоски прозорци (напр. касови терминали, разделени екрани) изискват други правила.
- Ориентация:
Landscapeна таблети често е подобен на десктоп, Portrait по-скоро „мобилен“. - Полезна площ в Safe-Area: В iOS/Android ефективно използваемата височина може значително да се намали от системните ленти. Който разглежда само
Height, понякога маршрутизира „твърде късно“.
Рутерът е умишлено построен така, че да можете да подмените функцията за Breakpoints. Това е полезно и в Legacy-ситуации, когато една и съща форма работи в няколко хоста (напр. веднъж като обикновен прозорец, веднъж в вграден контейнер).
Ungewöhnlich sauber: Layout-Routing als „Transaktion“
При по-големи екрани проблемът се дължи не толкова на самите Breakpoints, колкото на последователността на UI операциите. Практичен модел е да третирате Routing-а като транзакция: първо да решите, след това да пренаредите, и накрая да изпълните страничните ефекти (Visibility, фокус, обновяване на данните) подредено.
Конкретно това означава: избягвайте отделни контроли по време на пренареждане да задействат собствени събития, които от своя страна стартират оформлението или достъп до данни. В FMX това се случва например, когато при смяна на Parent се извикват OnEnter/OnExit или когато LiveBinding-Ausdruck се преразглежда вследствие на Bounds-Update. Ако наблюдавате такива ефекти, помага централен „Updating“-ключ (както в Router-а) плюс ясен пост-стъп: едва след ApplyRoutes скъпи операции могат да се изпълняват (напр. презареждане на списък, свързване на детайлния изглед).
Особено при клиенти с REST-достъп това е релевантно: нежелано Reload по време на Resize може да доведе до излишни заявки. В LAN това не се усеща, но при VPN или мобилна връзка веднага.
Wann sich der Ansatz lohnt – und wo er Grenzen hat
Layout-Routerът си заслужава, когато:
- едно FMX-приложение просъществува с години и няколко разработчика работят върху същите екрани,
- UI-блоковете могат да се разделят ясно (Sidebar/Details/Content),
- имате нужда от възпроизводими правила за Breakpoints, вместо ad-hoc Align-Tuning.
Ограниченията се появяват, когато един екран трябва да бъде силно „fluid“ (много динамични плочки, реални Masonry-оформления). Тогава TFlowLayout/TGridPanelLayout или собствени класове за оформление са по-подходящи. Също така, ако много отделни контроли превключват между слотове, поддържането на маршрутите става непрегледно – тогава е по-добре да се режат по-големи блокове или да се въведе декларативен слой за конфигурация (например JSON-конфигурация за присвояване на слотове, която се зарежда при стартиране).
Заключение: За Responsive оформления във FMX „Umhängen mit Breakpoints“ е прагматично междинно решение: по-малко хаос в дизайнера, ясни правила, стабилни състояния. То не заменя обмислена UI-структура, но ви дава надеждна рамка, за да развивате FMX-клиенти в дигитални корпоративни решения контролирано през различни форм-фактори.
Ако в съществуващо Delphi- или FMX-приложение искате да приложите такава архитектура на оформлението чисто, без да рискувате UI-регресии в експлоатационни сценарии, можете технически да го обсъдим с нас: обсъдете проект или модернизационно начинание с Net-Base.
В професионалния контекст също Delphi Fmx Breakpoints и Firemonkey Layout играят важна роля, когато интеграции, потоци от данни и по-нататъшно развитие трябва да се съгласуват безпроблемно.
Следваща стъпка
Когато темата прерасне в реален проект, архитектурата, съществуващото състояние и експлоатацията трябва да бъдат разгледани съвместно още в ранна фаза.
Подпомагаме не само при отделни въпроси, но и когато от фрагменти от изходен код, проблеми с наследени системи или идеи за портал трябва да бъде реализиран надежден корпоративен проект.
- Сегашното състояние, целевото състояние и техническите рискове се оценяват съвместно.
- REST, достъпът до данни, порталите и разгръщането не се отлагат като по-късни последици.
- Виждате рано кой път е икономически и експлоатационно жизнеспособен.