Net-Base Magazine

03.06.2026

QR Code Scanner in Delphi FMX: robust, thread-safe camera scanning without UI jitter

A practical QR code scanner Delphi FMX hinges on camera lifecycle, threading and clean stop/start. The article demonstrates a robust approach using ZXing, debounce, frame throttling, ROI cropping, and debug and operational details for Android and iOS.

03.06.2026

From magazine topic to project implementation

Relevant service and technical pages for this post

QR Code Scanner Delphi FMX in practice

A QR Code Scanner Delphi FMX can be assembled quickly in a demo: show camera preview, grab a bitmap, run ZXing over it. In real business software (e.g. goods receiving, device assignment, ticketing, access processes), however, additional boundary conditions appear: the app goes to the background, the camera loses focus, the user holds the device at an angle, the image format changes — and suddenly you scan the same code twice per second or the UI stutters because decoding runs on the UI thread.

The typical problems are less „ZXing cannot read“ and more lifecycle and architecture: releasing camera resources, pacing frames, thread-safety when accessing TBitmap (GPU/CPU), and a clear stop/start that remains clean even when users navigate quickly or the OS briefly revokes the camera.

Architecture overview: Pipeline instead of „OnSampleBufferReady does everything“

In practice, a small pipeline with clear responsibilities has proven effective:

  • Camera adapter: delivers frames (or copies thereof) in a defined format.
  • Decoder: runs on a background thread and returns results via a callback.
  • Gate/Debounce: prevents duplicate scans and manages load (throttle).
  • UI layer: shows preview, optional focus rectangle (ROI, „Region of InteREST“) and reacts to results.

This prevents UI, camera and decoder from blocking each other. „ROI“ here means a cropped search window (e.g. centered 60%) that relieves the decoder and reduces false-positive results. Important: ROI is a performance and usability tool, not a security mechanism.

Source snippet: Robust QR Code Scanner (FMX + ZXing) with debounce and clean stop

The following code is intended as a compact but project-ready component. It uses ZXing (Delphi port) via ZXing.ScanManager and hooks into TCameraComponent.OnSampleBufferReady. Three points are critical:

  • Frames are throttled (do not decode every sample).
  • Decoding does not run in the UI thread.
  • Stop/Start is idempotent (callable multiple times without resource chaos).
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 scanner controller for FMX (Android/iOS).
  /// Handles camera frame gating, background decoding, and clean stop/start.
  /// </summary>
  TQrScannerController = class
  private
    FCamera: TCameraComponent;
    FScanManager: TScanManager;
    FBitmap: TBitmap;
    FLock: TObject;

    FOnResult: TQrScanResultEvent;

    // Gating/Throttle
    FIsRunning: Boolean;
    FIsDecoding: Integer; // 0/1 as an Interlocked flag
    FLastDecodeTick: Int64;
    FMinIntervalMs: Cardinal;

    // Debounce against repeated identical codes
    FLastText: string;
    FLastTextTick: Int64;
    FDebounceMs: Cardinal;

    // ROI: portion of the image that is scanned (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; // e.g. 120
    property DebounceMs: Cardinal read FDebounceMs write FDebounceMs;         // e.g. 1200
    property EnableRoi: Boolean read FEnableRoi write FEnableRoi;
    property RoiScale: Single read FRoiScale write FRoiScale;                 // e.g. 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;

  // Initialize ScanManager and restrict to QR (performance + fewer 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;

  // Activate camera: in real apps check permissions beforehand (Android) and consider UI flow.
  if Assigned(FCamera) then
    FCamera.Active := True;
end;

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

  // Deactivate cleanly
  if Assigned(FCamera) then
    FCamera.Active := False;

  // Reset decoder flag in case Stop is called at an inopportune time
  TInterlocked.Exchange(FIsDecoding, 0);
end;

function TQrScannerController.ShouldDecodeNow(const ANowTick: Int64): Boolean;
begin
  // Throttle: do not decode every frame
  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);

  // same text within debounce window - ignore
  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;

  // Only one decode at a time (otherwise queue buildup on weak devices)
  if TInterlocked.CompareExchange(FIsDecoding, 1, 0) <> 0 then
    Exit;

  // Copy camera sample into FBitmap. Lock because the same bitmap buffer must not be used in parallel.
  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;

  // Background decoding
  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
  // Crop ROI centered: reduces computational load and guides the user.
  // Note: for very small QR codes the ROI can be too tight.
  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;

  // UI thread: navigation, beep, populate fields etc.
  TThread.Queue(nil,
    procedure
    begin
      if FIsRunning and Assigned(FOnResult) then
        FOnResult(AText);
    end);
end;

end.

What the code solves (and why it is necessary)

Throttle (MinIntervalMs) reduces CPU load and heat generation. Without limiting, some devices try to decode 30–60 frames/s; in practice 5–10/s is sufficient, often less. Debounce (DebounceMs) prevents a steadily held QR code from being triggered multiple times (e.g., double posting in a process step).

The Interlocked flag (FIsDecoding) ensures that at most one decode task runs. This is an architectural trick against „queue buildup“: if decoding takes 200 ms but a task is started every 120 ms, the queue grows and results arrive delayed, which in operation appears as „scanner responds incorrectly.“

