Camera em app Android parece simples até o primeiro fluxo real: pedir permissão no momento certo, abrir o preview sem vazar Activity, capturar uma foto com rotação correta, pausar quando a tela sai de foco, analisar frames sem travar a UI e explicar para QA por que o emulador não reproduz o bug do aparelho físico. Em 2026, a forma mais segura de começar é combinar CameraX com Kotlin e Jetpack Compose.

CameraX é a camada Jetpack que reduz a diferença entre fabricantes, versões de Android e APIs antigas de câmera. Compose, por outro lado, não substitui tudo que existe no Android View system. O padrão prático é usar Compose para estado e layout, e embutir PreviewView dentro de AndroidView para renderizar o preview da câmera.

Este guia mostra uma arquitetura enxuta para app Android Kotlin com câmera: dependências, permissão, preview, captura de foto, análise de imagem, lifecycle, tratamento de erro e testes. Se você está montando um app maior, conecte este fluxo com permissões no Android com Kotlin, Android offline-first, segurança de dados locais e testes Android com Compose e Maestro.

Quando usar CameraX

Use CameraX quando o app precisa controlar câmera dentro da própria experiência: scanner de documento, leitura de QR Code, captura de recibo, validação de identidade, foto de perfil guiada, medição visual, inventário, vistoria, registro de entrega ou análise de imagem em tempo real.

Se o requisito é só “o usuário escolhe ou tira uma foto”, avalie primeiro ActivityResultContracts.TakePicture, Photo Picker ou um intent externo. Menos código costuma significar menos risco. CameraX vale a pena quando o preview, o enquadramento, a análise ou a experiência guiada fazem parte do produto.

Dependências base

Em um módulo Android com Gradle Kotlin DSL, uma base comum fica assim:

dependencies {
    implementation("androidx.camera:camera-core:1.4.1")
    implementation("androidx.camera:camera-camera2:1.4.1")
    implementation("androidx.camera:camera-lifecycle:1.4.1")
    implementation("androidx.camera:camera-view:1.4.1")

    implementation("androidx.activity:activity-compose:1.10.1")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
}

Use versões alinhadas ao seu Android Gradle Plugin e ao Compose BOM do projeto. Em times grandes, prefira centralizar essas versões em Version Catalog com Gradle Kotlin DSL para evitar cada módulo puxar uma versão diferente de CameraX.

No AndroidManifest.xml, declare apenas o necessário:

<uses-permission android:name="android.permission.CAMERA" />

Não peça microfone, localização ou armazenamento se o fluxo não usa esses recursos. Permissão extra piora confiança, revisão de loja e manutenção.

Permissão antes do preview

Não inicialize CameraX antes de saber que a permissão foi concedida. Em Compose, o fluxo costuma ficar em três estados: explicação, pedido do sistema e câmera ativa.

@Composable
fun CameraPermissionGate(
    onPermissionGranted: @Composable () -> Unit,
) {
    val context = LocalContext.current
    var granted by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.CAMERA,
            ) == PackageManager.PERMISSION_GRANTED,
        )
    }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { result ->
        granted = result
    }

    if (granted) {
        onPermissionGranted()
    } else {
        Column(Modifier.padding(24.dp)) {
            Text("Precisamos da câmera para enquadrar o documento antes da captura.")
            Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) {
                Text("Permitir câmera")
            }
        }
    }
}

A mensagem deve explicar a tarefa atual, não a tecnologia. “Precisamos da câmera para enquadrar o documento” é melhor que “o app requer permissão de câmera”. Para recusa permanente, mostre uma alternativa e um caminho para configurações, sem loop infinito de popup.

PreviewView dentro de Compose

CameraX ainda entrega o preview por PreviewView. Em Compose, use AndroidView e mantenha a inicialização em uma função separada. Isso evita transformar o composable em um bloco gigante de side effects.

@Composable
fun CameraPreview(
    modifier: Modifier = Modifier,
    onImageCaptureReady: (ImageCapture) -> Unit,
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { ctx ->
            PreviewView(ctx).also { previewView ->
                val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)

                cameraProviderFuture.addListener({
                    val cameraProvider = cameraProviderFuture.get()
                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }
                    val imageCapture = ImageCapture.Builder()
                        .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                        .build()

                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        CameraSelector.DEFAULT_BACK_CAMERA,
                        preview,
                        imageCapture,
                    )

                    onImageCaptureReady(imageCapture)
                }, ContextCompat.getMainExecutor(ctx))
            }
        },
    )
}

O ponto crítico é o lifecycle: bindToLifecycle faz CameraX pausar e retomar junto com a tela. Sem isso, você aumenta o risco de câmera presa, consumo de bateria e crash ao navegar.

Capture foto sem travar a UI

Guarde o ImageCapture em estado e dispare a captura a partir de um botão Compose. Salve em arquivo interno ou cache quando a imagem ainda não deve ir para a galeria.

