מהנושא במגזין ליישום בפרויקט
דפי שירות וטכניים רלוונטיים למאמר
מי שצריך לשרת מספר פורמט־פקטורים ב־Delphi ב־FireMonkey, נתקע במהירות ב־Responsive Layouts FMX — ובאותו קצב גם בתערובת של קסקדות Align, מכולות פריסה מוסתרות ו־Designer‑Workarounds, שעלולות לקרוס בעת שינוי DPI או סיבוב. בלקוחות תוכנה עסקית שהתפתחו לאורך זמן זה בעייתי במיוחד: ה־UI ממשיך להתפתח, צוותים מתחלפים, ולפתע לוגיקה קשורה לפרטים חזותיים.
ליבת הבעיה: FireMonkey מספקת הרבה בלוקים בנייה (למשל Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), אבל אין מערכת Breakpoint „נייטיבית“ כמו באינטרנט. אמנם אפשר להגיב לשינויי גודל, אך ללא ארכיטקטורה ברורה זה מסתיים ב־“if Width < … then …“ שמפוזר על פני טפסים רבים.
פוסט זה מראה Layout-Router: רכיב קטן שמנהל Breakpoints במרכז ומשתיל Controls (או בלוקים שלמים של Layout) בין Slots מוכנים מראש. המטרה: מצבים נשמרים, הקוד ניתן לתחזוקה, ומקרי קצה כמו סיבוב, פריסות מקוננות ו־Re‑Entrancy מוחלשים. בנוסף יש כמה תכסיסים פחות ברורים שמבחינה מעשית עושים את ההבדל בין „רץ בדמו“ ל“רץ יציב בהרצה“.
מדוע Breakpoints ב־FMX שונים מהאינטרנט
בפריסות ווב Breakpoints בדרך כלל דקלרטיביים (CSS Media Queries). ב־FMX החלטות פריסה בזמן ריצה הן בדרך כלל אימפרטיביות: ב־OnResize משנים את המצב. בנוסף יש תכונות ספציפיות לפלטפורמה:
- Device‑Pixel לעומת פיקסלים לוגיים:
ClientWidth/ClientHeightהם ביחידות לוגיות (תלויות בסקיילינג). שינויים ב־DPI (למשל Windows Per‑Monitor‑DPI) יכולים לטרגר פריסות מחדש מבלי שמשהו „פיזי“ משתנה. - סיבוב ו‑Safe Areas: פלטפורמות מובייל מספקות Insets (Notch/Safe Area) — תלוי ב‑OS ובמכשיר. „Breakpoint שמתבסס רק על רוחב“ הוא לעתים קצר רואי, כי השטח הזמין קטן מגודל החלון הגולמי.
- מעבר פריסה: FireMonkey מחשבת פריסות בשלבים. אם משנים Parent/Align ברגע הלא נכון נוצרים אפקטים לוואי (למשל חישוב חוזר מרובה של הפריסה או תנודות בגודל).
נתב פריסה מטפל בכך על ידי (1) ניתוק ה־“מתי“ (Resize/Scale/Rotation) מה־“איך“ (כללי הפריסה) ו־(2) ריכוז הכללים במקום אחד. עבור מובילי טכנולוגיה האפקט החשוב ביותר: הם מקבלים מרכז החלטות ברור וניתן לבדיקה במקום שורה של מקרים מיוחדים מקומיים.
ארכיטקטורה: Layout‑Router עם Slots במקום יצירת Controls
הפתרון הנקי עבור FMX: לא ליצור Controls באופן דינמי, אלא לתלות Controls קיימים בין Slots. Slot הוא פשוט מכולה (למשל TLayout) שמייצגת אזור בממשק: Sidebar, Toolbar, Content, Footer, Details‑Pane.
יתרונות בתוכנה עסקית מותאמת:
- מצבים נשמרים (Edit‑Text, Scrollposition, פריטים נבחרים), כיוון שמופעים לא נבנים מחדש.
- סיכון מופחת לכפילות חיבורית של Events, Timers או Bindings.
- כללי הפריסה הופכים לגלויים: „איזה בלוק נמצא באיזה Slot“ ניתן לעקוב ולבדוק עבור כל Breakpoint.
חשוב לפרקטיקה: חלקו את בלוקי הממשק (UI) באופן גס דיו. אם תעבירו כ־30 Controls בודדים, רשימת הנתיבים עצמה תהפוך למקור שגיאות. עדיף לעבוד עם מכולות כגון layFilterBar, layNavigation, layResultList, layDetails.
קטע מקור: Breakpoint-Router לפריסות רספונסיביות FMX
הקוד הבא מיועד כיחידת עזר שניתן להשתמש בה ב‑FMX-Forms. הוא מחשב Breakpoint (XS/SM/MD/LG/XL) ומעביר Controls מוגדרים אל מכולות‑סלוט מוגדרות. פרטים חשובים:
- Debounce באמצעות
TThread.ForceQueue: אירועי Resize מרובים מאוחדים לעדכון יחיד (פחות ריצוד UI, פחות לולאות reflow). - הגנה מפני Re-Entrancy: עדכון הפריסה לעתים קרובות מעורר שוב אירועי Resize/Layout.
- אופציונלי: אוריינטציה (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);
// מיפוי: איזה Control צריך להיות באיזה Slot (Container) עבור 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: החל רק פעם אחת בכל לולאת ההודעות
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 היכן מיוננים הבלוקים. בדרך כלל סרגל הצד וחלונית הפרטים עוברים אחד מתחת לשני ב-breakpoints קטנים.
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;Einordnung: Warum „Umhängen“ oft stabiler ist als Visible-Schalten
גישה נפוצה היא להחזיק עבור כל וריאנט עצי פריסה נפרדים ולשנות רק את Visible. זה נוח בעבודה עם המעצב, אבל יש לכך תופעות לוואי אופייניות:
- כפילות ב-Binding וב-Events: שני רכיבים דומים צריכים להישמר מסונכרנים (למשל שתי שורות סינון).
- סדר Tab ופוקוס: בעת המרת בין הווריאנטים מאבדים פוקוס או נתקלים ברכיבים בלתי נראים אם TabStop/HitTest אינם מוגדרים כראוי.
- State Drift: מיקומי גלילה, מצבי בחירה או טקסטים שעוברים עריכה עלולים לסטות ולהתמזג.
המיקום מחדש (Umhängen) שומר על אינסטנציה ברורה. חשוב לחלק את בלוקי הפריסה כך שניתן להזיז כל אחד באופן עצמאי (למשל „סרגל צד“ כמכולה עצמאית במקום פריסה של רכיבים יחידים). זה משתלם בתחזוקה ובניתוח שגיאות: אתם מדבגים אינסטנציה אחת, לא שתי ממשקי-צל מקבילים.
Stolperfallen in der Praxis (und wie man sie debuggt)
1) Resize-Stürme und Re-Entrancy
FMX מפעיל את OnResize לא רק כאשר המשתמש משנה גודל, אלא גם כשיש החלפות סגנון, שינויים ב-parent ובעתים גם כש-DPI משתנה. בלי מנגנון דיבאונס האפליקציה עלולה להיתקע בלולאות פריסה. ה-Router משתמש ב-TThread.ForceQueue כדי לדחוף את השינויים לטיק UI הבא.
טיפ לדיבוג: רישום לוגים (למשל באמצעות OutputDebugString) יחד עם נקודת שבירה, מידות ומונה עדכונים עוזר לאתר לולאות Reflow. אם תתעדו גם את רגע ההתחלה והסיום של ApplyRoutes, תראו במהירות אם שינוי גודל יחיד „מתפשט“ בקסקדה.
2) Z-Order, HitTest und „unsichtbare“ Klick-Blocker
שינוי ה-parent משנה את ה-Z-Order. אם overlays (למשל Flyouts) מפסיקים לקבל קליקים, לעיתים קרובות הדבר נובע מקיומו של Container מיושר-Client שמעליהם ועם HitTest פעיל. גישה אלטרנטיבית: להקצות אזור סלאט נפרד ל-overlays בראש ההיררכיה ולהצמיד לשם רכיבים מסוג זה. ב-FMX, HitTest (האם רכיב קולט אירועי עכבר/מגע) היא לרוב הגורם יותר מאשר הנראות עצמה.
3) TGridPanelLayout und prozentuale Größen
TGridPanelLayout יכול לגרום לחישובי-מחדש בלתי צפויים כאשר משתמשים בעמודות/שורות באחוזים בשילוב עם Align=Client והעברות דינמיות. אם אתם חייבים להשתמש ב-Grid, הניחו את ה-Grid בתוך Slot, והעבירו רק בלוקים שלמים של Grid, לא את ילדי ה-Grid. זה מצמצם את קומבינטוריקת מעברי ה-layout.
4) Fokus, virtuelle Tastatur und „springende“ Eingabefelder
מקרה גבולי שמופיע באפליקציות FMX ניידות וגם בטאבלטים Windows: בעת ההעברה עלול Edit-Control שממוקד לאבד באופן זמני את ה-Parent. זה עלול לסגור את המקלדת הווירטואלית או לאפס את הסמן. בפועל נהוג לשמור זמנית את הפוקוס הנוכחי לפני הניתוב (Focused/IFMXFocusControl) ולשחזר את הפוקוס לאחר הניתוב (באותו UI-Tick). זה משתלם במיוחד בטפסי קלט שמחליפים בין „zweispaltig“ (Tablet/PC) ל-„einspaltig“ (Phone).
Varianten: Breakpoints nach Formfaktor statt nur nach Breite
בלקוחות מולטיפלטפורמה במציאות, „רוחב“ לבדו לעיתים קרובות אינו האות המתאים. וריאציות הגיוניות:
- רוחב וגובה: חלונות שטוחים מאוד (למשל מסופי קופה, מסכים מחולקים) זקוקים לחוקים שונים.
- אוריינטציה:
Landscapeבטאבלטים הוא לעתים קרובות „דומה לשולחן עבודה“, Portrait בדרך כלל „כמו מובייל“. - שטח שימושי ב-Safe-Area: ב-iOS/Android הגובה הזמין בפועל יכול להתכווץ משמעותית עקב סרגלי מערכת. מי שבוחן רק
Heightעלול לנתב לעיתים „מאוחר מדי“.
ה-Router נבנה במכוון כך שניתן להחליף את פונקציית ה-Breakpoint. זה גם עוזר במצבי מורשת, כאשר אותו טופס רץ בכמה Hosts שונים (למשל פעם כחלון רגיל, פעם במכולה מוטמעת).
Ungewöhnlich sauber: Layout-Routing als „Transaktion“
במסכים גדולים הבעיה פחות נשקלת בנקודות השבירה עצמן ויותר בסדר פעולות ה-UI. תבנית מעשית היא להתייחס לניתוב כטרנזקציה: קודם להחליט, אחר כך להעביר, ואז לבצע באופן מסודר את ההשפעות הצדדיות (Visibility, פוקוס, רענון נתונים).
במובן הקונקרטי: הימנעו ממצב שבו Controls בודדים מפעילים במהלך ההעברה אירועים שמפעילים בעצמם עיבוד של Layout או גישה לנתונים. ב-FMX זה קורה למשל כאשר בעת החלפת ה-Parent מופעלים OnEnter/OnExit או כאשר ביטוי של LiveBinding מוערך מחדש בעקבות עדכון Bounds. אם אתם רואים אפקטים כאלה, עוזר מתג מרכזי של „Updating“ (כמו ב-Router) בתוספת של שלב Post ברור: רק לאחר ApplyRoutes מותר להריץ פעולות כבדות (למשל טעינת רשימה מחדש, קשירת תצוגת פרטים).
במיוחד אצל קליינטות עם REST-גישה זה רלוונטי: רילוד לא מכוון במהלך שינוי גודל יכול להוביל לבקשות מיותרות. זה לא בולט ב-LAN, אבל ב-VPN או בנייד מרגישים אותו מיד.
Wann sich der Ansatz lohnt – und wo er Grenzen hat
ה-Layout-Router משתלם כאשר:
- יישום FMX חי לאורך שנים ומספר מפתחים עובדים על אותם מסכים,
- גושי UI ניתנים להפרדה ברורה (Sidebar/Details/Content),
- אתם זקוקים לכללי Breakpoint שניתנים לשחזור, במקום כוונון Align ad-hoc.
גבולות ניכרים עולים כאשר מסך צריך להיות מאוד „fluid“ (ריבוי אריחים דינמיים, Masonry-Layouts אמיתיים). אז TFlowLayout/TGridPanelLayout או מחלקות Layout ייעודיות מתאימות יותר. גם כשרכיבי UI בודדים רבים עוברים בין Slots, תחזוקת הראוטים נעשית לא נוחה – עדיף אז לחתוך לבלוקים גדולים יותר או להכניס שכבת קונפיגורציה דקלרטיבית (למשל קובץ JSON לקביעת הקצאות Slots, הטעון בעת ההשקה).
מסקנה: עבור פריסות רספונסיביות ב-FMX, „התאמה באמצעות Breakpoints“ היא פתרון פרגמטי: פחות כאוס בעיצוב, כללים ברורים, מצבים יציבים. זה אינו מחליף מבנה UI מתוכנן היטב, אך מספק שלד אמין שמאפשר לפתח ולתחזק את לקוחות FMX בפתרונות ארגוניים דיגיטליים באופן מבוקר על פני גורמי צורה שונים.
אם ברצונכם ליישם ארכיטקטורת פריסה כזו באופן נקי ביישום Delphi קיים או ביישום FMX מבלי לסכן רגרסיות UI בתרחישי הפעלה, ניתן לקבוע זאת אצלנו לניתוח טכני: לדון בפרויקט או במהלך מודרניזציה עם Net-Base.
בסביבה המקצועית גם Delphi Fmx Breakpoints ו-Firemonkey Layout ממלאים תפקיד חשוב כאשר אינטגרציות, זרמי נתונים ופיתוח המשכי חייבים להשתלב באופן מסודר.
השלב הבא
כאשר הנושא הופך לפרויקט ממשי, יש לבחון כבר בשלב מוקדם את הארכיטקטורה, המצב הקיים והתפעול יחד.
אנו תומכים לא רק בשאלות נקודתיות, אלא גם כשמקטעי קוד מקור, נושאי Legacy או רעיונות פורטל אמורים להפוך לפרויקט ארגוני מהימן ועמיד.
- המצב הקיים, תמונת היעד והסיכונים הטכניים מוערכים יחד.
- REST, גישה לנתונים, פורטלים ו-Rollout לא יידחו כתוצאות מאוחרות.
- אתם מזהים מוקדם איזה נתיב בר-קיימא מבחינה כלכלית ותפעולית.