lightweight_barcode_scanner 0.4.0 copy "lightweight_barcode_scanner: ^0.4.0" to clipboard
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. high roughly doubles the per-frame cost.
  • Leave detectionsPerSecond at 12. More attempts do not make a symbol appear sooner; they just burn battery.
  • Use ScanMode.single when one code is all you need.
  • Leave auto zoom on for 1D symbologies. Raising ScanResolution instead 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.

1
likes
160
points
129
downloads

Documentation

API reference

Publisher

verified publisherenver.dev

Weekly Downloads

Lightweight, offline, ML-free barcode & QR scanner for Flutter powered by a shared ZXing-C++ core.

Repository (GitHub)
View/report issues

Topics

#barcode #qrcode #scanner #camera

License

Apache-2.0 (license)

Dependencies

ffi, flutter

More

Packages that depend on lightweight_barcode_scanner

Packages that implement lightweight_barcode_scanner