DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)")
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)