How I Built a QR Scanner App for a Real-World Project (Android + ML Kit)

QR Scanner App

How I Built a QR Scanner App for a Real-World Project (Android + ML Kit)

By Ashif Kadri | Android & Flutter Developer

When my company needed a robust QR scanner feature for one of our internal enterprise projects, I faced a classic developer dilemma: do I implement a quick, third-party wrapper library, or do I build the implementation from scratch?

After evaluating the project’s long-term maintenance needs, I decided to build our custom solution using Jetpack CameraX and Google’s ML Kit. Honestly, it was one of the best technical decisions I have made. Not only did it eliminate external dependencies, but it also gave us a highly optimized, modular, and deeply integrated scanning experience.

Here is the comprehensive breakdown of how I designed, architected, and built a production-ready QR scanner in just 2 to 3 days.

Why I Built It from Scratch

It is always tempting to drop in a ready-made, open-source library and call it a day. However, when building for production—especially within an enterprise environment—relying on unverified third-party libraries introduces significant trade-offs:

  • Lack of UI Flexibility: Many legacy scanning libraries force you into a rigid, pre-built XML overlay. Modern applications require seamless UI integration, such as custom Jetpack Compose bounding boxes, animated scanning lines, and native material design handling.

  • Performance Bottlenecks: Off-the-shelf wrappers often lack optimization for modern camera APIs, leading to sluggish frame rates, excessive battery consumption, or slow barcode recognition.

  • Control Over Lifecycle and Hardware: Real-world usage requires precise handling of hardware variables, including low-light environments (torch control), tap-to-focus configurations, and strict adherence to lifecycle states to prevent memory leaks and application crashes.

By leveraging Google’s official Jetpack CameraX and ML Kit APIs directly, we gained full control over the execution pipeline while ensuring native, lightweight execution.

The Production Tech Stack

To ensure the scanning module remained maintainable, scalable, and testable, I utilized a modern Android development stack:

  • Language: Kotlin (leveraging structured concurrency via Coroutines and Flows)

  • UI Framework: Jetpack Compose (for a fully declarative, reactive user interface)

  • Camera Framework: CameraX (providing lifecycle-aware camera management)

  • ML Engine: Google ML Kit Barcode Scanning (offering optimized, on-device machine learning inference)

  • Architecture: MVVM + Clean Architecture principles

  • Dependency Injection: Hilt (for modular, decoupled dependency provision)

Architectural Blueprint

Before diving into the implementation, it is vital to understand how data flows through the application. The system relies on a decoupled, reactive pipeline where CameraX captures the physical environment frame by frame, processes the raw image stream via an analyzer, passes it to the ML Kit processing engine, and dispatches the decoded string back up to the UI layer through a clean, single source of truth.

Deep Dive: Connecting CameraX to ML Kit

The primary challenge when building a custom scanner is bridging the gap between raw hardware frames and the machine learning engine. CameraX provides an ImageAnalysis.Analyzer use case explicitly for this purpose.

The analyzer intercepts the camera stream, extracts individual frames as an ImageProxy, converts them into a format readable by ML Kit (InputImage), and processes them asynchronously.

Here is the production-grade implementation of the core analysis pipeline:

Kotlin

package com.example.qrscanner.analyzer

import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage

class QRAnalyzer(
    private val onQRDetected: (String) -> Unit,
    private val onFailure: (Exception) -> Unit
) : ImageAnalysis.Analyzer {

    // Thread-safe initialization of the client optimized for QR/Barcodes
    private val scanner = BarcodeScanning.getClient()

    @ExperimentalGetImage
    override fun analyze(imageProxy: ImageProxy) {
        // Extract the media.Image safely from the proxy wrapper
        val mediaImage = imageProxy.image
        if (mediaImage == null) {
            imageProxy.close()
            return
        }

        // Construct the input image wrapper incorporating current rotation properties
        val image = InputImage.fromMediaImage(
            mediaImage,
            imageProxy.imageInfo.rotationDegrees
        )

        scanner.process(image)
            .addOnSuccessListener { barcodes ->
                // Look for the first valid recognized barcode format
                val primaryBarcode = barcodes.firstOrNull()
                primaryBarcode?.rawValue?.let { qrValue ->
                    onQRDetected(qrValue)
                }
            }
            .addOnFailureListener { exception ->
                onFailure(exception)
            }
            .addOnCompleteListener {
                // CRITICAL: Always release the proxy frame to prevent camera pipeline freezing
                imageProxy.close()
            }
    }
}

Critical Implementation Takeaways:

  1. Image Close Lifecycle: The imageProxy.close() call within the addOnCompleteListener block is absolutely mandatory. CameraX maintains a fixed buffer pool of frames. If you fail to explicitly close an ImageProxy, the pipeline will stall, starving the system of frames and causing the camera preview to freeze permanently.

  2. Thread Concurrency: ML Kit performs inference asynchronously on an internal background thread pool, meaning it will not block the main UI thread during processing loops.

Implementing the Camera Preview in Jetpack Compose

Integrating an imperative, surface-driven API like CameraX inside a declarative UI framework like Jetpack Compose requires a bridging component. We achieve this by using AndroidView to host a traditional PreviewView, linking its lifecycle directly to the Composable composition tree.

