VOOZH about

URL: https://dev.to/yushulx/ios-barcode-sdk-benchmark-dynamsoft-vs-ml-kit-apple-vision-and-zxing-cpp-in-swiftui-2ab0

⇱ iOS Barcode SDK Benchmark: Dynamsoft vs ML Kit, Apple Vision, and ZXing-CPP in SwiftUI - DEV Community


Developers integrating barcode scanning into iOS apps have several viable options — Dynamsoft Barcode Reader (commercial), Google ML Kit (free), Apple Vision (native), and ZXing-CPP (open source) — each with different accuracy, speed, and format coverage characteristics. This article walks through building a SwiftUI benchmark app that runs all four SDKs side-by-side against identical image and video inputs. The results show Dynamsoft Barcode Reader as the clear leader: it achieved the highest detection count on a real-world test set while remaining the only SDK to combine that accuracy with hardware-accelerated, production-viable speed.

What you'll build: An iOS 16+ SwiftUI app that benchmarks four barcode scanning SDKs (Dynamsoft, ML Kit, Apple Vision, ZXing-CPP) across image, video, and live camera modes with colored bounding-box overlays and an HTML report exportable from a built-in HTTP server.

Demo Video: iOS Barcode Scanner Benchmark in Action

Prerequisites

  • Xcode 15.0+ with Swift 5.0 support
  • iOS 16.0+ device or simulator
  • CocoaPods installed (sudo gem install cocoapods) — required for Google ML Kit
  • A Dynamsoft Barcode Reader trial license key (the other three SDKs are free)

Get a 30-day free trial license at dynamsoft.com/customer/license/trialLicense

Step 1: Install and Configure the SDKs

ML Kit is not available through Swift Package Manager, so it is installed via CocoaPods. From the project root:

cd BarcodeBenchmarkiOS
pod install

After installation, always open BarcodeBenchmark.xcworkspace, not the .xcodeproj file. DynamsoftCaptureVisionBundle (v11.4.1200) and zxing-cpp (v2.3.0) are declared in project.pbxproj and resolved automatically by Xcode on first build. Apple Vision requires no additional setup — it is part of the iOS SDK.

The Podfile targets iOS 16 and links ML Kit 8.0.0:

platform :ios, '16.0'

target 'BarcodeBenchmark' do
 use_frameworks!

 # Google ML Kit Barcode Scanning
 pod 'GoogleMLKit/BarcodeScanning', '8.0.0'
end

Step 2: Define a Shared BarcodeDetector Protocol

All four detectors implement the same two-method protocol. This keeps the benchmark logic SDK-agnostic and makes it straightforward to add or remove a backend in the future:

protocol BarcodeDetector {
 func detectBarcodes(in image: UIImage) async throws -> [BarcodeInfo]
 func detectBarcodes(in pixelBuffer: CVPixelBuffer) async throws -> [BarcodeInfo]
}

The UIImage overload is used for image and video-frame benchmarks; the CVPixelBuffer overload is called directly from the camera sample buffer delegate for live scanning.

Step 3: Implement the Dynamsoft Barcode Reader Detector

Dynamsoft uses CaptureVisionRouter from DynamsoftCaptureVisionBundle. License verification happens asynchronously on init; decoding is a single synchronous call to captureFromImage:

import DynamsoftCaptureVisionBundle
import DynamsoftBarcodeReaderBundle

class DynamsoftBarcodeDetector: NSObject, BarcodeDetector, LicenseVerificationListener {

 private let cvr: CaptureVisionRouter

 override init() {
 cvr = CaptureVisionRouter()
 super.init()
 LicenseManager.initLicense("YOUR_LICENSE_KEY_HERE", verificationDelegate: self)
 }

 func onLicenseVerified(_ isSuccess: Bool, error: Error?) {
 if !isSuccess {
 print("Dynamsoft license error: \(error?.localizedDescription ?? "Unknown error")")
 }
 }

