Net-Base Журнал

03.06.2026

Сканер QR-кодов в Delphi FMX: сканирование с камеры — надёжно, потокобезопасно и без подёргиваний интерфейса

Практически применимый сканер QR-кодов Delphi FMX зависит от управления жизненным циклом камеры, многопоточности и корректного запуска/остановки. В статье показан надёжный подход с использованием ZXing, Debounce (устранение дребезга), ограничения частоты кадров (frame-throttling), кадрирования области интереса (ROI), а также с деталями отладки и эксплуатации для Android и iOS.

03.06.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

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

В сканере QR-кодов Delphi FMX в демо всё быстро собирается: показать превью камеры, снять Bitmap, прогнать через ZXing. В реальной бизнес‑софте (например, приемка товаров, привязка устройств, тикетинг, процессы доступа) появляются дополнительные граничные условия: приложение уходит в фон, камера теряет фокус, пользователь держит устройство под углом, меняется формат изображения — и внезапно вы сканируете один и тот же код дважды в секунду или интерфейс дергается, потому что декодирование выполняется в UI‑потоке.

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

Обзор архитектуры: Pipeline вместо „OnSampleBufferReady macht alles“

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

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

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

Фрагмент кода: надёжный сканер QR‑кодов (FMX + ZXing) с Debounce и корректной остановкой

Ниже приведён код как компактный, но пригодный для проекта компонент. Он использует ZXing (Delphi‑порт) через ZXing.ScanManager и привязывается к TCameraComponent.OnSampleBufferReady. Ключевые моменты:

  • Фреймы ограничиваются throttled (не декодировать каждый Sample).
  • Декодирование выполняется не в UI‑потоке.
  • Stop/Start идемпотентен (можно вызывать многократно без хаоса с ресурсами).

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 — Interlocked-флаг
FLastDecodeTick: Int64;
FMinIntervalMs: Cardinal;

// Антидребезг против повторяющихся одинаковых кодов
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
// Ограничение: не декодировать каждый фрейм
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);

// одинаковый текст в пределах окна дебаунса — игнорировать
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) обеспечивает, что выполняется не более одного Decode-Task. Это архитектурный приём против «Queue-Stau»: если декодирование занимает 200 мс, но новые таски запускаются каждые 120 мс, очередь растёт и результаты приходят с задержкой, что в эксплуатации выглядит как «сканер реагирует неправильно».

Условия и подводные камни

  • TBitmap und Threading: FMX‑Bitmap могут быть GPU‑поддерживаемыми. Подход копирует фрейм в локальную Bitmap и декодирует в фоне. В зависимости от версии/платформы Delphi всё равно может потребоваться осторожность: если вы видите артефакты, принудительно используйте CPU‑Bitmap (например, через Pixel-Read/Write) либо работайте с ByteBuffer из SampleBuffer (более низкоуровневый, но стабильный вариант).
  • Stop/Start при навигации: В мобильных приложениях при смене формы или на событии паузы приложения часто вызывают остановку. Важно, чтобы Stop можно было вызывать многократно и чтобы он не генерировал исключений (идемпотентность). Кроме того, колбэк результатов должен проверять, запущен ли ещё сканер (это делает DoResultOnMainThread).
  • ROI слишком узкий: Центрированное ROI ускоряет обработку, но может не сработать, если пользователь держит код вне зоны или код очень маленький. Поэтому EnableRoi делается настраиваемым, а RoiScale ограничено.
  • Фиксация формата на QR: Ограничение форматов до QR_CODE чаще всего оправдано. Если вам также нужны Code128/EAN, расширьте набор форматов — при этом ожидайте больше ложноположительных срабатываний и большую нагрузку CPU.

Delphi FMX жизненный цикл камеры: разрешения, фон, поворот

Наиболее частые баги возникают не при декодировании, а вокруг камеры:

  • Разрешения Android: Права на камеру нужно запрашивать во время выполнения. Учтите сценарий, когда пользователь отклоняет запрос или выбирает «Только сейчас». Технически это означает: держите состояние UI («Сканер готов?») отдельно от состояния камеры, иначе вы рискуете застрять в полуготовых состояниях.
  • Приложение уходит в фон: При OnApplicationEvent (например, EnteredBackground) следует вызывать Stop. При возврате сознательно вызывайте Start (и при необходимости добавьте небольшую задержку), чтобы превью оставалось стабильным.
  • Поворот/зеркалирование: Для QR‑кодов поворот часто некритичен, но в некоторых конвейерах камеры Bitmap может быть зеркально отображён или повернут. Если скан работает «только в одной ориентации», это сигнал. В таком случае поверните/отзеркальте изображение перед сканированием или используйте декодер, который учитывает метаданные ориентации.

