CameraX в 2026 году: как привязать Preview и ImageAnalysis, не утекая сессией камеры
Практическое руководство по CameraX на Android: привязка Preview и ImageAnalysis к жизненному циклу, правильная стратегия backpressure и краш, который вызывает поворот экрана.
Два моих приложения наводят камеру на что-то и ждут ответа меньше чем за секунду: Stocky сканирует штрихкод, Subly читает счёт. Модели зрения за обоими — сканер штрихкодов ML Kit и его распознаватель текста — разобраны в отдельных статьях. Эта — про слой под ними обоими: CameraX, часть, которая должна оставаться открытой ровно столько, сколько жив экран с превью, и ни одним кадром дольше.
CameraX легко запустить и легко использовать с незаметной ошибкой. Демо-код привязывает превью и анализатор в несколько строк и работает с первого запуска. Баги проявляются позже — при повороте экрана, при быстром переключении между экранами, на телефоне, который ставит приложение на паузу посреди сканирования. Вот что на самом деле нужно сделать правильно.
Привязка к жизненному циклу, а не к activity
Причина использовать CameraX вместо голого Camera2 — не поверхность API, а bindToLifecycle. Передайте ему LifecycleOwner вместе с вашими use case’ами, и CameraX запустит камеру, когда этот owner достигнет состояния STARTED, и разберёт её, когда перестанет — никакой ручной проводки onPause/onResume, которую легко сделать неправильно:
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
cameraProvider.unbindAll()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(analysisExecutor, ::analyzeFrame) }
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
}, ContextCompat.getMainExecutor(context))
Этот unbindAll() перед привязкой — не защитный код “на всякий случай”, а исправление самого частого краша CameraX. ProcessCameraProvider.getInstance() возвращает ListenableFuture, поэтому привязка происходит асинхронно, внутри listener’а. Если экран пересоздаётся (поворот, изменение конфигурации, возврат на экран сканирования, который уже был привязан) до того, как старая сессия была аккуратно освобождена, вы получаете два набора use case’ов, borющихся за одну камеру, либо IllegalStateException, сообщающий, что use case уже привязан к другому жизненному циклу. Вызов unbindAll() первым делает каждую привязку идемпотентной: неважно, сколько раз вы прошли через onResume.
Превью и анализ — два потребителя одних и тех же кадров
Preview и ImageAnalysis — это не этапы конвейера, а два независимых use case’а, которые камера питает одновременно. Превью показывает пользователю живой поток на PreviewView; анализатор получает свой собственный ImageProxy на каждый кадр, чтобы запускать на нём инференс. Привязка обоих вместе — это то, что превращает “камеру с живым оверлеем сканирования” в один вызов вместо двух камер. Это также означает, что скорость анализатора не влияет на то, что пользователь видит на экране — медленная модель не делает превью дёрганым, и это важнее, чем кажется на слух, для того, насколько “отзывчивым” ощущается сканер.
Стратегия backpressure — не второстепенная настройка
В более новых версиях CameraX ImageAnalysis по умолчанию использует STRATEGY_KEEP_ONLY_LATEST, но стоит задать её явно, потому что альтернатива — STRATEGY_BLOCK_PRODUCER — ставит кадры в очередь, когда анализатор не успевает, а модель штрихкода или OCR редко успевает за камерой на 30 кадров в секунду. KEEP_ONLY_LATEST отбрасывает все кадры, кроме самого нового, пока анализатор ещё занят — это именно то поведение, которое нужно: пользователь видит живое превью и получает результат по тому кадру, который модель успела рассмотреть, а не по кадрам, отставшим на секунду из-за того, что конвейер не успевает.
Закрывайте каждый ImageProxy, иначе поток молча остановится
Это баг, который сложнее всего заметить при тестировании, потому что он не приводит к краху — он просто останавливается. ImageAnalysis не доставит новый кадр анализатору, пока не закрыт предыдущий ImageProxy, даже при KEEP_ONLY_LATEST. Ранний return при пустом изображении, исключение, брошенное до вашего finally, или поиск, который выполняется асинхронно и закрывает proxy только в своём колбэке — любой из этих случаев может оставить один ImageProxy открытым навсегда, и начиная с этого кадра анализатор просто замолкает. Ни ошибки, ни лога — просто сканер, который перестал сканировать:
fun analyzeFrame(image: ImageProxy) {
try {
val media = image.image ?: return
val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
scanner.process(input).addOnSuccessListener { /* handle result */ }
} finally {
image.close()
}
}
Помещайте закрытие в finally, а не в конец счастливого пути. Это единственная строка во всей настройке, которую легко пропустить и дорого потом отлаживать.
Проверяйте разрешение перед привязкой, а не внутри колбэка
CameraX не запрашивает разрешение на камеру за вас и не завершается изящно, если вы привязываетесь без него — bindToLifecycle выбрасывает SecurityException, который обрушит экран, если CAMERA ещё не выдано. Решение — в порядке действий: проверьте ContextCompat.checkSelfPermission и пройдите ActivityResultContracts.RequestPermission до того, как сработает listener ProcessCameraProvider, а не внутри него. Проверка разрешения, которая срабатывает только после того, как пользователь уже один раз отказал, или которая соревнуется с асинхронным future провайдера, — вот как получается отчёт о краше, который воспроизводится только на свежей установке.
Итог
CameraX оправдывает своё существование по сравнению с голым Camera2 ровно одним способом: он превращает “управление жизненным циклом камеры” в решённую задачу — при условии, что вы действительно позволяете ему владеть этим жизненным циклом. Привязывайтесь через LifecycleOwner, вызывайте unbindAll() перед каждой привязкой, чтобы поворот экрана и навигация никогда не могли привязать use case дважды, задавайте стратегию backpressure явно, а не полагаясь на значение по умолчанию, и закрывайте каждый ImageProxy в finally. Сделайте эти четыре вещи правильно — и камера станет скучной. А для функции, которая должна работать каждый раз, когда пользователь наводит телефон на штрихкод или счёт, это именно то, чем она и должна быть.
// По теме
Ещё из журнала
androidx.startup в 2026 году: порядок инициализации библиотек без стопки ContentProvider'ов
Практическое руководство по библиотеке App Startup для Android — объединение initializer'ов в один ContentProvider, объявление зависимостей и случаи ленивой инициализации, которые она не заменяет.
API обновлений внутри приложения Android в 2026 году: гибкое или немедленное, и когда его форсировать
Практическое руководство по In-App Update API от Google: гибкий и немедленный сценарии, приоритет обновления, дни устаревания и проверка возобновления, о которой все забывают.
Android Photo Picker API в 2026 году: прикрепить фото без доступа ко всей галерее
Практическое руководство по Photo Picker API в Android — одиночный и множественный выбор, фильтрация по MIME, бэкпорт для версий до Android 13 и почему это лучше READ_MEDIA_IMAGES.