 func detectBarcodes(in image: UIImage) async throws -> [BarcodeInfo] {
 let result = cvr.captureFromImage(image, templateName: "ReadBarcodes_Default")
 guard let items = result.decodedBarcodesResult?.items else { return [] }
 return items.map { item in
 let bounds: CGRect? = {
 guard image.size.width > 0, image.size.height > 0 else { return nil }
 let br = item.location.boundingRect
 return CGRect(
 x: br.minX / image.size.width,
 y: br.minY / image.size.height,
 width: br.width / image.size.width,
 height: br.height / image.size.height)
 }()
 return BarcodeInfo(format: item.formatString ?? "", text: item.text ?? "", decodeTimeMs: 0, normalizedBounds: bounds)
 }
 }

 func detectBarcodes(in pixelBuffer: CVPixelBuffer) async throws -> [BarcodeInfo] {
 let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
 guard let cgImage = CIContext().createCGImage(ciImage, from: ciImage.extent) else {
 throw DetectionError.invalidImage
 }
 return try await detectBarcodes(in: UIImage(cgImage: cgImage))
 }
}

Step 4: Implement the ML Kit Barcode Detector

ML Kit uses an async-callback BarcodeScanner. The detector wraps the callback in withCheckedThrowingContinuation to fit the async throws protocol signature. Passing .all to BarcodeScannerOptions enables every supported format:

import MLKitBarcodeScanning
import MLKitVision

class MLKitBarcodeDetector: BarcodeDetector {

 private let scanner: MLKitBarcodeScanning.BarcodeScanner

 init() {
 let options = BarcodeScannerOptions(formats: .all)
 scanner = MLKitBarcodeScanning.BarcodeScanner.barcodeScanner(options: options)
 }

 func detectBarcodes(in image: UIImage) async throws -> [BarcodeInfo] {
 let visionImage = VisionImage(image: image)
 visionImage.orientation = image.imageOrientation
 let imgW = image.size.width
 let imgH = image.size.height
 return try await withCheckedThrowingContinuation { continuation in
 scanner.process(visionImage) { barcodes, error in
 if let error = error {
 continuation.resume(throwing: DetectionError.detectionFailed(error.localizedDescription))
 return
 }
 let results = (barcodes ?? []).map { barcode -> BarcodeInfo in
 var bounds: CGRect?
 if imgW > 0 && imgH > 0 {
 let f = barcode.frame
 bounds = CGRect(x: f.minX / imgW, y: f.minY / imgH,
 width: f.width / imgW, height: f.height / imgH)
 }
 return BarcodeInfo(format: barcode.format.description,
 text: barcode.rawValue ?? barcode.displayValue ?? "",
 decodeTimeMs: 0, normalizedBounds: bounds)
 }
 continuation.resume(returning: results)
 }
 }
 }

 func detectBarcodes(in pixelBuffer: CVPixelBuffer) async throws -> [BarcodeInfo] {
 let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
 guard let cgImage = CIContext().createCGImage(ciImage, from: ciImage.extent) else {
 throw DetectionError.invalidImage
 }
 return try await detectBarcodes(in: UIImage(cgImage: cgImage))
 }
}

Step 5: Implement the Apple Vision Barcode Detector

Apple Vision requires no SDK installation. VNDetectBarcodesRequest is created per call and submitted via VNImageRequestHandler. Note that Vision's boundingBox origin is bottom-left, so the Y coordinate must be flipped before drawing overlays:

import Vision

class VisionBarcodeDetector: BarcodeDetector {