@Composable
fun CameraScreen() {
    val context = LocalContext.current
    var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
    var saving by remember { mutableStateOf(false) }

    Box(Modifier.fillMaxSize()) {
        CameraPreview(onImageCaptureReady = { imageCapture = it })

        Button(
            enabled = imageCapture != null && !saving,
            onClick = {
                val capture = imageCapture ?: return@Button
                saving = true
                val file = File(context.cacheDir, "captura-${System.currentTimeMillis()}.jpg")
                val output = ImageCapture.OutputFileOptions.Builder(file).build()

                capture.takePicture(
                    output,
                    ContextCompat.getMainExecutor(context),
                    object : ImageCapture.OnImageSavedCallback {
                        override fun onImageSaved(result: ImageCapture.OutputFileResults) {
                            saving = false
                            // envie file.absolutePath para o ViewModel ou fluxo seguinte
                        }

                        override fun onError(exception: ImageCaptureException) {
                            saving = false
                            // registre o erro e mostre uma mensagem recuperável
                        }
                    },
                )
            },
            modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
        ) {
            Text(if (saving) "Salvando..." else "Capturar")
        }
    }
}

Em produção, mova a decisão de destino para uma camada própria. Foto de documento, selfie de identidade e imagem pública têm requisitos diferentes de retenção, criptografia, upload e exclusão.

Análise de imagem em tempo real

Para QR Code, OCR, ML Kit ou validação de enquadramento, adicione ImageAnalysis. O erro comum é analisar todo frame como se CPU fosse infinita. Use backpressure e libere a imagem sempre.

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

analysis.setAnalyzer(cameraExecutor) { imageProxy ->
    try {
        val rotation = imageProxy.imageInfo.rotationDegrees
        // converta ou envie para o analisador necessário
    } finally {
        imageProxy.close()
    }
}

Nunca esqueça imageProxy.close(). Um analyzer que não fecha frames rapidamente congela o pipeline da câmera. Para processamento pesado, use um executor dedicado, limite frequência e exponha resultado para a UI via StateFlow.

Estado de tela e erros

Evite espalhar Toast e flags soltas pelo composable. Um ViewModel pode modelar estados como:

data class CameraUiState(
    val permissionGranted: Boolean = false,
    val cameraReady: Boolean = false,
    val saving: Boolean = false,
    val lastImagePath: String? = null,
    val errorMessage: String? = null,
)

Isso facilita testes e deixa claro o que a tela deve renderizar. Também ajuda a integrar o fluxo com upload offline-first, retries, telemetria, Crashlytics e analytics.

Cuidados de produção

Antes de publicar um fluxo com câmera, revise esta lista:

  • peça a permissão apenas depois de uma ação do usuário;
  • mostre alternativa quando a permissão for negada;
  • teste rotação, app em background, volta da tela e troca de câmera;
  • trate aparelhos sem câmera traseira ou com câmera indisponível;
  • não salve imagem sensível em diretório público por padrão;
  • remova arquivos temporários que não serão usados;
  • registre falhas com contexto, sem guardar foto em log;
  • valide performance em aparelho intermediário, não só em emulador;
  • se usar ML Kit, faça fallback quando o modelo não estiver disponível;
  • documente quais imagens são enviadas para servidor e por quanto tempo ficam armazenadas.

Esses detalhes importam para produto e carreira. Vagas Android que mencionam Kotlin, Compose, segurança, banco de dados local e testes geralmente esperam que a pessoa saiba discutir lifecycle e privacidade, não apenas abrir a câmera.

Como testar CameraX

Teste unitário não deve depender da câmera real. Separe o que é regra de produto do que é integração com hardware. Por exemplo: validação de estado, regra de habilitar botão, destino do arquivo, parser do resultado e mapeamento de erro podem rodar na JVM.

Para UI, use Compose test em telas que não inicializam CameraX real ou injete um CameraController fake. Para fluxo completo, rode um teste manual guiado ou Maestro em aparelho físico quando o requisito envolver câmera. O emulador ajuda, mas não substitui diferença de fabricante, foco, rotação e performance.

Um smoke test manual mínimo deve cobrir:

  1. primeira abertura com permissão ainda não concedida;
  2. recusa simples;
  3. concessão de permissão;
  4. captura com rotação vertical e horizontal;
  5. navegação para fora e volta;
  6. app em background durante preview;
  7. arquivo temporário removido quando o fluxo é cancelado.

Caminho recomendado

Para um app novo, implemente nesta ordem:

  1. tela de explicação e permissão;
  2. preview com PreviewView e lifecycle;
  3. captura de foto para cache interno;
  4. estado em ViewModel;
  5. tratamento de erro e alternativa;
  6. análise de imagem se o produto realmente precisa;
  7. testes de regra e smoke test em aparelho físico.

Essa sequência reduz risco. Você valida primeiro se a câmera abre de forma confiável e só depois adiciona ML, upload, edição ou OCR.

Conclusão

CameraX com Compose e Kotlin é o caminho prático para câmera dentro do app Android moderno. Compose cuida da experiência e do estado; CameraX cuida da compatibilidade de hardware, preview, captura e análise. A qualidade vem de juntar os dois com lifecycle correto, permissão contextual, armazenamento cuidadoso e testes realistas.

Se o seu objetivo é crescer como dev Android, trate câmera como um fluxo de produto completo. Ele cruza UI, permissões, lifecycle, performance, segurança local, testes e arquitetura — exatamente os temas que diferenciam um app demo de um app pronto para produção.