Net-Base Žurnalas

03.06.2026

QR kodo skaitytuvas Delphi FMX: patikimas kameros skenavimas, gijų saugus ir be vartotojo sąsajos trūkčiojimo

Praktiškas QR kodo skaitytuvas Delphi FMX sėkmė priklauso nuo kameros gyvavimo ciklo, siūlų valdymo ir tvarkingo paleidimo/stabdymo. Straipsnyje pristatomas patikimas sprendimas su ZXing, Debounce, frame-throttling, ROI apkirpimu bei derinimo ir eksploatacijos detalėmis Android ir iOS.

03.06.2026

Nuo žurnalo temos iki projekto įgyvendinimo

Tinkami puslapiai apie paslaugas ir techninę informaciją šiam įrašui

QR kodo skaitytuvas Delphi FMX praktikoje

Vienas QR kodo skaitytuvas Delphi FMX demonstracijoje greitai surenkamas: parodyti kameros peržiūrą, paimti Bitmap, paleisti ZXing. Tačiau realioje verslo programinėje įrangoje (pvz., prekių priėmimas, įrenginių priskyrimas, bilietų sistema, prieigos procesai) atsiranda ribiniai atvejai: programa pereina į foną, kamera praranda fokusą, vartotojas laiko įrenginį pakreiptą, keičiasi vaizdo formatas – ir staiga skenuojate tą patį kodą du kartus per sekundę arba UI stringa, nes dekodavimas vyksta UI gijoje.

Tipinės problemos nėra tiek „ZXing negali skaityti“, kiek lifecycle ir architektūra: kameros išteklių atlaisvinimas, kadrų taktavimas, gijų saugumas prieigos prie TBitmap (GPU/CPU) metu, ir aiškus Stop/Start, kuris lieka tvarkingas net kai vartotojai greitai naršo arba OS laikinai atima kamerą.

Architektūros apžvalga: pipeline vietoje „OnSampleBufferReady atlieka viską“

Praktiškai pasiteisino maža pipeline su aiškiomis atsakomybėmis:

  • Kamera-Adapter: teikia kadrus (ar jų kopijas) apibrėžtu formatu.
  • Decoder: veikia foninėje gijoje ir grąžina rezultatus per callback.
  • Gate/Debounce: užkerta kelią dvigubiems skenavimams ir reguliuoja apkrovą (throttle).
  • UI-Schicht: rodo peržiūrą, pasirinktinį fokusavimo stačiakampį (ROI, „Region of InteREST“) ir reaguoja į rezultatus.

Tai padeda išvengti, kad UI, kamera ir dekoderis blokuotų vienas kitą. Čia „ROI“ reiškia apkarpytą paieškos langą (pvz., centre 60 %), kuris sumažina dekoderio apkrovą ir sumažina klaidingai teigiamus rezultatus. Svarbu: ROI yra našumo ir naudojimo patogumo priemonė, o ne saugumo mechanizmas.

Kodo fragmentas: stabilus QR kodo skaitytuvas (FMX + ZXing) su debounce ir tvarkingu Stop

Tolimesnis kodas skirtas kaip kompaktiškas, bet projektiškai tinkamas modulis. Jis naudoja ZXing (Delphi-portą) per ZXing.ScanManager ir prisijungia prie TCameraComponent.OnSampleBufferReady. Esminiai yra trys punktai:

  • Kadrų srautas yra throttled (ne dekoduoti kiekvieno mėginio).
  • Dekodavimas nevyksta UI gijoje.
  • Stop/Start yra idempotent (gali būti kviečiamas kelis kartus, nepaliekant išteklių chaoso).
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 für FMX (Android/iOS).
  /// Kümmert sich um Kamera-Frame-Gating, Hintergrund-Decoding und sauberes Stop/Start.
  /// </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 gegen wiederholte gleiche Codes
    FLastText: string;
    FLastTextTick: Int64;
    FDebounceMs: Cardinal;

    // ROI: Anteil des Bildes, der gescannt wird (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; // z.B. 120
    property DebounceMs: Cardinal read FDebounceMs write FDebounceMs;         // z.B. 1200
    property EnableRoi: Boolean read FEnableRoi write FEnableRoi;
    property RoiScale: Single read FRoiScale write FRoiScale;                 // z.B. 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 initialisieren und auf QR beschränken (Performance + weniger 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;

  // Kamera aktivieren: In echten Apps vorher Permissions prüfen (Android) und UI-Flow berücksichtigen.
  if Assigned(FCamera) then
    FCamera.Active := True;
end;

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

  // Aktiv sauber abschalten
  if Assigned(FCamera) then
    FCamera.Active := False;

  // Decoder-Flag zurücksetzen, falls Stop in einer ungünstigen Phase kommt
  TInterlocked.Exchange(FIsDecoding, 0);
end;

