Net-Base Журнал

03.06.2026

Сканер QR-кодів у Delphi FMX: сканування камерою — стійке, потокобезпечне та без тремтіння інтерфейсу

Практично застосовний QR Code Scanner Delphi FMX залежить від життєвого циклу камери, багатопоточності та коректного зупинення/запуску. У матеріалі показано надійний підхід із ZXing, Debounce, Frame-Throttling, кадруванням ROI, а також деталями відладки та експлуатації для Android та iOS.

03.06.2026

Від теми журналу до практики проєкту

Відповідні сторінки послуг і технічні сторінки до публікації

Сканер QR-кодів Delphi FMX на практиці

У демо QR Code Scanner Delphi FMX швидко збирається: показати попередній перегляд камери, отримати Bitmap, прогнати ZXing. У реальному бізнес‑ПЗ (наприклад, приймання товарів, прив’язка пристроїв, тикетинг, процеси доступу) з’являються додаткові умови: додаток переходить у фоновий режим, камера втрачає фокус, користувач тримає пристрій під кутом, формат кадру змінюється — і раптом ви двічі на секунду скануєте той самий код або інтерфейс підвисає, бо декодування виконується в UI-потоці.

Типові проблеми — не стільки «ZXing не може прочитати», скільки lifecycle та архітектура: звільнення ресурсів камери, тактування кадрів, потокобезпечність при доступі до TBitmap (GPU/CPU) та чіткий Stop/Start, який залишається коректним навіть коли користувач швидко навігує або ОС тимчасово відібрала камеру.

Огляд архітектури: Pipeline statt „OnSampleBufferReady macht alles“

Практично зарекомендував себе невеликий пайплайн з чіткими зонами відповідальності:

  • Адаптер камери: постачає кадри (або їх копії) у визначеному форматі.
  • Декодер: працює у фоновому потоці і повертає результати через callback.
  • Gate/Debounce: запобігає подвійним скануванням і регулює навантаження (Throttle).
  • Шар UI: показує попередній перегляд, опційно рамку фокусування (ROI, „Region of InteREST“) і реагує на результати.

Це запобігає взаємному блокуванню UI, камери та декодера. «ROI» тут означає обрізане поле пошуку (наприклад, центральні 60 %), яке розвантажує декодер і зменшує кількість хибно‑позитивних результатів. Важливо: ROI — інструмент для продуктивності та юзабіліті, а не механізм безпеки.

Source-Schnipsel: Robuster QR Code Scanner (FMX + ZXing) mit Debounce und sauberem Stop

Наступний код задуманий як компактний, але придатний для проєкту компонент. Він використовує ZXing (Delphi-порт) через ZXing.ScanManager і підписується на TCameraComponent.OnSampleBufferReady. Вирішальними є три пункти:

  • Кадри проходять throttled (не декодувати кожний Sample).
  • Decoding відбувається не в UI-потоці.
  • Stop/Start є ідемпотентним (можна викликати багаторазово без хаосу з ресурсами).
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).
  /// Піклується про кадрування камери, фонове декодування та коректну зупинку/запуск.
  /// </summary>
  TQrScannerController = class
  private
    FCamera: TCameraComponent;
    FScanManager: TScanManager;
    FBitmap: TBitmap;
    FLock: TObject;

    FOnResult: TQrScanResultEvent;

    // Обмеження частоти (gating/throttle)
    FIsRunning: Boolean;
    FIsDecoding: Integer; // 0/1 als Interlocked-Flag
    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 (продуктивність + менше хибнопозитивів)
  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
  // Throttle: не декодувати кожний кадр
  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;

  // 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 мс, а нове завдання стартує кожні 120 мс, черга зростає і результати надходять із затримкою, що в експлуатації виглядає як «сканер реагує неправильно».

Обмеження та підводні камені

  • TBitmap und Threading: FMX-Bitmaps можуть використовувати апаратне прискорення на GPU. Підхід копіює кадр у локальну Bitmap і декодує у фоні. Залежно від версії/платформи Delphi все одно може знадобитися обережність: якщо ви бачите артефакти, примусьте CPU-Bitmap (наприклад, через Pixel-Read/Write) або працюйте з ByteBuffer із SampleBuffer (ближче до платформи, але стабільніше).
  • Stop/Start bei Navigation: У мобільних додатках часто зупиняють при зміні форми або при події паузи додатка. Важливо, щоб Stop можна було викликати кілька разів без викидання виключень (ідемпотентно). Крім того, callback результату має перевіряти, чи ще працює сканер (робить це DoResultOnMainThread).
  • ROI zu eng: Центральне ROI пришвидшує роботу, але може не спрацювати, якщо користувач тримає код поза зоною або код дуже малий. Тому EnableRoi має бути конфігурованим, а RoiScale обмеженим.
  • Format-Lock auf QR: Обмеження на QR_CODE зазвичай правильне. Якщо вам потрібні також Code128/EAN, розширте формати — але розраховуйте на більше хибних спрацьовувань і вищу завантаженість CPU.

Delphi FMX Kamera-Lifecycle: Berechtigungen, Hintergrund, Rotation