 func detectBarcodes(in image: UIImage) async throws -> [BarcodeInfo] {
 guard let cgImage = image.cgImage else {
 throw DetectionError.invalidImage
 }
 return try await withCheckedThrowingContinuation { continuation in
 let request = VNDetectBarcodesRequest { request, error in
 if let error = error {
 continuation.resume(throwing: DetectionError.detectionFailed(error.localizedDescription))
 return
 }
 guard let results = request.results as? [VNBarcodeObservation] else {
 continuation.resume(returning: [])
 return
 }
 let barcodes = results.map { observation -> BarcodeInfo in
 let bb = observation.boundingBox
 // boundingBox origin is bottom-left; flip Y for top-left screen coords
 let bounds = CGRect(x: bb.minX, y: 1.0 - bb.maxY,
 width: bb.width, height: bb.height)
 return BarcodeInfo(format: self.mapSymbology(observation.symbology),
 text: observation.payloadStringValue ?? "",
 decodeTimeMs: 0, normalizedBounds: bounds)
 }
 continuation.resume(returning: barcodes)
 }
 request.symbologies = [.qr, .code128, .code39, .code93, .ean8, .ean13,
 .upce, .pdf417, .aztec, .dataMatrix, .codabar, .itf14]
 let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
 do {
 try handler.perform([request])
 } catch {
 continuation.resume(throwing: DetectionError.detectionFailed(error.localizedDescription))
 }
 }
 }

 func detectBarcodes(in pixelBuffer: CVPixelBuffer) async throws -> [BarcodeInfo] {
 return try await withCheckedThrowingContinuation { continuation in
 let request = VNDetectBarcodesRequest { request, error in
 if let error = error {
 continuation.resume(throwing: DetectionError.detectionFailed(error.localizedDescription))
 return
 }
 guard let results = request.results as? [VNBarcodeObservation] else {
 continuation.resume(returning: [])
 return
 }
 let barcodes = results.map { observation -> BarcodeInfo in
 let bb = observation.boundingBox
 let bounds = CGRect(x: bb.minX, y: 1.0 - bb.maxY,
 width: bb.width, height: bb.height)
 return BarcodeInfo(format: self.mapSymbology(observation.symbology),
 text: observation.payloadStringValue ?? "",
 decodeTimeMs: 0, normalizedBounds: bounds)
 }
 continuation.resume(returning: barcodes)
 }
 request.symbologies = [.qr, .code128, .code39, .code93, .ean8, .ean13,
 .upce, .pdf417, .aztec, .dataMatrix, .codabar, .itf14]
 let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
 do {
 try handler.perform([request])
 } catch {
 continuation.resume(throwing: DetectionError.detectionFailed(error.localizedDescription))
 }
 }
 }
}

Step 6: Implement the ZXing-CPP Barcode Detector

ZXing-CPP is integrated via SPM using the zxing-cpp package and its iOS ZXingCpp wrapper. The ZXIBarcodeReader class handles both CGImage and CVPixelBuffer directly, and bounding coordinates are extracted from the result's position quad:

import ZXingCpp

class ZXingCppBarcodeDetector: BarcodeDetector {

 private let reader: ZXIBarcodeReader

 init() {
 reader = ZXIBarcodeReader()
 }

 func detectBarcodes(in image: UIImage) async throws -> [BarcodeInfo] {
 guard let cgImage = image.cgImage else {
 throw DetectionError.invalidImage
 }
 let results = try reader.read(cgImage)
 let imgW = Int(image.size.width)
 let imgH = Int(image.size.height)
 return results.map { r in
 var bounds: CGRect?
 if imgW > 0 && imgH > 0 {
 let pos = r.position
 let xs = [pos.topLeft.x, pos.topRight.x, pos.bottomRight.x, pos.bottomLeft.x]
 let ys = [pos.topLeft.y, pos.topRight.y, pos.bottomRight.y, pos.bottomLeft.y]
 if let minX = xs.min(), let maxX = xs.max(),
 let minY = ys.min(), let maxY = ys.max() {
 bounds = CGRect(x: CGFloat(minX) / CGFloat(imgW),
 y: CGFloat(minY) / CGFloat(imgH),
 width: CGFloat(maxX - minX) / CGFloat(imgW),
 height: CGFloat(maxY - minY) / CGFloat(imgH))
 }
 }
 return BarcodeInfo(format: mapFormat(r.format), text: r.text,
 decodeTimeMs: 0, normalizedBounds: bounds)
 }
 }

