Net-Base מגזין

03.06.2026

סורק קוד QR ב־Delphi FMX: סריקת מצלמה יציבה, בטוחה להרצה מרובת-פתילים וללא רעידות בממשק המשתמש

סורק קודי QR פרקטי Delphi FMX תלוי בלולאת חיי המצלמה, ב-threading ובעצירה/הפעלה נקייה. המאמר מציג גישה יציבה עם ZXing, Debounce, Frame-Throttling, חיתוך ROI וכן פרטי דיבוג ותפעול ל־Android ול־iOS.

03.06.2026

מהנושא במגזין ליישום בפרויקט

דפי שירות וטכניים רלוונטיים למאמר

סורק קוד QR Delphi FMX בפרקטיקה

סורק קוד QR Delphi FMX מורכב במהירות בדמו: הצגת תצוגת מקדימה של המצלמה, שליפת Bitmap, והרצת ZXing עליו. בתוכנה עסקית אמיתית (למשל קבלת סחורה, שיוך מכשירים, ניהול כרטיסים, תהליכי גישה) מתווספים אילוצים נוספים: האפליקציה עוברת לרקע, המצלמה מאבדת פוקוס, המשתמש מחזיק את המכשיר בזווית, פורמט התמונה משתנה — ולפתע אתם סורקים פעמיים בשנייה את אותו קוד או הממשק מקפץ כי הפענוח רץ ב-UI-Thread.

הבעיות הטיפוסיות הן פחות „ZXing לא יכול לקרוא“ ויותר מחזור חיים ואדריכלות: שחרור משאבים של המצלמה, תיזמון הפריימים, בטיחות גישה בת’רדים ל-TBitmap (GPU/CPU), ואופן Stop/Start ברור שעובד גם כאשר משתמשים מנווטים במהירות או ה-OS מסיר את המצלמה לזמן קצר.

סקירת ארכיטקטורה: Pipeline במקום „OnSampleBufferReady עושה הכל“

ניסיון מעשי הראה שפייפליין קטנה עם תחומי אחריות ברורים עובדת היטב:

  • מתאם מצלמה: מספק פריימים (או עותקים שלהם) בפורמט מוגדר.
  • דקודר: עובד בת’רד רקע ומחזיר תוצאות דרך Callback.
  • Gate/Debounce: מונע סריקות כפולות ומווסת עומס (Throttle).
  • שכבת UI: מציגה תצוגת מקדימה, באופן אופציונלי מסגרת מיקוד (ROI, „אזור עניין“) ומגיבה לתוצאות.

כך תמנעו חסימות הדדיות בין ה-UI, המצלמה והדקודר. „ROI“ כאן מתייחס לחלון חיפוש חתוך (למשל מרכזי 60%), שמפחית עומס מהדקודר ומצמצם תוצאות חיוביות שגויות. חשוב: ROI הוא כלי לביצועים ולנוחות שימוש, ולא מנגנון אבטחה.

קטע מקור: סורק קוד QR עמיד (FMX + ZXing) עם Debounce ועצירה נקייה

הקוד הבא מיועד כבלוק קומפקטי אך מותאם לפרויקט. הוא משתמש בZXing (Delphi-Port) דרך ZXing.ScanManager ונקשר ל-TCameraComponent.OnSampleBufferReady. שלוש נקודות מהותיות הן:

  • הפריימים מווסתים (throttled) — לא מפענחים כל דגימה.
  • הפענוח אינו רץ ב-UI-Thread (לא).
  • עצירה/הפעלה היא idempotent (ניתן לקרוא לה מספר פעמים ללא כאוס משאבים).
Delphi
unit UQrScanner;

interface

uses
  System.SysUtils, System.Classes, System.Types, System.UITypes, System.SyncObjs,
  System.Diagnostics, System.Threading,
  FMX.Types, FMX.Graphics, FMX.Media,
  ZXing.BarcodeFormat, ZXing.ReadResult, ZXing.ScanManager;