Отладка в эксплуатации: как найти реальные причины

Если сканер «иногда» не считывает, воспроизводимая отладка имеет большую ценность. Три проверенных меры:

  1. Логирование выборки кадров: Логируйте (только в режиме отладки/поддержки) тик, размер изображения, размер ROI, время декодирования. Так вы сразу увидите, являются ли причиной проблема с Throttle/Debounce или нагрузкой CPU.
  2. Сохранение тестовых изображений: Сохраняйте каждые N секунд изображение ROI (временно). Это позволит без наличия камеры проанализировать, являются ли причиной проблемы контраст или нечеткость.
  3. Разделение нагрузки: не обновляйте UI-элементы (Preview-Overlay, Status-Text) с высокой частотой. «Дрожание UI» часто возникает из-за слишком большого количества Queue-событий.

Варианты: если требуется больше, чем «скан и готово»

Несколько результатов, но под контролем

Для пакетных процессов (например, при последовательной печати множества этикеток) уменьшите DebounceMs и добавьте Whitelist/State-Machine: QR-код должен приниматься только тогда, когда текущий шаг процесса его ожидает. Это не логика UI, а доменная логика — она должна находиться в отдельном слое, чтобы сканер и процесс оставались независимо тестируемыми.

Офлайн-валидация и защищённые полезные данные

В корпоративных процессах QR-коды часто содержат идентификаторы или токены. Не полагайтесь на то, что «QR = корректный». Выполняйте валидацию локально (формат, контрольная сумма, ожидаемые префиксы) и на сервере (REST-API). Если вы используете токены: учитывайте время жизни, защиту от повторного воспроизведения и осторожно относитесь к логированию (не храните токены в открытом виде в логах поддержки).

Ситуации с наследием: FMX-сканер как модуль в смешанных кодовых базах

Если у вас устойчивая VCL-среда, FMX как мобильный клиент часто развивается в отдельную ветку. Держите сканер как класс контроллера без зависимостей от форм (как описано выше), тогда вы сможете интегрировать его в разные экраны. Это окупается и при модернизации: бизнес-логика остаётся тестируемой, а камера — лишь канал ввода. В наследуемых системах также оправдан чёткий раздел для логирования, Feature-Flags и удалённой конфигурации.

Вывод: надёжное FMX-QR-сканирование — это проблема жизненного цикла, а не только вызов ZXing

QR-сканер в Delphi FMX становится стабильным, если вы рассматриваете его как небольшую конвейерную цепочку: камера поставляет кадры, фоновый декодер работает контролируемо, а Debounce/Throttle предотвращают дубли и поздние события. Приведённый выше фрагмент исходного кода нацелен именно на те места, где в реальных мобильных бизнес-процессах происходят сбои: слишком много задач декодирования, некорректная остановка, блокировки UI-потока и ненужная нагрузка.

Границы применения: если вам требуются экстремально высокие скорости сканирования (например, промышленное сканирование на конвейере) или жёсткие требования к обработке изображений, стандартная камера FMX + Bitmap-пайплайн часто оказывается слишком затратной. В таких случаях имеет смысл низкоуровневый подход (Native Camera API, YUV-Buffer напрямую, SIMD/NEON) или специализированный Scanner-SDK. Для большинства прикладных мобильных решений представленный подход, при условии чистой интеграции lifecycle, прав и потоков, является достаточным — при условии, что процессы, стоящие за ним, однозначно определены.

Если вам нужно встроить QR-скан в существующую Delphi-архитектуру (включая граничные случаи: навигация, переход в фон, логирование и валидация процесса), мы готовы проработать это структурированно:

В профессиональном контексте Zxing Delphi и Fmx Tcameracomponent также играют важную роль, когда требуется согласованная интеграция, потоки данных и дальнейшая эволюция.

Обсудить проект или задачу по модернизации с Net-Base.

Следующий шаг

Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.

Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.

  • Текущее состояние, целевое состояние и технические риски оцениваются совместно.
  • REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
  • Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.