Lehden aiheesta projektikäytäntöön
Artikkeliin liittyvät palvelu- ja tekniikkasivut
Kun Delphi FireMonkeyssä täytyy tukea useita form-faktoreita, päädyt nopeasti käyttämään Responsiiviset asettelut FMX -ratkaisuja – ja yhtä nopeasti yhdistelmään Align-kaskadeja, piilotettuja layout-konttereita ja Designer-kiertoteitä, jotka kaatuvat seuraavassa DPI- tai rotaatiovaihdossa. Kasvaneissa yritysohjelmistoasiakasratkaisuissa tämä on erityisen hankalaa: käyttöliittymää jatkokehitetään, tiimit vaihtuvat ja yhtäkkiä logiikka riippuu visuaalisista yksityiskohdista.
Ongelman ydin: FireMonkey tarjoaa paljon rakennuspalikoita (esim. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), mutta ei „natiivista“ breakpoint-järjestelmää kuten verkossa. Koon muutoksiin voi toki reagoida, mutta ilman selkeää arkkitehtuuria se johtaa moniin „if Width < … then …“ -tarkistuksiin eri lomakkeissa.
Tässä kirjoituksessa esitellään Layout-Router: pieni komponentti, joka hallinnoi breakpointteja keskitetysti ja ripustaa Controls-komponentteja (tai koko layout-lohkoja) valmiiksi määriteltyjen slotien välillä. Tavoite: tilat säilyvät, koodi on ylläpidettävää ja reunatapaukset kuten rotaatio, sisäkkäiset layoutit ja uudelleensisäänpääsy (re-entrancy) vaimennetaan. Mukana on pari vähemmän ilmeistä niksiä, jotka käytännössä erottavat „demo toimii“ -ratkaisun ja „toimii vakaasti tuotannossa“ -ratkaisun.
Miksi breakpointit FMX:ssä eroavat verkon käytännöistä
Verkkolayouteissa breakpointit ovat yleensä deklaratiivisia (CSS Media Queries). FMX:ssä layout-päätökset tehdään tyypillisesti ajoituksellisesti eli imperatiivisesti: OnResize-käsittelijässä vaihdetaan tiloja. Lisäksi on alustakohtaisia erikoisuuksia:
- Laitepikselit vs. loogiset pikselit:
ClientWidth/ClientHeightovat loogisissa yksiköissä (riippuen skaalauksesta). DPI-vaihdokset (esim. Windows Per-Monitor-DPI) voivat triggeröidä layoutin uudelleen ilman, että „fyysisesti“ mikään muuttuu. - Rotaatio ja Safe Areas: Mobiilialustat antavat insettejä (Notch/Safe Area) – riippuen käyttöjärjestelmästä ja laitteesta. Pelkkään leveyteen perustuva breakpoint on usein liian kapea ajatus, koska käytettävissä oleva pinta-ala voi olla pienempi kuin ikkuna kokonaisuudessaan.
- Layout-pass: FireMonkey laskee layoutit vaiheittain. Jos Parent/Align muutetaan väärässä hetkessä, syntyy sivuvaikutuksia (esim. moninkertainen reflow tai kokoa vilkkuvia muutoksia).
Layout-Router vastaa tähän erottamalla (1) „milloin“ (Resize/Scale/Rotation) eriäminen ja (2) „miten“ (layout-säännöt), sekä keskittämällä säännöt yhteen paikkaan. Teknillisille johtajille tärkein vaikutus on se, että he saavat selkeän, tarkastettavan päätöskeskuksen monien paikallisten poikkeustapauksien sijaan.
Arkkitehtuuri: Layout-reititin slotteihin kontrollien uudelleenluomisen sijaan
Siisti niksi FMX:lle: älä luo kontrollikomponentteja dynaamisesti uudelleen, vaan siirrä olemassa olevia ohjaimia slottien välillä. Slot on yksinkertaisesti kontaineri (esim. TLayout), joka edustaa käyttöliittymän aluetta: sivupalkki, työkalurivi, sisältö, alatunniste, yksityiskohtapaneeli.
Edut räätälöidyssä yritysohjelmistossa:
- Tilat säilyvät (muokkauskenttien sisältö, vierityssijainti, valitut kohteet), koska instansseja ei rakenneta uudelleen.
- Vähemmän riskiä kaksoiskytkennöille tapahtumissa, ajastimissa tai sidoksissa (bindings).
- Layout-säännöt tulevat näkyviksi: „mikä lohko sijaitsee missäkin slotissa“ on jäljitettävissä ja tarkistettavissa jokaisen breakpointin kohdalla.
Käytännössä tärkeää: Jaa käyttöliittymälohkot riittävän karkeasti. Jos siirrät 30 yksittäistä kontrollia uudelleen, reittilista itse saattaa muodostua virhelähteeksi. Parempia ovat säiliöt kuten layFilterBar, layNavigation, layResultList, layDetails.
Lähdekoodiesimerkki: Breakpoint-reititin responsiivisille FMX-asetteluille
Seuraava koodi on tarkoitettu apuyksiköksi, jota voit käyttää FMX-lomakkeissa. Se laskee breakpointin (XS/SM/MD/LG/XL) ja kytkee määritellyt kontrollit määriteltyihin slot-säiliöihin. Tärkeitä yksityiskohtia:
- Debounce käyttäen
TThread.ForceQueue: useita Resize-tapahtumia yhdistetään yhdeksi päivitykseksi (vähemmän käyttöliittymän tärinää, vähemmän reflow-silmukoita). - Re-Entrancy-Schutz: Asettelupäivitys laukaisee usein itse uudelleen Resize/Layout-tapahtuman.
- Valinnainen: orientaatio (Portrait/Landscape) voidaan ottaa huomioon breakpoint-logiikassa.
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);
// Kartoitus: welches Control soll in welchen Slot (Container) für einen Breakpoint.
// Kartoitus: mikä Control sijoitetaan mihin slottiin (kontaineriin) tietylle breakpointille.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // typischerweise TLayout oder TPresentedControl
// tyypillisesti TLayout tai 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; // manuell neu berechnen
// pakottaa uudelleenlaskennan
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 dürfen nicht nil sein‘);
// Control/Slot eivät saa olla 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: nur einmal pro Message-Loop anwenden
// Debounce: suorita korkeintaan kerran per viestisilmukka
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;
// Achtung: Parent-Wechsel verändert Z-Order.
// Huom: Parentin vaihto muuttaa Z-järjestystä.
// Wenn Reihenfolge relevant ist, DefineRoute in gewünschter Reihenfolge aufrufen.
// Jos järjestyksellä on merkitystä, kutsu DefineRoute halutussa järjestyksessä.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align erst nach Parent setzen, sonst werden Bounds ggf. anders interpretiert.
// Aseta Align vasta Parentin jälkeen, muuten Bounds voidaan tulkita eri tavalla.
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 bewusst grob, da FMX Zielplattformen stark variieren.
// Breakpointit ovat tarkoituksella karkeita, koska FMX-kohdealustat vaihtelevat voimakkaasti.
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.
Routerin käyttö lomakkeessa
Määrittelet slotit TLayout-olioiksi (esim. layTop, layLeft, layContent) ja rekisteröit sitten kutakin breakpointia varten, missä kukin lohko sijaitsee. Tyypillistä on, että sivupalkki ja yksityiskohtapaneeli pienissä breakpointeissa siirtyvät allekkain.
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;Luokittelu: Miksi uudelleenparentointi on usein vakaampaa kuin Visible-tilan vaihtaminen
Yleinen lähestymistapa on pitää kullekin variantille erilliset layout-puut ja pelkästään vaihtaa Visible-tilaa. Tämä tuntuu Designerissa kätevältä, mutta aiheuttaa tyypillisiä sivuvaikutuksia:
- Kaksoissidonta / tapahtumat: Kaksi samankaltaista kontrollia on pidettävä synkronissa (esim. kaksi suodatinpalkkia).
- Tab-järjestys ja fokus: Vaihdettaessa fokus voi hävitä tai päätyä näkymättömiin kontrollieihin, jos TabStop/HitTest ovat epäedullisesti asetettu.
- Tilan hajaantuminen: Vieritysasemat, valintatilat tai muokatut tekstit voivat erota.
Uudelleenparentointi säilyttää instanssin yksiselitteisenä. Tärkeää on jakaa layout-lohkot siten, että niitä voi siirtää itsenäisesti (esim. „Sidebar“ erillisenä kontainerina sen sijaan, että olisi monta yksittäistä kontrollia). Tämä maksaa itsensä takaisin ylläpidossa ja vikadiagnostiikassa: debuggaat yhtä instanssia, et kahta rinnakkaista varjokäyttöliittymää.
Käytännön sudenkuopat (ja miten niitä debugataan)
1) Koonmuutosmyrskyt ja re-entrancy
FMX laukaisee OnResize-tapahtuman paitsi käyttäjän muuttaman koon seurauksena myös tyylinvaihdosten, parent-muutosten ja osittain DPI-muutosten yhteydessä. Ilman debounceta sovellus voi jäädä layout-silmukoihin. Router käyttää TThread.ForceQueue-kutsua siirtääkseen muutokset seuraavaan UI-tickiin.
Debuggausvinkki: lokitus (esim. OutputDebugString) breakpointin, koon ja päivityslaskurin kanssa auttaa löytämään reflow-silmukoita. Jos kirjaat lisäksi ajan, jolloin ApplyRoutes alkaa ja päättyy, näet nopeasti, aiheutuuko yksittäisestä Resize-tapahtumasta kaskaadi.
2) Z-Order, HitTest ja „näkymättömät“ klikkiblokkerit
Parentin vaihto muuttaa Z-Orderia. Jos overlayt (esim. flyoutit) eivät enää vastaanota klikkauksia, syynä on usein se, että client-aligned-kontainer sijaitsee niiden päällä ja HitTest on aktiivinen. Ratkaisu: varaa overlay-alueille tietoisesti oma slot aivan ylimmäksi ja parentoi tällaiset kontrollit vain sinne. FMX:ssä HitTest (se, sieppaako kontrolli hiiri-/touch-tapahtumat) on useammin syynä kuin näkyvyys.
3) TGridPanelLayout ja prosenttiperusteiset koot
TGridPanelLayout voi prosenttipohjaisten sarakkeiden/rivien yhteydessä yhdessä Align=Client -asetuksen ja dynaamisen uudelleenkytkennän kanssa aiheuttaa odottamattomia uudelleenlaskelmia. Jos sinun täytyy käyttää Grid:iä, sijoita Grid slottiin ja siirrä vain kokonaisia Grid-lohkoja, älä Grid-lapsielementtejä. Tämä vähentää layout-kierrosten kombinatoriikkaa.
4) Fokus, virtuelle Tastatur und „springende“ Eingabefelder
Reunatapaus, joka esiintyy mobiileissa FMX-sovelluksissa ja myös Windows-tableteilla: siirron yhteydessä fokusoitu Edit-ohjain voi hetkellisesti menettää parentin. Tämä voi sulkea virtuaalisen näppäimistön tai palauttaa kursorin paikkaa. Käytännössä toimii hyvin, jos ennen reititystä väliaikaisesti tallennatte nykyisen fokuksen (Focused/IFMXFocusControl) ja reitityksen jälkeen (samassa UI-tikissä) palautatte fokuksen. Tämä kannattaa erityisesti lomakkeissa, jotka vaihtavat „kaksisarakkeisesta“ (Tablet/PC) „yksisarakkeiseen“ (Phone).
Varianten: Breakpoints nach Formfaktor statt nur nach Breite
Todellisissa monialusta-asiakasohjelmissa pelkkä „leveys“ ei usein ole oikea signaali. Käytännöllisiä vaihtoehtoja ovat:
- Leveys ja korkeus: hyvin matalat ikkunat (esim. kassapäätteet, jaetut näytöt) tarvitsevat eri sääntöjä.
- Suunta:
Landscapetableteilla on usein „työpöydän kaltainen“, portrait-tila taas enemmän „mobiilimainen“. - Safe-Area-käyttöpinta-ala: iOS/Androidissa käytettävissä oleva efektiivinen korkeus voi pienentyä järjestelmäpalkkien takia merkittävästi. Jos tarkastellaan vain
Height:ia, reititys tapahtuu joskus „liian myöhään“.
Router on tarkoituksella rakennettu siten, että voit vaihtaa breakpoint-toiminnon. Tämä on hyödyllistä myös legacy-tilanteissa, kun sama lomake ajetaan useissa hosteissa (esim. kerran normaalina ikkunana, kerran upotetussa kontissa).
Ungewöhnlich sauber: Layout-Routing als „Transaktion“
Suuremmissa näkymissä ongelma ei niinkään ole itse breakpointeissa vaan UI-operaatioiden järjestyksessä. Käytännöllinen malli on käsitellä reititys transaktiona: ensin tehdään päätös, sitten siirretään komponentit, ja vasta sen jälkeen suoritetaan sivuvaikutukset (Visibility, fokus, datapäivitys) hallitussa järjestyksessä.
Konkreettisesti tämä tarkoittaa sitä, että vältetään tilannetta, jossa yksittäiset kontrollit laukaisevat omia eventtejään siirron aikana, ja nämä eventit puolestaan käynnistävät layoutin tai tietokutsut. FMX:ssä tätä tapahtuu esimerkiksi silloin, kun parentin vaihtuessa OnEnter/OnExit laukeaa tai LiveBinding-lauseke arvioidaan uudelleen bounds-päivityksen takia. Jos havaitsette tällaisia efektejä, auttaa keskeinen „Updating“-kytkin (kuten Routerissa) yhdistettynä selkeään post-stepiin: vasta ApplyRoutes:in jälkeen sallitaan kalliit operaatiot (esim. listan uudelleenlataus, detaljin sitominen).
Erityisesti asiakkaissa, joilla on pääsy REST:iin, tämä on merkityksellistä: tahaton reload resize-tilanteessa voi johtaa tarpeettomiin pyyntöihin. LAN-ympäristössä se ei välttämättä näy, mutta VPN:ssä tai mobiilissa vaikutus on välitön.
Wann sich der Ansatz lohnt – und wo er Grenzen hat
Layout-router kannattaa ottaa käyttöön, kun:
- FMX-sovellus elää useita vuosia ja useat kehittäjät työskentelevät samoilla näkymillä,
- UI-lohkot voidaan erottaa selkeästi (Sidebar/Details/Content),
- tarvitsette toistettavia breakpoint-sääntöjä sen sijaan, että tekisitte ad-hoc Align-säätöjä.
Rajat tulevat esiin, kun näkymän on oltava hyvin „fluidi“ (paljon dynaamisia laattoja, aidot Masonry-asettelut). Silloin TFlowLayout/TGridPanelLayout tai omat layout-luokat ovat sopivampia. Myös silloin, kun hyvin monet yksittäiset kontrollit vaihtavat paikkoja slotien välillä, reittien ylläpito käy epäselväksi – silloin on parempi leikata suurempia lohkoja tai ottaa käyttöön deklaratiivinen konfiguraatiokerros (esim. JSON-konfiguraatio slot-osoituksille, joka ladataan käynnistyksen yhteydessä).
Yhteenveto: FMX:n responsiivisissa asetteluissa ‚uudelleenkytkentä breakpointtien mukaan‘ on pragmaattinen keskitie: vähemmän suunnittelijakaaosta, selkeät säännöt, vakaat tilat. Se ei korvaa harkittua käyttöliittymärakennetta, mutta se antaa kestävän rungon, jolla FMX-asiakasohjelmia voi jatkokehittää hallitusti eri formfaktorien yli digitaalisissa yritysratkaisuissa.
Jos haluat jäljitellä tällaista asetteluarkkitehtuuria siististi olemassa olevaan Delphi- tai FMX-sovellukseen ilman, että UI-regressiot käyttötilanteissa muodostavat riskin, voimme mielellämme käsitellä sen kanssanne teknisesti: keskustelkaa projektista tai modernisointihankkeesta Net-Base kanssa.
Ammattimaisessa kontekstissa myös Delphi FMX-breakpointit ja Firemonkey-asettelut ovat tärkeitä, kun integraatioiden, datavirtojen ja jatkokehityksen on toimittava yhteen hallitusti.
Keskustelkaa projektista tai modernisointihankkeesta Net-Base kanssa.
Seuraava vaihe
Kun aiheesta tulee todellinen projekti, arkkitehtuuri, nykyinen järjestelmäkanta ja käyttö tulisi varhaisessa vaiheessa tarkastella yhdessä.
Emme tue pelkästään yksittäiskysymyksissä, vaan myös silloin, kun lähdekoodipalasista, legacy-aiheista tai portaali-ideoista halutaan muodostaa luotettava yrityshanke.
- Nykytila, tavoitetila ja tekniset riskit arvioidaan yhdessä.
- REST, datan käyttö, portaalit ja käyttöönotto eivät jätetä myöhempien seurausten varaan.
- Näette ajoissa, mikä ratkaisu on taloudellisesti ja toiminnallisesti kestävä.