type
  TQrScanResultEvent = reference to procedure(const AText: string);

  /// <summary>
  /// בקר לסורק QR עבור FMX (Android/iOS).
  /// מטפל ב-frame-gating של המצלמה, בדיקודינג ברקע והפעלה/עצירה מסודרת.
  /// </summary>
  TQrScannerController = class
  private
    FCamera: TCameraComponent;
    FScanManager: TScanManager;
    FBitmap: TBitmap;
    FLock: TObject;

    FOnResult: TQrScanResultEvent;

    // הגבלת קצב (Gating/Throttle)
    FIsRunning: Boolean;
    FIsDecoding: Integer; // 0/1 כ-דגל Interlocked
    FLastDecodeTick: Int64;
    FMinIntervalMs: Cardinal;

    // Debounce נגד חזרתיות של אותם קודים
    FLastText: string;
    FLastTextTick: Int64;
    FDebounceMs: Cardinal;

    // ROI: חלק התמונה שנסרק (0..1)
    FEnableRoi: Boolean;
    FRoiScale: Single;

    procedure CameraSampleBufferReady(Sender: TObject; const ATime: TMediaTime);
    function ShouldDecodeNow(const ANowTick: Int64): Boolean;
    function IsDebounced(const AText: string; const ANowTick: Int64): Boolean;
    function ExtractRoiBitmap(const ASrc: TBitmap): TBitmap;

    procedure DoResultOnMainThread(const AText: string);

  public
    constructor Create(const ACamera: TCameraComponent);
    destructor Destroy; override;

    procedure Start;
    procedure Stop;

    property MinIntervalMs: Cardinal read FMinIntervalMs write FMinIntervalMs; // למשל 120
    property DebounceMs: Cardinal read FDebounceMs write FDebounceMs;         // למשל 1200
    property EnableRoi: Boolean read FEnableRoi write FEnableRoi;
    property RoiScale: Single read FRoiScale write FRoiScale;                 // למשל 0.6

    property OnResult: TQrScanResultEvent read FOnResult write FOnResult;
  end;

implementation

uses
  System.Math;

{ TQrScannerController }

constructor TQrScannerController.Create(const ACamera: TCameraComponent);
var
  Formats: TArray<TBarcodeFormat>;
begin
  inherited Create;
  FLock := TObject.Create;

  FCamera := ACamera;
  FCamera.OnSampleBufferReady := CameraSampleBufferReady;

  // אתחול ScanManager והגבלה ל-QR (ביצועים + פחות False Positives)
  Formats := TArray<TBarcodeFormat>.Create(TBarcodeFormat.QR_CODE);
  FScanManager := TScanManager.Create(Formats);

  FBitmap := TBitmap.Create;
  FMinIntervalMs := 120;
  FDebounceMs := 1200;
  FEnableRoi := True;
  FRoiScale := 0.6;

  FLastDecodeTick := 0;
  FLastText := '';
  FLastTextTick := 0;
  FIsDecoding := 0;
  FIsRunning := False;
end;

destructor TQrScannerController.Destroy;
begin
  Stop;
  FBitmap.Free;
  FScanManager.Free;
  FLock.Free;
  inherited;
end;

procedure TQrScannerController.Start;
begin
  if FIsRunning then
    Exit;
  FIsRunning := True;

  // הפעלת מצלמה: באפליקציות אמיתיות יש לבדוק הרשאות קודם (Android) ולשקול את זרימת ה-UI.
  if Assigned(FCamera) then
    FCamera.Active := True;
end;

procedure TQrScannerController.Stop;
begin
  if not FIsRunning then
    Exit;
  FIsRunning := False;

  // כיבוי פעיל בצורה מסודרת
  if Assigned(FCamera) then
    FCamera.Active := False;

  // איפוס הדגל של הדקודר, למקרה שה-Stop קרה בשלב לא רצוי
  TInterlocked.Exchange(FIsDecoding, 0);
end;

function TQrScannerController.ShouldDecodeNow(const ANowTick: Int64): Boolean;
begin
  // הגבלת קצב: לא לדקוד כל פריים
  Result := (ANowTick - FLastDecodeTick) >= FMinIntervalMs;
  if Result then
    FLastDecodeTick := ANowTick;
end;

function TQrScannerController.IsDebounced(const AText: string; const ANowTick: Int64): Boolean;
begin
  Result := False;
  if AText = '' then
    Exit(True);

  // אותו טקסט בתוך חלון Debounce - להתעלם
  if SameText(AText, FLastText) and ((ANowTick - FLastTextTick) <= FDebounceMs) then
    Exit(True);

  FLastText := AText;
  FLastTextTick := ANowTick;
end;

procedure TQrScannerController.CameraSampleBufferReady(Sender: TObject; const ATime: TMediaTime);
var
  NowTick: Int64;
  LocalCopy: TBitmap;
