CameraX Implementation Guide

Adding camera features without the Camera2 pain? Learn CameraX: setup and runtime permissions, the three use cases (Preview, ImageCapture, ImageAnalysis for ML/scanning), lifecycle binding in Compose, executor discipline, and the mistakes behind black screens and frozen previews.

Written by Projectech5 min readPublished
For B.E./B.Tech Computer Science and IT students adding camera features (scanning, capture, ML vision) to Android apps for academic and final-year projects Topics: Kotlin, CameraX, Android
Illustration of a smartphone camera viewfinder with CameraX use cases labeled: preview, image capture, and image analysis pipeline.
Illustration generated for this guide.
In this guide

Your project needs the camera — a QR attendance scanner, a plant-disease detector that photographs leaves, a document scanner, a security monitor. You open the Camera2 API documentation and find hundreds of lines of state-machine boilerplate: capture sessions, surfaces, request builders, device callbacks. Camera2 is powerful and painful. CameraX is Google's Jetpack wrapper that reduces the same features to a few dozen lines: it handles device quirks, lifecycle, and threading, and stays consistent across the fragmented Android device ecosystem.

This guide covers CameraX setup, the three use cases (Preview, ImageCapture, ImageAnalysis), permissions, lifecycle binding, and the mistakes that produce black screens and crashes.

CameraX vs Camera2: the tradeoff

Camera2 CameraX
Control Full manual control (exposure, focus, RAW) Sensible defaults, key overrides
Boilerplate 500+ lines for a basic preview ~50 lines
Device quirks You handle them CameraX handles them
Lifecycle Manual session management Automatic bind/unbind to lifecycle
Learning curve Steep Gentle

Use CameraX unless your project genuinely needs manual sensor control (custom exposure bracketing, RAW capture pipelines). For scanning, capture, and frame analysis — the student use cases — CameraX is the right choice and the one Google recommends.

Setup

Add the CameraX dependencies (check current versions in the docs — they move):

implementation("androidx.camera:camera-core:1.3.0")
implementation("androidx.camera:camera-camera2:1.3.0")
implementation("androidx.camera:camera-lifecycle:1.3.0")
implementation("androidx.camera:camera-view:1.3.0")

Declare the permission in the manifest:

<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />

And request it at runtime before touching the camera (CameraX throws without it):

val permissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }

LaunchedEffect(Unit) {
    permissionLauncher.launch(Manifest.permission.CAMERA)
}

Note: Request the permission before initializing the camera, and handle the denial path in the UI (show an explanation + a settings shortcut). A camera screen that silently shows black when permission is denied is a demo-day embarrassment.

The three use cases

CameraX structures everything around use cases — independent units you bind to the lifecycle:

1. Preview — show the viewfinder

val preview = Preview.Builder().build()
val previewView = remember { PreviewView(context) }
preview.setSurfaceProvider(previewView.surfaceProvider)

PreviewView (from camera-view) is the ready-made viewfinder — use it rather than building your own SurfaceView plumbing.

2. ImageCapture — take photos

val imageCapture = ImageCapture.Builder()
    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
    .build()

fun takePhoto() {
    val outputFile = File(outputDir, "photo_" + System.currentTimeMillis() + ".jpg")
    val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build()
    imageCapture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(context),
        object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                // photo saved at outputFile — update UI / upload
            }
            override fun onError(exc: ImageCaptureException) {
                // handle failure honestly — don't silently drop it
            }
        }
    )
}

3. ImageAnalysis — process frames (ML, scanning)

This is the use case for ML Kit barcode scanning, custom vision models, and frame processing:

val imageAnalysis = ImageAnalysis.Builder()
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()

imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
    // process imageProxy (a YUV frame) — e.g. run your model
    processFrame(imageProxy)
    imageProxy.close()  // MANDATORY — forgetting this stalls the pipeline
}

STRATEGY_KEEP_ONLY_LATEST drops frames when your analyzer is slower than the camera — right for real-time analysis where stale frames are worthless. And imageProxy.close() is the most-forgotten line in CameraX: every acquired frame must be closed or the pipeline stalls after a few frames and the preview freezes.

Binding to the lifecycle

The core CameraX call ties use cases to a lifecycle owner — when the Activity/Composable pauses, the camera releases automatically:

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
    val cameraProvider = cameraProviderFuture.get()
    val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
    try {
        cameraProvider.unbindAll()  // clear previous bindings
        cameraProvider.bindToLifecycle(
            lifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis
        )
    } catch (e: Exception) {
        // binding failed — log and show an error state
    }
}, ContextCompat.getMainExecutor(context))

In Compose, do this inside a LaunchedEffect (or AndroidView for the PreviewView) keyed on permission state, so the camera binds when permission is granted and releases when the composable leaves. Never bind in the composable body directly — it would re-bind on every recomposition.

Use-case combination rules: Preview + ImageCapture + ImageAnalysis can usually bind together, but some devices can't stream all three at full resolution simultaneously — if binding throws, drop to the combinations you actually need (preview + analysis for a scanner; preview + capture for a camera app).

Executor discipline

CameraX is threading-sensitive:

  • The analyzer executor should be a background thread (Executors.newSingleThreadExecutor()) — never analyze on the main thread.
  • Shut the executor down when done (cameraExecutor.shutdown()) — leaked executors keep threads alive and drain battery.
  • UI updates from callbacks must post back to the main thread.

Common mistakes

  • Forgetting imageProxy.close() — the #1 CameraX bug. Preview freezes after a handful of frames.
  • Binding in the composable body — re-binds every recomposition; bind in LaunchedEffect instead.
  • No permission-denied UI — black screen with no explanation.
  • Analyzing on the main thread — jank, ANRs, dropped frames.
  • Leaked executor — battery drain after leaving the camera screen.
  • Assuming all use-case combos work on all devices — guard bindToLifecycle with try/catch and degrade gracefully.
  • Ignoring rotation. Set the target rotation on use cases (setTargetRotation) or saved photos come out sideways — test on a real device, not just the emulator.
  • Forgetting unbindAll() before rebinding (e.g. switching front/back camera) — stale bindings throw.

Quick checklist

  • CameraX dependencies + CAMERA permission (manifest and runtime)
  • Permission-denied state handled in UI
  • Preview via PreviewView; capture and/or analysis use cases built
  • bindToLifecycle in an effect, with unbindAll() before rebinding
  • Analyzer on a background executor; every imageProxy closed
  • Executor shut down when the screen is disposed
  • Rotation handled; tested on a physical device

Where to go from here

More project guides

More in Android