function TQrScannerController.ShouldDecodeNow(const ANowTick: Int64): Boolean;
begin
  // Throttle: nicht jedes Frame dekodieren
  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);

  // gleicher Text innerhalb Debounce-Fenster - ignorieren
  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;

  // Nur ein Decode gleichzeitig (sonst Queue-Stau bei schwachen Geräten)
  if TInterlocked.CompareExchange(FIsDecoding, 1, 0) <> 0 then
    Exit;

  // Kamera-Sample in FBitmap kopieren. Lock, weil derselbe Bitmap-Buffer nicht parallel benutzt werden soll.
  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;

  // Hintergrund-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
  // ROI mittig ausschneiden: reduziert Rechenlast und lenkt den Nutzer.
  // Achtung: bei sehr kleinen QR-Codes kann ROI zu eng sein.
  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, Feld füllen etc.
  TThread.Queue(nil,
    procedure
    begin
      if FIsRunning and Assigned(FOnResult) then
        FOnResult(AText);
    end);
end;

end.

Ką išsprendžia kodas (ir kodėl tai būtina)

Throttle (MinIntervalMs) sumažina CPU apkrovą ir šilumos išsiskyrimą. Be ribos kai kurie įrenginiai bando dekoduoti 30–60 kadrų/s; praktiškai pakanka 5–10/s, dažnai mažiau. Debounce (DebounceMs) neleidžia stabiliai laikomam QR kodui sukelti kelių įvykių (pvz., dvigubas įrašymas į proceso žingsnį).

Interlocked-žymuo (FIsDecoding) užtikrina, kad vyksta ne daugiau nei vienas dekodavimo uždavinys. Tai architektūrinis sprendimas prieš „eilės užsikimšimą“: jei dekodavimas trunka 200 ms, bet uždavinys pradedamas kas 120 ms, eilė auga ir rezultatai atkeliauja su vėlavimu, o tai eksploatacijoje atrodo kaip „skaitytuvas reaguoja neteisingai“.

Apribojimai ir spąstai

  • TBitmap ir siūlų naudojimas: FMX-Bitmaps gali būti GPU-backed. Sprendimas nukopijuoja kadrą į vietinį Bitmap ir dekoduoja fone. Priklausomai nuo Delphi versijos/platformos vis tiek gali prireikti atsargumo: jei matote artefaktus, priverstinai naudokite CPU-Bitmap (pvz., per pikselių skaitymą/rašymą) arba dirbkite su ByteBuffer iš SampleBuffer (artimiau platformai, bet stabilesnis).
  • Stop/Start navigacijos metu: Mobiliose programėlėse dažnai sustabdoma keičiant Form arba App-Pause įvykyje. Svarbu, kad Stop būtų galima kviesti kelis kartus ir kad jis negeneruotų išimčių (idempotentiškas). Be to, rezultatų atgalinis kvietimas turi patikrinti, ar skaitytuvas vis dar veikia (tai atlieka DoResultOnMainThread).
  • Per siauras ROI: Centrinis ROI pagreitina veikimą, bet gali nepasisekti, jei vartotojas laiko kodą už ribų arba kodas labai mažas. Todėl EnableRoi yra konfigūruojamas, o RoiScale ribojamas.
  • Formatų užrakinimas ant QR: Ribojimas iki QR_CODE dažniausiai yra teisingas. Jei jums reikia taip pat Code128/EAN, pridėkite formatus – skaičiuokite su didesniu klaidingų aptikimų skaičiumi ir didesne CPU apkrova.

Delphi FMX kameros gyvavimo ciklas: leidimai, fonas, rotacija

Dažniausios klaidos kyla ne dekodavimo metu, o aplink kamerą:

  • Android leidimai: Kameros teisės turi būti prašomos vykdymo metu. Apsvarstykite atvejį, kai vartotojas atsisako arba pasirenka „Tik šį kartą“. Techniniu požiūriu tai reiškia: UI būseną („Skaitytuvas pasiruošęs?“) laikykite atskirai nuo kameros būsenos, kitaip galite užstrigti pusiau baigtose būsenose.
  • Programa pereina į foną: OnApplicationEvent (pvz., EnteredBackground) metu turėtumėte kviesti Stop. Grįžtant sąmoningai kviesti Start (ir, jei reikia, trumpą vėlinimą), kad peržiūra būtų stabili.
  • Sukimas / atspindėjimas: QR kodams sukimas dažnai nėra kritinis, tačiau kai kuriose kameros grandinėse Bitmap gali būti atspindėtas arba pasuktas. Jei skanavimas veikia „tik tam tikroje padėtyje“, tai yra signalas. Tokiu atveju: prieš skanavimą pasukite/atspindėkite vaizdą arba naudokite dekoderį, kuris naudoja orientacijos metaduomenis.

Klaidų paieška veikimo metu: kaip rasti tikrąsias priežastis

