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
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.

Top comments (0)