begin
  if not FIsRunning then
    Exit;

  NowTick := TThread.GetTickCount64;
  if not ShouldDecodeNow(NowTick) then
    Exit;

  // רק דקוד אחד בו-זמנית (אחרת עומס בתור במכשירים חלשים)
  if TInterlocked.CompareExchange(FIsDecoding, 1, 0) <> 0 then
    Exit;

  // העתקת דגימת מצלמה ל-FBitmap. נעילה, כי אותו באפר Bitmap לא צריך לשמש במקביל.
  TMonitor.Enter(FLock);
  try
    FCamera.SampleBufferToBitmap(FBitmap, True);
    LocalCopy := TBitmap.Create;
    try
      LocalCopy.Assign(FBitmap);
    except
      LocalCopy.Free;
      raise;
    end;
  finally
    TMonitor.Exit(FLock);
  end;

  // דקודינג ברקע
  TTask.Run(
    procedure
    var
      ScanBmp: TBitmap;
      Res: TReadResult;
      Text: string;
      Tick: Int64;
    begin
      try
        Tick := TThread.GetTickCount64;

        if FEnableRoi then
          ScanBmp := ExtractRoiBitmap(LocalCopy)
        else
          ScanBmp := LocalCopy;

        try
          Res := FScanManager.Scan(ScanBmp);
          if Assigned(Res) then
            Text := Res.Text
          else
            Text := '';
        finally
          if ScanBmp <> LocalCopy then
            ScanBmp.Free;
        end;

        if (Text <> '') and (not IsDebounced(Text, Tick)) then
          DoResultOnMainThread(Text);

      finally
        LocalCopy.Free;
        TInterlocked.Exchange(FIsDecoding, 0);
      end;
    end);
end;

function TQrScannerController.ExtractRoiBitmap(const ASrc: TBitmap): TBitmap;
var
  R: TRectF;
  W, H: Single;
  RoiW, RoiH: Single;
  X, Y: Single;
begin
  // חיתוך ROI במרכז: מקטין עומס חישובי ומכוון את המשתמש.
  // תשומת לב: בקודי QR מאוד קטנים ה-ROI עלול להיות צר מדי.
  W := ASrc.Width;
  H := ASrc.Height;

  RoiW := Max(16, W * EnsureRange(FRoiScale, 0.2, 1.0));
  RoiH := Max(16, H * EnsureRange(FRoiScale, 0.2, 1.0));

  X := (W - RoiW) / 2;
  Y := (H - RoiH) / 2;
  R := TRectF.Create(X, Y, X + RoiW, Y + RoiH);

  Result := TBitmap.Create(Round(RoiW), Round(RoiH));
  Result.Canvas.BeginScene;
  try
    Result.Canvas.Clear(TAlphaColors.Black);
    Result.Canvas.DrawBitmap(ASrc, R, TRectF.Create(0, 0, Result.Width, Result.Height), 1.0, True);
  finally
    Result.Canvas.EndScene;
  end;
end;

procedure TQrScannerController.DoResultOnMainThread(const AText: string);
begin
  if not Assigned(FOnResult) then
    Exit;

  // Thread ה-UI: ניווט, צפצוף, מילוי שדה וכו'.
  TThread.Queue(nil,
    procedure
    begin
      if FIsRunning and Assigned(FOnResult) then
        FOnResult(AText);
    end);
end;

end.

מה הקוד פותר (ולמה זה נחוץ)

Throttle (MinIntervalMs) מקטין עומס על ה-CPU ועל חימום המכשיר. ללא הגבלה חלק מהמכשירים ינסו לפענח 30–60 פריימים/שנייה; בפועל מספיקים 5–10/שנייה, לעיתים פחות. Debounce (DebounceMs) מונע מצב שבו קוד QR שמוחזק יציב מפעיל פעולה מספר פעמים (למשל רישום כפול בשלב תהליך).

ה-Interlocked-Flag (FIsDecoding) מוודא שמריצה לכל היותר משימת פענוח אחת. זה תכסיס ארכיטקטוני נגד „פקק בתור“: אם פענוח נמשך 200 ms אך כל 120 ms מתחילה משימה חדשה, התור יתארך והתוצאות יגיעו במצב עיכוב, דבר שמצטייר בשטח כ“הסורק מגיב לא נכון“.