Найбільш розповсюджені баги виникають не при декодуванні, а навколо камери:

  • Android Permissions: Права на камеру потрібно запитувати під час виконання. Передбачте випадок, коли користувач відмовляє або вибирає «Тільки цього разу». Технічно це означає: тримайте UI-State («Сканер готовий?») відокремлено від Kamera-State, інакше опинитесь у напівготових станах.
  • App geht in den Hintergrund: При OnApplicationEvent (наприклад, EnteredBackground) слід викликати Stop. Повертаючись — явно викликати Start (і за потреби невелику затримку), щоб preview стабілізувався.
  • Rotation/Mirroring: Для QR-кодів обертання часто некритичне, але в деяких конвеєрах камери bitmap може бути дзеркально відображено або повернуто. Якщо сканування працює «лише в одній орієнтації», це вказівка на проблему. У такому випадку: перед сканом поверніть/віддзеркальте зображення або використайте декодер, який враховує метадані орієнтації.

Debugging im Betrieb: So finden Sie die echten Ursachen

Якщо сканер «інколи» не читає, відтворюване налагодження — велика цінність. Три заходи, що себе виправдали:

  1. Frame-Sampling loggen: Логуйте (тільки в режимі Debug/Support) тик, розмір зображення, розмір ROI, час декодування. Так ви відразу побачите, чи проблема в Throttle/Debounce або в навантаженні CPU.
  2. Testbilder sichern: Зберігайте кожні N секунд зображення ROI (тимчасово). Це дозволяє аналізувати без підключеної камери, чи проблема в контрасті/розмитті.
  3. Workload trennen: UI-Updates (Preview-Overlay, Status-Text) nicht in hoher Frequenz aktualisieren. Das „UI-Zittern“ kommt oft von zu vielen Queue-Events.

Varianten: Wenn Sie mehr brauchen als „Scan und fertig“

Mehrere Ergebnisse, aber kontrolliert

Für Stapelprozesse (z. B. viele Labels nacheinander) reduzieren Sie DebounceMs und ergänzen eine Whitelist/State-Machine: Ein QR-Code darf nur dann akzeptiert werden, wenn der aktuelle Prozessschritt ihn erwartet. Das ist keine UI-Logik, sondern Domänenlogik – sie gehört in eine eigene Schicht, damit Scanner und Prozess unabhängig testbar bleiben.

Offline-Validierung und sichere Nutzdaten

In Unternehmensprozessen enthalten QR-Codes oft IDs oder Token. Verlassen Sie sich nicht darauf, dass „QR = korrekt“. Validieren Sie lokal (Format, Prüfsumme, erwartete Prefixe) und serverseitig (REST-API). Wenn Sie Token verwenden: Ablaufzeiten, Replay-Schutz, und Logging mit Vorsicht (keine Tokens im Klartext in Support-Logs).

Legacy-Situationen: FMX-Scanner als Modul in gemischten Codebasen

Wenn Sie eine gewachsene VCL-Welt haben, ist FMX als Mobile-Client oft ein separater Strang. Halten Sie den Scanner als Controller-Klasse ohne Form-Abhängigkeiten (wie oben), dann können Sie ihn in unterschiedliche Screens integrieren. Das zahlt sich auch bei Modernisierung aus: Die Business-Logik bleibt testbar, die Kamera ist nur ein Input-Kanal. Gerade in Legacy-Situationen lohnt außerdem ein klarer Schnitt für Logging, Feature-Flags und Remote-Konfiguration.

Fazit: Solider FMX-QR-Scan ist ein Lifecycle-Problem – nicht nur ein ZXing-Aufruf

Ein QR Code Scanner in Delphi FMX wird stabil, wenn Sie ihn wie eine kleine Pipeline behandeln: Kamera liefert Frames, ein Hintergrund-Decoder arbeitet kontrolliert, und Debounce/Throttle verhindern Doppel- und Spät-Events. Der Source-Schnipsel oben adressiert genau die Stellen, die in echten mobilen Business-Prozessen kippen: zu viele Decode-Tasks, unsauberer Stop, UI-Thread-Blockaden und unnötige Last.

Einsatzgrenzen: Wenn Sie extrem hohe Scanraten brauchen (z. B. Industrie-Scanning am Fließband) oder harte Anforderungen an Bildverarbeitung haben, ist die FMX-Standardkamera + Bitmap-Pipeline oft zu teuer. Dann lohnt ein plattformnaher Ansatz (Native Camera API, YUV-Buffer direkt, SIMD/NEON) oder ein spezialisierter Scanner-SDK. Für die meisten prozessnahen mobilen Anwendungen reicht der gezeigte Ansatz jedoch, sofern Lifecycle, Rechte und Threading sauber integriert sind – und die Prozesse dahinter eindeutig sind.

Wenn Sie einen QR-Scan in eine bestehende Delphi-Architektur einpassen müssen (inklusive Randfällen wie Navigation, Backgrounding, Logging und Prozessvalidierung), klären wir das gerne strukturiert:

Im fachlichen Umfeld spielen auch Zxing Delphi und Fmx Tcameracomponent eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Наступний крок

Якщо тема перетворюється на реальний проєкт, архітектуру, наявну інфраструктуру та експлуатацію слід розглядати разом на ранньому етапі.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
  • REST, доступ до даних, портали та розгортання не відкладаються на пізніші етапи.
  • Ви завчасно визначаєте, який підхід є економічно та операційно життєздатним.

Поділитися дописом

Поділитися цим дописом безпосередньо

LinkedIn, X, XING, Facebook, WhatsApp та електронна пошта доступні негайно. Для Instagram ми готуємо посилання та короткий текст безпосередньо.

Електронна пошта

Instagram відкривається в новій вкладці. Посилання та короткий текст попередньо копіюються у буфер обміну.