lightweight_barcode_scanner 0.4.0
lightweight_barcode_scanner: ^0.4.0 copied to clipboard
Lightweight, offline, ML-free barcode & QR scanner for Flutter powered by a shared ZXing-C++ core.
lightweight_barcode_scanner #
A small, fully offline barcode and QR scanner for Flutter.
Camera frames are decoded inside the native camera pipeline by a shared ZXing-C++ core. Flutter receives decoded results - never pixels. There is no machine-learning runtime, no Google Play Services dependency, no Apple Vision, no OpenCV and no network access.
Native camera ──▶ luminance plane ──▶ shared ZXing-C++ decoder ──▶ result ──▶ Flutter
For the full picture - frame ownership, threading, rotation and region of interest, memory and why there is no ML runtime - see doc/ARCHITECTURE.md.
| Platforms | Android 7.0 (API 24)+, iOS 13+ |
| Native size | ~880 KB per Android ABI, ~790 KB on iOS (arm64, release) |
| Dependencies | CameraX on Android, AVFoundation on iOS, package:ffi on Dart |
| Offline | Always. Nothing leaves the device |
Install #
dependencies:
lightweight_barcode_scanner: ^0.4.0
Android #
Nothing to configure. The plugin's manifest contributes
android.permission.CAMERA and the NDK build produces
libbarcode_scanner.so for every ABI your app targets. minSdk must be 24 or
higher.
iOS #
Add a usage description to ios/Runner/Info.plist; iOS terminates the app
without one:
<key>NSCameraUsageDescription</key>
<string>Scans barcodes with the camera. Images stay on the device.</string>
Both Swift Package Manager and CocoaPods are supported, and nothing has to be configured for either: Flutter picks whichever your project uses. SPM needs Flutter 3.44 or newer; on older versions the plugin falls back to CocoaPods on its own.
Basic usage #
class ScannerPage extends StatefulWidget {
const ScannerPage({super.key});
@override
State<ScannerPage> createState() => _ScannerPageState();
}
class _ScannerPageState extends State<ScannerPage> {
late final BarcodeScannerController controller;
@override
void initState() {
super.initState();
controller = BarcodeScannerController(
formats: {
BarcodeFormat.qrCode,
BarcodeFormat.ean13,
BarcodeFormat.code128,
},
);
}
@override
Widget build(BuildContext context) {
return BarcodeScannerView(
controller: controller,
onDetected: (result) => debugPrint('${result.format}: ${result.value}'),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
BarcodeScannerView starts the camera when it is mounted, releases it when the
app goes to the background, re-opens it on return, and stops it when the widget
is removed. You own the controller, so it survives a rebuild; dispose it when
your page goes away.
Supported formats #
| Format | BarcodeFormat |
Notes |
|---|---|---|
| QR Code | qrCode |
Models 1 and 2 |
| Micro QR / rMQR | microQrCode, rmqrCode |
Also matched when qrCode is requested |
| EAN-13 | ean13 |
|
| EAN-8 | ean8 |
|
| UPC-A | upcA |
Reported as a 13 digit GTIN, see below |
| UPC-E | upcE |
Reported in expanded 13 digit form |
| Code 39 | code39 |
Including the extended and Code 32 / PZN variants |
| Code 93 | code93 |
|
| Code 128 | code128 |
Including GS1-128 |
| ITF | itf |
Including ITF-14 |
| Codabar | codabar |
|
| GS1 DataBar | dataBar |
Omni, stacked and limited variants |
| GS1 DataBar Expanded | dataBarExpanded |
Aztec, Data Matrix, PDF417 and MaxiCode are compiled out of the vendored
decoder. Enabling them is a two-line change to tool/vendor_zxing.sh, at a
cost in binary size and per-frame CPU.
Always pass the formats you actually need. An empty formats set means
"try everything", which costs CPU on every frame and raises the chance of a
misread from a lookalike symbology.
UPC-A values #
ZXing follows ISO/IEC 15420 and GS1 and reports UPC-A and UPC-E content as the
13 digit GTIN, so the 12 digit code printed on a package comes back with a
leading zero (036000291452 reads as 0036000291452). Strip it if you need
the printed form:
final printed = result.format == BarcodeFormat.upcA
? result.value.substring(1)
: result.value;
Controller lifecycle #
await controller.start(); // open the camera and begin analysing
await controller.pause(); // stop analysing, keep the camera warm
await controller.resume(); // analyse again
await controller.stop(); // release the camera and the preview texture
controller.dispose(); // terminal; every later call throws
controller.state moves through idle → initializing → running, and to
paused, stopped or error from there. Invalid transitions do not silently
do nothing: calling anything on a disposed controller throws a
BarcodeScannerException with BarcodeScannerErrorCode.invalidState.
Use pause/resume around a result dialog - it is instant. Use stop/start
when leaving the screen; stop is what actually releases the camera.
Errors #
switch (controller.error?.code) {
case BarcodeScannerErrorCode.permissionDenied: // ask again
case BarcodeScannerErrorCode.permissionPermanentlyDenied: // send to Settings
case BarcodeScannerErrorCode.cameraUnavailable:
case BarcodeScannerErrorCode.cameraInitializationFailed:
case BarcodeScannerErrorCode.cameraInterrupted: // a call, or split view
case BarcodeScannerErrorCode.unsupportedOperation: // e.g. no torch
case BarcodeScannerErrorCode.invalidState:
case BarcodeScannerErrorCode.unknown:
case null:
}
BarcodeScannerException.isRecoverable tells you whether retrying can help.
Torch, zoom and focus #
await controller.setTorch(true);
await controller.toggleTorch();
await controller.setZoom(2.0); // clamped to the camera's range
await controller.setFocusPoint(const Offset(0.5, 0.5)); // fractions of the preview
await controller.setFocusPoint(null); // back to continuous autofocus
await controller.switchCamera();
controller.preview reports hasTorch, minZoom and maxZoom once the
camera is running. setTorch on a camera without a torch throws
unsupportedOperation rather than failing quietly.
Continuous autofocus, continuous auto-exposure and continuous auto white
balance are enabled by default, and on iOS the autofocus range is restricted to
near subjects - a correct decoder with bad focus is still a bad scanner.
BarcodeScannerView focuses where the user taps unless you pass
tapToFocus: false.
Region of interest #
BarcodeScannerView(
controller: controller,
scanWindow: const Rect.fromLTWH(0.1, 0.3, 0.8, 0.4), // fractions of the preview
)
The widget dims the area outside the window and the decoder only looks inside it. Cropping is a view transform over the camera's own memory - no pixels are copied - so a smaller window is a straight CPU saving, typically 3-5x on an empty frame.
You can also set it directly: controller.setScanRegion(rect), or
setScanRegion(null) to scan the whole frame.
Continuous scanning and duplicate filtering #
BarcodeScannerController(
scanMode: ScanMode.continuous, // keep scanning
duplicateFilterDuration: const Duration(milliseconds: 750),
detectionsPerSecond: 12,
);
ScanMode |
Behaviour |
|---|---|
continuous |
Keeps scanning. The default |
single |
Stops analysing after the first accepted barcode; the controller moves to paused |
multiple |
Returns every barcode found in a frame. Costs more CPU, so it is opt-in |
distinct |
Keeps scanning like continuous, but only reports a barcode that differs from the last one reported. A symbol held in view fires once, however long it stays there |
The same format + value is suppressed for duplicateFilterDuration, natively,
before it ever crosses into Dart. Duration.zero disables suppression and gives
you one event per successful frame.
detectionsPerSecond caps decode attempts. The camera keeps running at 30 or
60 fps; surplus frames are dropped, never queued, so the scanner always
works on the most recent frame and never builds up latency.
Streams #
controller.barcodes.listen((result) => ...); // one event per barcode
controller.captures.listen((capture) { // one event per analysed frame
print('${capture.barcodes.length} in ${capture.imageSize}');
});
result.cornerPoints and result.boundingBox are in the coordinate space of
result.imageSize, which is the analysed image after rotation and cropping.
Auto zoom #
BarcodeScannerController(
autoZoom: AutoZoom.enabled, // the default
initialZoom: 1.4, // the default: where the camera opens, and rests
);
AutoZoom |
Ramps on |
|---|---|
enabled |
Both platforms. The default |
enabledIosOnly |
iOS only |
enabledAndroidOnly |
Android only |
disabled |
Neither; the zoom is whatever your app sets |
How much the ramp earns its keep depends on the lens: a camera that focuses
close barely needs it, and there the narrower field of view is a cost with no
return. The per-platform modes are there so you can keep it where your own
device testing says it helps. initialZoom is unaffected either way - it is
where the camera opens on both platforms.
The camera opens at 1.4x. If nothing decodes for about a second the scanner zooms in a little at a time, up to 2x, and steps back to 1.4x on the first read. It is off during a scan that is working, so most sessions never see it.
1.4x rather than 1x is deliberate. 1x is the ratio that forces the user closest
to the symbol, which is exactly where the lens stops being able to focus, and it
makes handing the framing back a visible lurch. Resting slightly tight costs a
little of the frame and removes both. Pass initialZoom: 1 for the full field
of view.
It exists because linear symbologies are limited by focus, not by resolution. Adjacent narrow bars blur into each other long before the frame runs out of pixels, and how much blur a symbol survives scales with how much of the frame it covers. Measured against the host fixtures:
| Symbol covers | EAN-13 | Code 128 | QR |
|---|---|---|---|
| 2 px per module | 1 px of blur | 1 px | 0 px |
| 4 px per module | 1 px | 2 px | 2 px |
| 6 px per module | 4 px | 4 px | 4 px |
The trap is that at 1x the only way to make a barcode fill the frame is to move the phone closer, and past the lens's minimum focus distance it cannot focus at all - so the symbol gets bigger and blurrier at the same time. Zooming buys the same coverage from a distance the lens can still focus at. QR codes rarely need it, which is why a scanner can feel flawless on QR and unreliable on a barcode in the same session.
The ramp stops at an absolute 2x - not 2x whatever it started from - on purpose:
past roughly that point a phone is enlarging pixels it never captured, so it
costs field of view and returns no detail.
Calling setZoom hands control back to your app and switches auto zoom off for
the rest of the session.
Decoder profiles #
BarcodeScannerController(profile: DecoderProfile.balanced);
| Profile | What it enables | When |
|---|---|---|
fast |
No fallbacks | Upright, well-lit symbols; lowest latency |
balanced |
Rotation and downscale fallbacks | The default |
thorough |
Also inverted (light-on-dark) symbols | Still images, or a "having trouble?" mode |
Static image decoding #
final results = await BarcodeScanner.decodeImage(bytes); // PNG, JPEG, WebP...
final fromPixels = await BarcodeScanner.decodePixels(
pixels: luminance,
width: 1280,
height: 720,
pixelFormat: ImagePixelFormat.luminance,
);
This is the one path where pixels travel from Dart into native code, over
dart:ffi. The image is decoded by Flutter's own codecs (no image library is
bundled), copied once into native memory and decoded on a background isolate.
BarcodeScanner.engineVersion; // vendored ZXing-C++ version
BarcodeScanner.supportedFormats; // what this build can decode
Performance recommendations #
- Name your formats. The single biggest lever.
- Use a scan window when the UI already tells the user where to aim.
- Stay at
ScanResolution.medium(1280x720) unless you are reading small or dense symbols.highroughly doubles the per-frame cost. - Leave
detectionsPerSecondat 12. More attempts do not make a symbol appear sooner; they just burn battery. - Use
ScanMode.singlewhen one code is all you need. - Leave auto zoom on for 1D symbologies. Raising
ScanResolutioninstead does not help: optical blur grows with the pixel count, so the ratio that actually matters stays where it was, and you pay about twice the CPU.
Decode latency of the shared core, measured on an Apple M-series host with
clean synthetic frames (tool/run_native_benchmark.sh). Treat it as relative
guidance, not a phone benchmark - a real device is slower and real frames are
noisier:
| Case | 640x480 | 1280x720 | 1920x1080 |
|---|---|---|---|
| QR, one format | 0.45 ms | 0.54 ms | 1.13 ms |
| QR, all formats | 0.30 ms | 0.52 ms | 1.12 ms |
| EAN-13 | 0.06 ms | 0.12 ms | 0.27 ms |
| Empty frame | 0.17 ms | 0.38 ms | 0.87 ms |
| Empty frame, 20% scan window | 0.02 ms | 0.08 ms | 0.18 ms |
Privacy #
- No image ever leaves the device, and the plugin makes no network calls.
- The only permission required is the camera. No internet, location, microphone, contacts, storage or photo-library access.
- Everything is decoded locally, so the scanner works fully offline.
- An empty privacy manifest (
PrivacyInfo.xcprivacy) ships with the iOS pod: nothing is collected and nothing is tracked.
Known limitations #
- No web, macOS, Windows or Linux. The camera pipelines are Android and iOS only. The C++ core itself is portable.
- Front-camera frames are mirrored. Matrix codes still decode, but a
mirrored 1D symbol may need
DecoderProfile.thorough. - Device verification is on you. The decoder is covered by host tests and both platform builds are verified, but camera behaviour - orientation, torch, focus - has to be checked on real hardware. Emulators and simulators do not reproduce it.
Development #
tool/vendor_zxing.sh [tag] # re-vendor the ZXing-C++ reader core
tool/generate_fixtures.py # regenerate the symbol fixtures
tool/run_native_tests.sh # host tests for the shared C++ decoder
tool/run_native_benchmark.sh # decode latency benchmark
flutter test # Dart unit and widget tests
doc/ARCHITECTURE.md explains the frame paths, the threading model and why
this package does not use ML Kit or Apple Vision.
License #
Apache-2.0. This package redistributes a subset of
ZXing-C++, also Apache-2.0; see
NOTICE and third_party/zxing-cpp/VENDORING.md.