תנאים מגבילים ונקודות תורפה

  • TBitmap und Threading: תמונות FMX עלולות להיות GPU-backed. הגישה המוצעת מעתיקה את הפריים ל-Bitmap מקומי ומבצעת את הפענוח ברקע. בהתאם לגרסה/פלטפורמה של Delphi ייתכן שעדיין דרושה זהירות: אם אתם רואים ארטיפקטים, כפו Bitmap מבוסס-CPU (למשל באמצעות קריאה/כתיבת פיקסלים) או עבדו עם ByteBuffer מה-SampleBuffer (קרוב יותר לפלטפורמה אך יציב יותר).
  • Stop/Start bei Navigation: באפליקציות ניידות נהוג לעצור בעת שינוי ה-Form או באירוע השעיית האפליקציה. חשוב ש-Stop יהיה ניתן לקריאה מרובה מבלי לזרוק Exceptions (idempotent). בנוסף, ה-callback של התוצאה צריך לבדוק אם הסורק עדיין פועל (זה מה ש-DoResultOnMainThread עושה).
  • ROI zu eng: ROI ממורכז מזרז את העיבוד, אך עלול להיכשל אם המשתמש מחזיק את הקוד מחוץ לאזור או אם הקוד קטן מאוד. לכן EnableRoi ניתן לקונפיגורציה ו-RoiScale מוגבל.
  • Format-Lock auf QR: הגבלת הפורמט ל-QR_CODE בדרך כלל נכונה. אם אתם זקוקים גם ל-Code128/EAN, הרחיבו את רשימת הפורמטים — אך צפו ליותר False Positives ולעומס CPU גבוה יותר.

Delphi FMX Kamera-Lifecycle: Berechtigungen, Hintergrund, Rotation

הבאגים השכיחים ביותר אינם בפענוח עצמו, אלא סביב ניהול המצלמה:

  • Android Permissions: יש לקבל הרשאות מצלמה בזמן ריצה. תכננו את המקרה שבו המשתמש יסרב או יבחר „פעם אחת בלבד“. מבחינה טכנית משמעות הדבר היא להפריד בין מצב ה-UI („Scanner מוכן?“) ומצב המצלמה, אחרת תמצאו את עצמכם במצבים חצי-מוכנים.
  • App geht in den Hintergrund: ב-OnApplicationEvent (למשל EnteredBackground) יש לקרוא ל-Stop. כשחוזרים למצב הפעיל, קראו במודע ל-Start (ולפעמים הוסיפו השהייה קצרה) כדי להבטיח ש-preview יהיה יציב.
  • Rotation/Mirroring: עבור QR-קודים סיבוב לרוב אינו קריטי, אך במסלולי מצלמה מסוימים ה-Bitmap עלול להיות מראה או מסובב. אם סריקות פועלות „רק בתנוחה אחת“ זה רמז לכך. במצב כזה: סובבו/החזירו את התמונה לפני הסריקה או השתמשו בדקודר המתחשב במטא-נתוני Orientation.

דיבוג בזמן ריצה: כך תמצאו את הסיבות האמיתיות