Jei skaitytuvas „kartais“ neskaito, galimybė pakartotinai atkurti klaidą derinimui yra itin vertinga. Trys priemonės, kurios dažnai pasiteisina:

  1. Frame-Sampling loggin: Loguokite (tik Debug/Support režime) Tick, vaizdo dydį, ROI dydį, dekodavimo trukmę. Taip iškart matysite, ar problema susijusi su Throttle/Debounce ar CPU apkrova.
  2. Išsaugoti testinius vaizdus: Išsaugokite kas N sekundžių vieną ROI vaizdą (laikinai). Tai leis be kameros aparatūros analizuoti, ar problema yra kontrastas ar neryškumas.
  • Atskirkite darbo krūvį: vartotojo sąsajos atnaujinimų (Preview-Overlay, būseno tekstas) neatnaujinkite dideliu dažniu. „UI drebėjimas“ dažnai kyla dėl per daug Queue-įvykių.
  • Variantai: Jei reikia daugiau nei „nuskaityti ir paruošta“

    Keli rezultatai, bet valdomai

    Partijos procesams (pvz., daug etikečių iš eilės) sumažinkite DebounceMs ir papildykite baltojo sąrašo / būsenių mašina: QR kodas turi būti priimamas tik tada, kai dabartinis proceso žingsnis jo laukia. Tai nėra UI logika, o domeno logika – ji turi būti atskiro sluoksnio dalis, kad skaitytuvas ir procesas būtų nepriklausomai testuojami.

    Offline patikra ir saugūs naudingieji duomenys

    Įmonių procesuose QR kodai dažnai talpina ID arba token’us. Nesikliaukite, kad „QR = teisingas“. Vykdykite lokalią validaciją (formatas, kontrolinė suma, laukiamų prefiksų tikrinimas) ir serverinę patikrą (REST-API). Jei naudojate token’us: numatykite galiojimo laikus, apsaugą nuo pakartotinio panaudojimo (replay protection) ir elkitės atsargiai su žurnalais (logging) — nenaudokite token’ų nešifruoto teksto palaikymo žurnaluose.

    Paveldėtos situacijos: FMX skaitytuvas kaip modulis mišriose kodo bazėse

    Jei turite brandžią VCL aplinką, FMX kaip mobilus klientas dažnai yra atskiras srautas. Laikykite skaitytuvą kaip Controller klasę be formų priklausomybių (kaip aukščiau), taip galėsite jį integruoti į skirtingus ekranus. Tai atsiperka ir modernizuojant: verslo logika lieka testuojama, o kamera yra tik įvesties kanalas. Ypač paveldėtose situacijose verta aiškus atskyrimas dėl žurnalavimo, feature-flag’ų ir nuotolinės konfigūracijos.

    Išvada: Patikimas FMX QR nuskaitymas yra gyvavimo ciklo problema – ne vien ZXing kvietimas

    QR kodo skaitytuvas in Delphi FMX bus stabilus, jei jį traktuosite kaip mažą duomenų srautą: kamera tiekia kadrus, foninis dekoderis dirba kontroliuojamai, o Debounce/Throttle užkerta kelią dvigubiems ir vėlyviems įvykiams. Aukščiau pateiktas kodo fragmentas adresuoja būtent tas vietas, kuriose tikri mobilių verslo procesų scenarijai linkę žlugti: per daug decode-užduočių, netinkamas sustabdymas, UI gijos blokavimai ir nereikalinga apkrova.

    Pritaikymo ribos: Jei jums reikia itin didelių nuskaitymo spartų (pvz., pramoninis nuskaitymas ant konvejerio) arba griežtų vaizdo apdorojimo reikalavimų, FMX standartinė kamera + bitmap pipeline dažnai yra per daug resursus naudojanti. Tokiu atveju verta platformai artimas sprendimas (Native Camera API, tiesioginis YUV buferis, SIMD/NEON) arba specializuotas Scanner-SDK. Tačiau daugumai procesų artimų mobiliųjų taikomųjų programų parodytas požiūris yra pakankamas, jei Lifecycle, leidimai ir gijų valdymas yra aiškiai integruoti – ir procesai už to yra aiškūs.

    Jei turite pritaikyti QR nuskaitymą į esamą Delphi architektūrą (įskaitant kraštinius atvejus, pvz., navigaciją, backgrounding, žurnalavimą ir proceso validaciją), mes tai mielai aptartume struktūruotai:

    Profesiniame kontekste taip pat svarbų vaidmenį atlieka Zxing Delphi ir Fmx Tcameracomponent, kai integracijos, duomenų srautai ir tolesnė plėtra turi veikti sklandžiai kartu.

    Aptarti projektą arba modernizacijos užduotį su Net-Base.

    Kitas žingsnis

    Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb frueh zusammen betrachtet werden.

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

    • Esama padėtis, tikslinis vaizdas ir techninės rizikos vertinami kartu.
    • REST, duomenų prieiga, portalai ir rollout nebus perkelti į vėlesnį etapą kaip vėlyvos pasekmės.
    • Jūs anksti matote, kuris kelias yra ekonomiškai ir operaciniškai tvarus.

    Pasidalinti įrašu

    Tiesiogiai pasidalinti šiuo įrašu

    LinkedIn, X, XING, Facebook, WhatsApp ir el. paštas yra iš karto prieinami. Instagramui paruošiame nuorodą ir trumpą tekstą iš karto.

    El. paštas

    Instagram atidaromas naujame skirtuke. Nuoroda ir trumpas tekstas iš anksto nukopijuojami į iškarpinę.