 func detectBarcodes(in pixelBuffer: CVPixelBuffer) async throws -> [BarcodeInfo] {
 let results = try reader.read(pixelBuffer)
 let bufW = CVPixelBufferGetWidth(pixelBuffer)
 let bufH = CVPixelBufferGetHeight(pixelBuffer)
 return results.map { r in
 var bounds: CGRect?
 if bufW > 0 && bufH > 0 {
 let pos = r.position
 let xs = [pos.topLeft.x, pos.topRight.x, pos.bottomRight.x, pos.bottomLeft.x]
 let ys = [pos.topLeft.y, pos.topRight.y, pos.bottomRight.y, pos.bottomLeft.y]
 if let minX = xs.min(), let maxX = xs.max(),
 let minY = ys.min(), let maxY = ys.max() {
 bounds = CGRect(x: CGFloat(minX) / CGFloat(bufW),
 y: CGFloat(minY) / CGFloat(bufH),
 width: CGFloat(maxX - minX) / CGFloat(bufW),
 height: CGFloat(maxY - minY) / CGFloat(bufH))
 }
 }
 return BarcodeInfo(format: mapFormat(r.format), text: r.text,
 decodeTimeMs: 0, normalizedBounds: bounds)
 }
 }
}

Step 7: Extract and Benchmark Barcode Detection Over Video Frames

👁 iOS barcode detection benchmark from video files

For video benchmarks, VideoFrameExtractor uses AVAssetImageGenerator to sample frames at a fixed interval (default 0.5 s). Each frame is decoded as a CGImage and wrapped into a UIImage before being passed to the detector chain. The tolerance is set to zero to ensure exact frame positions are decoded:

class VideoFrameExtractor {
 static func extractFrames(from videoURL: URL, interval: Double) async throws -> [UIImage] {
 let asset = AVAsset(url: videoURL)
 let duration = try await asset.load(.duration)
 let durationSeconds = CMTimeGetSeconds(duration)

 let imageGenerator = AVAssetImageGenerator(asset: asset)
 imageGenerator.appliesPreferredTrackTransform = true
 imageGenerator.requestedTimeToleranceBefore = .zero
 imageGenerator.requestedTimeToleranceAfter = .zero

 var frames: [UIImage] = []
 var currentTime: Double = 0

 while currentTime < durationSeconds {
 let cmTime = CMTime(seconds: currentTime, preferredTimescale: 600)
 if let cgImage = try? imageGenerator.copyCGImage(at: cmTime, actualTime: nil) {
 frames.append(UIImage(cgImage: cgImage))
 }
 currentTime += interval
 }
 return frames
 }
}

Once frames are extracted, each SDK runs sequentially. The benchmark records total processing time and accumulates unique barcode results (format:text as a deduplication key) across frames — so a barcode visible in five consecutive frames counts as one detection:

private func runDetectorBenchmark(
 detector: BarcodeDetector,
 engineName: String,
 frames: [UIImage],
 totalFrames: Int,
 startProgress: Double,
 endProgress: Double
) async {
 var allBarcodes: [BarcodeInfo] = []
 var uniqueBarcodes = Set<String>()
 var totalTimeMs: Int64 = 0

 for (index, frame) in frames.enumerated() {
 let startTime = Date()
 let barcodes = (try? await detector.detectBarcodes(in: frame)) ?? []
 totalTimeMs += Int64(Date().timeIntervalSince(startTime) * 1000)

 for barcode in barcodes {
 let key = "\(barcode.format):\(barcode.text)"
 if uniqueBarcodes.insert(key).inserted {
 allBarcodes.append(barcode)
 }
 }

 let frameProgress = Double(index + 1) / Double(totalFrames)
 let overallProgress = startProgress + (frameProgress * (endProgress - startProgress))
 await MainActor.run {
 progress = overallProgress
 statusMessage = "\(engineName): Frame \(index + 1)/\(totalFrames)"
 }
 }

 await MainActor.run {
 var result = BenchmarkResult(engineName: engineName)
 result.framesProcessed = totalFrames
 result.totalTimeMs = totalTimeMs
 result.barcodes = allBarcodes
 // assign to the appropriate viewModel property
 }
}