אם הסורק „לפעמים“ לא קורא, דיבוג שניתן לשחזור שווה זהב. שלוש פעולות שנמצאו יעילות:

  1. לוג של Frame-Sampling: רישמו (רק במצב Debug/Support) Tick, גודל תמונה, גודל ROI ומשך פענוח. כך תראו מיד אם הבעיה נובעת מ-Throttle/Debounce או מעומס CPU.
  2. שימור תמונות בדיקה: שמרו כל N שניות תמונת ROI (זמנית). בעזרתן תוכלו לנתח ללא הצורך בחומרת מצלמה האם הקונטרסט או הבלבול (טשטוש) הם מקור הבעיה.
  • הפרדת עומסי עבודה: לא לעדכן את עדכוני UI (Preview-Overlay, Status-Text) בתדירות גבוהה. ה“רעידות“ של ה-UI נגרמות לעתים קרובות מיותר מדי אירועי Queue.
  • אפשרויות: כאשר אתם צריכים יותר מ“סריקה וזהו“

    תוצאות מרובות, אך מבוקרות

    לתהליכי אצווה (למשל תוויות רבות ברצף) הקטינו את DebounceMs והוסיפו Whitelist/State-Machine: קוד QR יתקבל רק אם שלב התהליך הנוכחי מצפה לו. זו אינה לוגיקת UI, אלא לוגיקת דומיין — היא שייכת לשכבה נפרדת, כדי שהסורק והתהליך יישארו ניתנים לבדיקה באופן עצמאי.

    אימות לא מקוון ונתוני מטען מאובטחים

    בתהליכים ארגוניים קודי QR מכילים לעתים קרובות מזהים או טוקנים. אל תסמכו על „QR = korrekt“. בצעו ולידציה מקומית (פורמט, Prüfsumme, קידומות צפויות) ובצד השרת (REST-API). כשאתם משתמשים בטוקנים: זמן תפוגה, הגנה מפני replay, ותיעוד בזהירות (אין לשמור טוקנים בטקסט גלוי ביומני תמיכה).

    מצבי Legacy: FMX-Scanner כמודול בבסיסי קוד מעורבים

    אם יש לכם עולם VCL שצמח עם הזמן, FMX כלקוח מובייל הוא לעתים ענף נפרד. שמרו על הסורק כמחלקת Controller ללא תלות בטפסים (כפי שתואר לעיל), כך תוכלו לשלב אותו במסכים שונים. זה משתלם גם במהלכי מודרניזציה: הלוגיקה העסקית נשארת ניתנת לבדיקה, והמצלמה היא רק ערוץ קלט. במיוחד במצבי legacy כדאי גם לקבוע חיתוך ברור עבור Logging, Feature-Flags וקונפיגורציית מרחוק.

    מסקנה: סריקת FMX-QR יציבה היא בעיית Lifecycle — לא רק קריאת ZXing

    סורק קודי QR ב-Delphi FMX יהיה יציב אם תתייחסו אליו כצינור קטן: המצלמה מספקת פריימים, דקודר ברקע פועל מבוקר, ו-Debounce/Throttle מונעים אירועי כפילות ואירועים מאוחרים. קטע הקוד למעלה מתמקד בדיוק בנקודות שמקרטעות בתהליכים ניידים עסקיים אמיתיים: יותר מדי Decode-Tasks, עצירה לא נקייה, חסימות Thread של ה-UI ועומס מיותר.

    גבולות שימוש: אם אתם זקוקים לקצבי סריקה גבוהים במיוחד (למשל סריקה תעשייתית על פס ייצור) או לדרישות קשות לעיבוד תמונה, מצלמת ה-FMX הסטנדרטית + Bitmap-Pipeline לעתים יקרה מדי. אז כדאי גישה קרובה לפלטפורמה (Native Camera API, YUV-Buffer ישיר, SIMD/NEON) או SDK סורק ייעודי. עבור רוב היישומים הניידים הקרובים לתהליך הגישה המוצגת מספיקה, בתנאי ש-Lifecycle, הרשאות ו-Threading משולבים בצורה מסודרת — והתהליכים שמאחוריה ברורים.

    אם עליכם להתאים סריקת QR לארכיטקטורה קיימת של Delphi (כולל מקרי קצה כמו ניווט, Backgrounding, Logging ואימות תהליך), נשמח לחדד זאת בצורה מובנית:

    בהקשר המקצועי גם Zxing Delphi ו-Fmx Tcameracomponent משחקים תפקיד חשוב, כאשר אינטגרציות, זרמי נתונים ופיתוח המשך צריכים לעבוד ביחד בצורה מסודרת.

    לדון בפרויקט או במהלך מודרניזציה עם Net-Base.

    השלב הבא

    כאשר הנושא הופך לפרויקט ממשי, יש לבחון כבר בשלב מוקדם את הארכיטקטורה, המצב הקיים והתפעול יחד.

    אנו תומכים לא רק בשאלות נקודתיות, אלא גם כשמקטעי קוד מקור, נושאי Legacy או רעיונות פורטל אמורים להפוך לפרויקט ארגוני מהימן ועמיד.

    • המצב הקיים, תמונת היעד והסיכונים הטכניים מוערכים יחד.
    • REST, גישה לנתונים, פורטלים ו-Rollout לא יידחו כתוצאות מאוחרות.
    • אתם מזהים מוקדם איזה נתיב בר-קיימא מבחינה כלכלית ותפעולית.

    שתף פוסט

    לשתף את הפוסט הזה ישירות

    LinkedIn, X, XING, Facebook, WhatsApp ודוא"ל זמינים מיידית. עבור Instagram אנו מכינים קישור וטקסט קצר באופן מיידי.

    דוא״ל

    אינסטגרם נפתח בכרטיסייה חדשה. הקישור וטקסט קצר מועתקים מראש ללוח.