Optimizing CameraX and ML Kit for Real-World Workflows: Building Sharp QR
Most modern Android phones ship with a default camera app capable of detecting QR codes. Yet, if you look at the Play Store, barcode and QR scanning utilities remain persistently popular.
Why? Because default camera implementations are designed for casual photography, not high-throughput, specialized utility tasks. Most third-party alternatives swing too far in the opposite direction: bloated with intrusive ads, full-screen video interruptions, and sketchy permissions.
When I set out to build Sharp QR, my goal was to build a clean, reliable scanner and generator focused on performance and practical utility.
Here is a look at who this tool was built for, the technical challenges behind it, and what I learned building it with modern Android tooling.
Who Actually Needs a Dedicated QR Tool?
While everyday users occasionally scan a restaurant menu, certain professional workflows demand something far more dependable and focused:
- Event Organizers and Check-in Staff: When you need to process hundreds of attendees entering a venue, a standard camera app hunting for focus on wrinkled paper or dim phone screens causes massive bottlenecks. They need instant feedback, persistent history logs, and offline reliability.
- Marketers and Print Designers: Marketers regularly generate URLs, vCards, and Wi-Fi credentials for packaging, posters, and business cards. They need a tool that can instantly generate precise, high-contrast QR matrices, verify how they parse across different data schemas, and test them locally before sending assets to the print shop.
- Field Technicians and IT Administrators: Technicians regularly scan asset tags, network router credentials, and serial numbers in poorly lit server racks or warehouses. They need direct access to torch controls, instant clipboard copying, and zero ad popups blocking their workflow.
The Tech Stack
To keep the footprint small and the UI responsive, I relied on modern, first-party Android libraries:
- Language: Kotlin
- UI Layer: Jetpack Compose (Material 3)
-
Camera Pipeline: AndroidX CameraX (
camera-camera2,camera-lifecycle,camera-view) - Vision Processing: Google ML Kit Barcode Scanning API (bundled model)
- Local Persistence: Room Database with Kotlin Coroutines and Flow
- QR Generation: ZXing Core (strictly used for the encoding matrix logic)
Technical Challenges
1. Frame Analysis Bottlenecks with CameraX
Setting up CameraX with ImageAnalysis.Analyzer is straightforward on paper, but keeping the frame rate steady across diverse hardware is tricky. Feeding every 30fps YUV frame directly into ML Kit's detector quickly leads to thermal throttling and dropped frames on budget devices.
To solve this, I decoupled frame delivery from analysis. Using ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST ensured the camera pipeline never blocked waiting for ML Kit to complete inference on the previous frame. Additionally, I scoped the target analysis resolution to 1080p, which provides the sweet spot between reading dense, small QR codes and maintaining sub-50ms inference times.
2. Eliminating Detection Jitter
When scanning in continuous mode or saving scan history, ML Kit will report the same barcode across 15 consecutive frames. Without debouncing, your database fills up with duplicates instantly.
I built a simple time-window debounce mechanism using Kotlin Flows:
private var lastScannedValue: String? = null
private var lastScannedTimestamp: Long = 0L
private val scanCooldownMs = 1500L
fun onBarcodeDetected(rawValue: String) {
val now = System.currentTimeMillis()
if (rawValue != lastScannedValue || (now - lastScannedTimestamp) > scanCooldownMs) {
lastScannedValue = rawValue
lastScannedTimestamp = now
processBarcode(rawValue)
}
}
3. Asynchronous Code Generation
Generating QR codes with custom error correction levels (L, M, Q, H) using ZXing can cause perceptible frame drops if executed on the main dispatcher, particularly for large payloads like vCards. I offloaded all BitMatrix calculations and Bitmap rendering to Dispatchers.Default, passing the finished bitmap to Compose via a state holder.
Lessons Learned
- Don't overcomplicate the vision pipeline: Google ML Kit's bundled barcode model adds minimal app size while running fully on-device without an active internet connection. It consistently outperformed custom OpenCV pipelines for standard 2D formats.
-
Haptic feedback matters: In high-speed workflows (like scanning asset tags), visual cues on screen aren't enough. Adding subtle, immediate haptic feedback via
Vibratorupon successful parsing drastically improves the operator's scanning rhythm.
Try It Out
Sharp QR is built to do one job with speed and precision, without paywalls or distracting banner ads.
If you regularly work with QR codes or need a reliable utility for your workflow, you can download Sharp QR on Google Play.
You can also explore more independent utilities built for practical use cases at getinfotoyou.com.
Top comments (1)
The KEEP_ONLY_LATEST + 1080p pairing is the right call, but I think the debounce has a hole that will bite exactly the persona you built it for.
It keeps one slot - lastScannedValue - so it suppresses A,A but passes A,B,A. At a check-in desk that is the normal traffic pattern: two people hand over badges in alternation, or one attendee's code re-enters the frame after someone else's crossed it, and the third read lands 300ms later with the cooldown fully bypassed. Same-value-back-to-back is the one case a single slot catches, and it is the least likely one in a moving queue. A small LRU of recent values, each carrying its own timestamp, closes that without changing the ergonomics.
Worth splitting the two jobs while you are in there, because they want very different lifetimes: a UI debounce so the sheet does not flicker (hundreds of ms), versus an "already checked in" guard that has to hold for the whole event. The second is a uniqueness constraint in Room rather than a cooldown, and it survives the process death that a 1500ms in-memory window does not.
On the field-technician case - dim server racks, wrinkled paper - the remaining lever is probably focus rather than throughput. The default AF behaviour hunts on low-contrast close subjects, and CameraX does not expose the mode directly; Camera2Interop.Extender lets you pin CONTROL_AF_MODE to CONTINUOUS_VIDEO on the ImageAnalysis use case, which converges faster and hunts less than CONTINUOUS_PICTURE. Have you measured where the failed reads actually come from - inference time, or frames that were never in focus to begin with?