Kotlin

@Composable
fun ScannerPreviewScreen(
    onQrCodeScanned: (String) -> Unit,
    viewModel: ScannerViewModel = hiltViewModel()
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }

    Box(modifier = Modifier.fillMaxSize()) {
        AndroidView(
            factory = { ctx ->
                PreviewView(ctx).apply {
                    scaleType = PreviewView.ScaleType.FILL_CENTER
                    implementationMode = PreviewView.ImplementationMode.PERFORMANCE
                }
            },
            modifier = Modifier.fillMaxSize(),
            update = { previewView ->
                cameraProviderFuture.addListener({
                    val cameraProvider = cameraProviderFuture.get()

                    // Configure the visual Preview use case
                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }

                    // Configure the background Analysis use case
                    val imageAnalysis = ImageAnalysis.Builder()
                        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                        .build()
                        .also { analysis ->
                            analysis.setAnalyzer(
                                ContextCompat.getMainExecutor(context),
                                QRAnalyzer(
                                    onQRDetected = { result ->
                                        onQrCodeScanned(result)
                                    },
                                    onFailure = { error ->
                                        Log.e("QRAnalyzer", "Inference error: ${error.message}")
                                    }
                                )
                            )
                        }

                    try {
                        // Ensure clean re-binding state by unbinding previous links
                        cameraProvider.unbindAll()

                        // Bind the lifecycle-aware components to the hosting view lifecycle
                        cameraProvider.bindToLifecycle(
                            lifecycleOwner,
                            CameraSelector.DEFAULT_BACK_CAMERA,
                            preview,
                            imageAnalysis
                        )
                    } catch (exc: Exception) {
                        Log.e("ScannerPreview", "Binding use cases failed", exc)
                    }
                }, ContextCompat.getMainExecutor(context))
            }
        )
        
        // Add custom overlay components (like target crosshairs or scanning animations) here
        ScannerOverlay()
    }
}

Architectural Integration & Best Practices

To fit an enterprise application model, this scanning component should not exist in isolation. Adhering to Clean Architecture principles allows for better separation of concerns:

├── domain
│   └── model
│       └── ScanResult.kt
├── data
│   └── repository
│       └── ScanRepositoryImpl.kt
└── presentation
    ├── ui
    │   └── ScannerPreviewScreen.kt
    └── viewmodel
        └── ScannerViewModel.kt

By decoupling scanning logic from data transformations, the business application layers remain agnostic of how data is obtained—whether from a live hardware stream, a local image file, or a hardware scanner accessory.

Key Technical Lessons Learned

Developing this module for a production app highlighted several essential edge cases:

1. Robust Lifecycle Management

Camera hardware is a shared, limited system resource. If an app fails to release camera hooks during background pausing, the operating system may force-terminate the process, or subsequent apps will fail to initialize camera frames. CameraX handles much of this naturally when bound to a LifecycleOwner, but you must configure your background workers cleanly to handle abrupt disruptions.

2. Backpressure Optimization

In the analysis configuration, setting ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST is essential for performance. If the ML inference engine takes longer to process a frame than the camera takes to deliver a new one, this strategy discards intermediate frames, keeping only the freshest image buffer. This prevents a backlog of stale frames, lowering memory footprints and keeping the UI perfectly responsive.

3. Handling Environmental Variables

Real-world hardware scans happen in sub-optimal environments—under poor warehouse lighting, on reflective surfaces, or at steep physical angles. Implementing tap-to-focus and manual torch controls via CameraX’s CameraControl utility significantly improves scanning success rates across varying field conditions.

Summary of Results

Building this engine from scratch delivered a highly performant, flexible module that met all our criteria:

  • Universal Decoding: Processes multiple barcode formats natively (QR, DataMatrix, Aztec, UPC, EAN, Code 128) completely on-device.

  • Zero License Friction: Eliminates commercial or copyleft third-party license compliance concerns.

  • Optimized Resource Footprint: Added negligible binary weight to our distributed APK file compared to heavy external cross-platform packages.

You can view the source patterns, complete UI configurations, and modular dependency setups on GitHub: 👉 ModernQRScanner

Frequently Asked Questions

Q: Is Google ML Kit completely free to run?

A: Yes, Google ML Kit’s Barcode Scanning API is entirely free for Android developers, with no monthly active user caps or cloud billing requirements.

Q: Does ML Kit require a live internet connection to parse images?

A: No. ML Kit’s scanning engine executes completely on-device via a local machine learning bundle, making it perfect for offline enterprise applications or remote usage.

Q: Why choose CameraX over the older Camera2 API framework?

A: Camera2 offers deep, granular hardware access but requires thousands of lines of boilerplate code to handle device-specific bugs and aspect ratios. CameraX abstractly handles device compatibility across 99% of Android devices while cleanly aligning with lifecycle architecture.

Q: Can this implementation parse multiple QR codes within the same frame simultaneously?

A: Yes. The list returned by barcodes in the success listener contains all detected barcodes within that image matrix frame. You can easily loop through the entire collection to handle batch scanning.

Internal Recommended Reads

Exit mobile version