Constraints and pitfalls

  • TBitmap and threading: FMX bitmaps can be GPU-backed. The approach copies the frame into a local bitmap and decodes in the background. Depending on Delphi version/platform, caution may still be necessary: if you see artifacts, force a CPU bitmap (e.g., via pixel read/write) or work with a byte buffer from the sample buffer (more platform-near, but more stable).
  • Stop/Start during navigation: In mobile apps it is common to stop when changing forms or on the app pause event. It is important that Stop may be called multiple times and does not throw exceptions (idempotent). Also the result callback should check whether the scanner is still running (that is what DoResultOnMainThread does).
  • ROI too tight: A centered ROI speeds up decoding but can fail if users hold the code outside it or the code is very small. Therefore EnableRoi is configurable and RoiScale is constrained.
  • Format lock to QR: Restricting to QR_CODE is usually correct. If you also need Code128/EAN, extend the formats—but expect more false positives and higher CPU usage.

Delphi FMX camera lifecycle: permissions, background, rotation

The most common bugs do not occur during decoding, but around the camera:

  • Android permissions: Camera permissions must be requested at runtime. Plan for a user denying or selecting „Only this time.“ Technically this means keeping UI state („Scanner ready?“) separate from camera state, otherwise you end up in half-complete states.
  • App goes to background: On the OnApplicationEvent (e.g., EnteredBackground) you should call Stop. On returning, deliberately call Start (and possibly add a short delay) so the preview is stable.
  • Rotation/mirroring: For QR codes rotation is often uncritical, but some camera pipelines may mirror or rotate the bitmap. If scans only work in one orientation, this is an indicator. In that case: rotate/mirror before scanning or use a decoder that consumes orientation metadata.

Debugging in the field: how to find the real causes

When the scanner „sometimes“ doesn’t read, reproducible debugging is invaluable. Three measures that have proven effective:

  1. Log frame sampling: Log (only in debug/support mode) Tick, image size, ROI size, decode duration. This immediately shows whether throttle/debounce or CPU load is the issue.
  2. Save test images: Save an ROI image every N seconds (temporarily). This lets you analyze, without camera hardware, whether contrast/blur are the problem.
  3. Separate workload: Do not update UI elements (preview overlay, status text) at a high frequency. The “UI jitter” often comes from too many Queue events.

Variants: When you need more than „scan and done“

Multiple results, but controlled

For batch processes (e.g. many labels in sequence) reduce DebounceMs and add a whitelist/state machine: a QR code may only be accepted when the current process step expects it. This is not UI logic but domain logic — it belongs in its own layer so scanner and process remain independently testable.

Offline validation and secure payloads

In enterprise processes QR codes often contain IDs or tokens. Do not assume „QR = correct.“ Validate locally (format, checksum, expected prefixes) and server-side (REST-API). If you use tokens: expiration times, replay protection, and careful logging (do not include tokens in plain text in support logs).

Legacy situations: FMX scanner as a module in mixed codebases

If you have an established VCL landscape, FMX as a mobile client is often a separate branch. Keep the scanner as a controller class without form dependencies (as above), then you can integrate it into different screens. That also pays off during modernization: the business logic remains testable, the camera is only an input channel. In legacy situations a clear separation for logging, feature flags and remote configuration is particularly valuable.

Conclusion: Robust FMX QR scanning is a lifecycle problem — not just a ZXing call

A QR code scanner in Delphi FMX becomes stable when you treat it like a small pipeline: the camera supplies frames, a background decoder works in a controlled manner, and debounce/throttle prevent duplicate and late events. The source snippet above addresses exactly the points that fail in real mobile business processes: too many decode tasks, improper stop, UI thread blockages and unnecessary load.

Limitations: If you require extremely high scan rates (e.g. industrial scanning on a conveyor) or strict image-processing requirements, the FMX standard camera + bitmap pipeline is often too costly. Then a platform-near approach (Native Camera API, YUV buffer directly, SIMD/NEON) or a specialized scanner SDK is worthwhile. For most process-oriented mobile applications the demonstrated approach is sufficient, provided lifecycle, permissions and threading are cleanly integrated — and the processes behind it are well defined.

If you need to fit a QR scan into an existing Delphi architecture (including edge cases such as navigation, backgrounding, logging and process validation), we are happy to clarify this in a structured way:

In professional contexts Zxing Delphi and Fmx Tcameracomponent also play an important role when integrations, data flows and further development need to work together cleanly.

Discuss a project or modernization initiative with Net-Base.

Next step

When the topic becomes a real project, architecture, the existing system landscape and operations should be considered together early on.

We support not only with individual issues, but also when source snippets, legacy topics, or portal ideas are to be turned into a robust enterprise project.

  • Current state, target state and technical risks are assessed jointly.
  • REST, data access, portals and rollout are not deferred as afterthoughts.
  • You can determine early which path is economically and operationally viable.

Share post

Share this post directly

LinkedIn, X, XING, Facebook, WhatsApp and email are available immediately. For Instagram, we will prepare the link and a short caption immediately.

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.