Step 8: Configure the Live Camera Feed with AVFoundation

CameraManager wraps AVCaptureSession, selects the rear wide-angle camera, and configures AVCaptureVideoDataOutput to push sample buffers to a per-SDK delegate. Resolution is selectable between 720p and 1080p from the home screen before starting any scanner:

func setupCamera(resolution: CameraResolution, delegate: AVCaptureVideoDataOutputSampleBufferDelegate) throws {
 let session = AVCaptureSession()

 switch resolution {
 case .hd720:
 session.sessionPreset = .hd1280x720
 case .hd1080:
 session.sessionPreset = .hd1920x1080
 }

 guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
 throw CameraError.noCameraAvailable
 }

 let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
 if session.canAddInput(videoDeviceInput) {
 session.addInput(videoDeviceInput)
 }

 videoOutput.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "videoQueue"))
 videoOutput.alwaysDiscardsLateVideoFrames = true
 if session.canAddOutput(videoOutput) {
 session.addOutput(videoOutput)
 }

 if let connection = videoOutput.connection(with: .video) {
 connection.videoOrientation = .portrait
 }

 self.session = session
}

Each live scanner view (DynamsoftScannerView, MLKitScannerView, VisionScannerView, ZXingCppScannerView) draws color-coded bounding-box overlays — blue, green, purple, and orange respectively — using normalized CGRect coordinates returned by the detectors.

Step 9: Run Remote Benchmarks via the Built-In HTTP Server

Why Remote Benchmarking Is Valuable for Mobile SDK Evaluation

Evaluating a barcode SDK properly requires a large, diverse image corpus — often dozens to hundreds of files. Transferring this dataset to a mobile device and triggering each test manually is slow and error-prone. The built-in HTTP server solves this: open the device's IP in a desktop browser, drag-and-drop files in batch, and let the device run detection autonomously using its real CPU, GPU, and Neural Engine. The advantage over emulators or simulator tests is that results reflect the actual hardware pipeline — including ML accelerator scheduling and thermal throttling — which matters when comparing SDKs with different compute backends.

A secondary benefit is reproducibility: the server processes files in a consistent order, runs each SDK on the exact same decoded image bytes, and returns a structured JSON response per file that the browser accumulates into a downloadable HTML report. This workflow was used to produce the benchmark data in this article.

The server starts with a single call and makes the device's local IP available as a URL:

func startWebServer() {
 guard webServer == nil else { return }
 do {
 let server = try BenchmarkWebServer(port: BenchmarkConfig.serverPort, viewModel: self)
 try server.start()
 webServer = server
 isWebServerRunning = true
 if let ip = getLocalIPAddress() {
 serverURL = "http://\(ip):\(BenchmarkConfig.serverPort)"
 }
 } catch {
 print("Failed to start web server: \(error)")
 }
}

For each uploaded file the server determines whether it is an image or video (via MIME type or byte sniffing), runs all four detectors in series, and returns a JSON object with per-SDK barcode counts and timing. Video files are sampled at up to 30 evenly-spaced frames. A real-time progress endpoint (GET /api/video-progress) lets the browser poll SDK-level progress during video processing.

Benchmark Results

All benchmark data was collected via the remote HTTP server using 83 images from the Dynamsoft Barcode Test Sheet — a publicly available set covering QR codes, Data Matrix, PDF417, Aztec, EAN/UPC, Code 128/39/93, Codabar, ITF, and other common linear and 2D symbologies at various image qualities and orientations.

👁 iOS Barcode Scanner Benchmark with Dynamsoft, ML Kit, Apple Vision, and ZXing-CPP

Source Code

https://github.com/yushulx/ios-swiftui-barcode-mrz-document-scanner/tree/main/examples/BarcodeBenchmarkiOS