Перейти к основному содержимому

Глава 24: CameraX

Резюме​

К моменту прочтения этой главы вы уже освоили «чистый» API Camera2: ручное открытие экземпляров CameraDevice, создание объектов CaptureRequest.Builder, управление жизненным циклом CameraCaptureSession, работу с тремя типами обратных вызовов и тщательное освобождение ресурсов в каждом крайнем случае. Вы заслужили свои «шрамы». Теперь мы сделаем шаг назад и спросим: что если бы 80% этого шаблонного кода можно было убрать?

CameraX — это библиотека Jetpack от Google, которая оборачивает Camera2 в декларативное, ориентированное на сценарии использования (use-case-driven) API, учитывающее жизненный цикл компонентов. Она не заменяет Camera2 — это и есть Camera2 «под капотом». Она заменяет сотни строк кода настройки сеанса, обработку особенностей конкретных устройств и ручной учет жизненного цикла. В этой главе вы изучите архитектуру CameraX, поймете модель UseCase, увидите, как внедрять «сырые» параметры Camera2 в CameraX через Camera2Interop, и получите таблицу для принятия решения о том, когда стоит использовать CameraX, а когда необходимо спуститься на уровень «чистого» Camera2.

Чтобы следовать материалу и проверять возможности камеры на своем устройстве перед выбором слоя для разработки, установите приложение Android Camera Parameters из Google Play или изучите исходный код на github.com/zoozooll/AndroidCameraParameters.


Архитектура CameraX​

CameraX поставляется в виде пяти артефактов Jetpack: camera-core, camera-camera2, camera-lifecycle, camera-view и camera-extensions. Архитектурной основой является модель UseCase — вместо того чтобы думать о поверхностях (surfaces) и сеансах, вы думаете о том, что именно вы хотите, чтобы камера делала.

Модель UseCase​

Существует четыре канонических сценария использования, и вы можете привязать любой их набор к жизненному циклу одновременно:

UseCaseНазначение
PreviewПередает поток кадров в PreviewView или на Surface. Аналогично настройке повторяющегося запроса к SurfaceTexture.
ImageAnalysisПередает кадры ImageProxy вашему анализатору в фоновом потоке. Заменяет ручное создание ImageReader с YUV_420_888 и подключение его слушателя к повторяющемуся запросу.
ImageCaptureОдиночный или серийный захват фото. Берет на себя запрос захвата, работу с ImageReader, поворот и EXIF.
VideoCaptureВключено в CameraX начиная с версии 1.1; оборачивает конвейер MediaRecorder или ParcelFileDescriptor с корректной семантикой паузы/возобновления и маршрутизацией звука.

Привязка всех четырех сценариев абсолютно законна — CameraX внутренне проверяет комбинацию потоков по SCALER_STREAM_CONFIGURATION_MAP и вызывает isSessionConfigurationSupported от вашего имени, переходя на меньшие разрешения, если ваша точная комбинация не поддерживается. Это одна из самых больших побед: вам больше никогда не придется тратить три часа, чтобы выяснить, что среднебюджетный Samsung 2019 года из вашей тестовой матрицы не поддерживает одновременно 4:3 PRIV + 16:9 JPEG_MAX. CameraX просто работает.

ProcessCameraProvider и учет жизненного цикла​

Точкой привязки является ProcessCameraProvider, синглтон, принадлежащий процессу вашего приложения. Ключевая строка выглядит так:

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val camera: Camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture,
imageAnalysis
)
}, ContextCompat.getMainExecutor(context))

И это всё. Никакого ада обратных вызовов openCamera, никакого StateCallback, никакой настройки сеанса и никакого ручного завершения работы. Когда lifecycleOwner (ваш Fragment или Activity) достигает состояния ON_STOP, CameraX закрывает CameraDevice. При ON_DESTROY она завершает сеанс и освобождает все поверхности. Утечки ресурсов, на которые вы охотились в главе 7, просто не могут произойти — контракт жизненного цикла это гарантирует.

CameraX внутренне оборачивает Camera2​

Внутренне CameraX — это Camera2. Артефакт camera-camera2 содержит Camera2Camera, Camera2CameraCaptureResult и Camera2RequestProcessor, которые транслируют ваши высокоуровневые объявления UseCase в те же самые вызовы CameraManager.openCamera, createCaptureSession и setRepeatingRequest, которые вы писали вручную в предыдущих 23 главах. Обходные пути для конкретных производителей закодированы в XML-файлах внутри библиотеки — это знаменитая «база данных особенностей CameraX» (quirk database).

Полная архитектура выглядит так:

Следуйте по стрелкам слева направо: ваше приложение заявляет, что оно хочет (use cases), CameraX определяет, как это получить (размеры поверхностей, конфиг сеанса, особенности), а затем выполняет те же вызовы Camera2, которые написали бы вы сами. Добавленная стоимость здесь — два средних блока, сотни тысяч строк написанного Google кода для совместимости с устройствами, который вам не нужно писать.


Camera2Interop: Внедрение параметров Camera2 в CameraX​

CameraX идеальна в 80% случаев. Но вы, дорогой читатель, мастер Camera2. Вы знаете, что означает CONTROL_AE_MODE_OFF. Вы понимаете разницу между SENSOR_SENSITIVITY и CONTROL_AE_EXPOSURE_COMPENSATION. Когда спецификация продукта говорит: «дайте пользователю заблокировать ISO на 400 и выдержку на 1/60 с даже при использовании CameraX», вы не переписываете всю функцию на чистом Camera2. Вы используете Camera2Interop.

Паттерн Extender​

У каждого UseCase.Builder есть соответствующий Camera2Interop.Extender. Вызовите его перед build(), чтобы внедрить «сырые» ключи Camera2 на уровне сеанса или на уровне каждого запроса:

МетодЭквивалент в Camera2
extender.setCaptureRequestOption(key, value)CaptureRequest.Builder.set(key, value)
extender.setSessionOption(key, value)Параметры инициализации сеанса (реже исп.)

Extender является аддитивным: CameraX по-прежнему устанавливает свои значения по умолчанию для каждого ключа, который вы не переопределяете. Если вы установите только SENSOR_SENSITIVITY, CameraX все равно будет управлять AF, AWB, поворотом и метаданными.

Пример из практики: Ручное ISO и выдержка в CameraX​

Ниже приведен полный билдер ImageCapture, который переводит камеру в ручной режим AE с фиксированным ISO 400 и выдержкой 1/60 секунды, а затем делает снимок:

val iso = 400
val exposureTimeNanos = 1_000_000_000L / 60 // 1/60 с

val imageCapture = ImageCapture.Builder()
.setTargetResolution(Size(4032, 3024))
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.apply {
val interop = Camera2Interop.Extender(this)
interop.setCaptureRequestOption(
CaptureRequest.CONTROL_AE_MODE,
CaptureRequest.CONTROL_AE_MODE_OFF
)
interop.setCaptureRequestOption(
CaptureRequest.CONTROL_AE_LOCK,
false
)
interop.setCaptureRequestOption(
CaptureRequest.SENSOR_SENSITIVITY,
iso
)
interop.setCaptureRequestOption(
CaptureRequest.SENSOR_EXPOSURE_TIME,
exposureTimeNanos
)
interop.setCaptureRequestOption(
CaptureRequest.CONTROL_MODE,
CaptureRequest.CONTROL_MODE_OFF
)
}
.build()

cameraProvider.bindToLifecycle(
viewLifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageCapture
)

// Позже, запуск съемки:
imageCapture.takePicture(
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
// imageProxy содержит кадр с ручной экспозицией
imageProxy.close()
}

override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "Съемка не удалась: ${exception.imageCaptureError}", exception)
}
}
)

Важное предостережение: установка CONTROL_MODE_OFF отключает всю автоматику 3A. Если вы хотите только заблокировать экспозицию, но оставить работу AF и AWB, устанавливайте только CONTROL_AE_MODE_OFF (или CONTROL_AE_LOCK = true) и оставьте CONTROL_MODE по умолчанию (CONTROL_MODE_AUTO). CameraX сама выставит значения для всех ключей, которые вы не трогаете.

И да — вы можете сделать то же самое с Preview.Builder и ImageAnalysis.Builder для повторяющихся потоков с ручным управлением. Extender применяется к каждому повторяющемуся или одиночному запросу, выдаваемому на протяжении всего времени жизни этого UseCase.

Чтение результатов Camera2 обратно​

Извлечение TotalCaptureResult из обратного вызова CameraX выполняется так же просто через Camera2CameraCaptureResult:

imageCapture.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
val camera2Result = imageProxy.imageInfo
.cameraCaptureResult as? Camera2CameraCaptureResult
val totalResult: TotalCaptureResult? = camera2Result?.captureResult
val actualIso = totalResult?.get(CaptureResult.SENSOR_SENSITIVITY)
Log.d(TAG, "Фактическое ISO на сенсоре: $actualIso")
imageProxy.close()
}
}
)

Это позволяет убедиться, что ваши внедренные параметры действительно дошли до сенсора. Используйте Android Camera Parameters, чтобы проверить, какой SENSOR_INFO_SENSITIVITY_RANGE заявляет ваше устройство — если ваше внедренное значение ISO выходит за рамки этого диапазона, CameraX молча ограничит его (или это сделает HAL), и чтение результата будет единственным способом это узнать.


Выбор между CameraX и Camera2​

Самый сложный архитектурный вопрос — не «как использовать CameraX?», а «стоит ли вообще использовать CameraX?». Вот схема принятия решения, дистиллированная из реального опыта производства.

Схема принятия решения​

Таблица принятия решений​

СценарийCameraXЧистый Camera2
Предпросмотр в стиле Instagram + фото по тапу + видео✅⛔
Сканирование QR / штрихкодов / детекция лиц через ML Kit без кастомизации✅⛔
Ручная экспозиция с фикс. ISO + затвор (через Camera2Interop)✅⚠️
Кастомный конечный автомат 3A, переопределяющий алгоритмы OEM⛔✅
Профессиональное фото в RAW_SENSOR / RAW_PRIVATE / DNG⛔✅
Zero Shutter Lag (глава 23) с входными поверхностями переобработки⛔✅
Доступ к физическим потокам логической мультикамеры (глава 20)⛔✅
Скоростная съемка 120/240fps через constrained-high-speed-sessions⛔✅
Расширения камеры (Ночь / Боке / HDR) через OEM расширения✅✅
Автомобильная камера заднего вида с миграцией EVS при ранней загрузке⛔✅ (NDK)
Кросс-платформенная совместимость — приоритет №1✅⚠️

«Серая зона» (⚠️) — это место, где важна оценка. Ручное управление экспозицией через Camera2Interop надежно работает на устройствах уровня HARDWARE_LEVEL_FULL, но молча не срабатывает на устройствах LEGACY, потому что HAL LEGACY полностью игнорирует CONTROL_MODE_OFF. Запустите Android Camera Parameters на вашем тестовом парке, проверьте INFO_SUPPORTED_HARDWARE_LEVEL для каждого устройства, и если 20% вашего парка — это LEGACY, либо переходите на чистый Camera2 с путем отката, либо смиритесь с тем, что ручное управление не будет работать на этих устройствах.

Базовая настройка CameraX Preview + ImageCapture (полная)​

Для справки приведена полная минимальная настройка, которая заменяет ~300 строк чистого кода Camera2, написанного вами в главах 6–9.

class CameraXFragment : Fragment() {

private lateinit var viewBinding: FragmentCameraXBinding
private lateinit var imageCapture: ImageCapture

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewBinding = FragmentCameraXBinding.bind(view)

val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
bindCameraUseCases(cameraProvider)
}, ContextCompat.getMainExecutor(requireContext()))
}

private fun bindCameraUseCases(cameraProvider: ProcessCameraProvider) {
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(viewBinding.previewView.surfaceProvider)
}

imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.setTargetRotation(viewBinding.previewView.display.rotation)
.build()

val cameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()

cameraProvider.unbindAll()
try {
cameraProvider.bindToLifecycle(
viewLifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (exc: Exception) {
Log.e(TAG, "Ошибка привязки UseCase", exc)
}
}

private fun takePhoto() {
val photoFile = File(
outputDirectory,
"PHOTO_${System.currentTimeMillis()}.jpg"
)
val outputOptions = ImageCapture.OutputFileOptions
.Builder(photoFile)
.build()

imageCapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(requireContext()),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
val savedUri = Uri.fromFile(photoFile)
Toast.makeText(requireContext(),
"Сохранено: $savedUri", Toast.LENGTH_SHORT).show()
}
override fun onError(exc: ImageCaptureException) {
Log.e(TAG, "Ошибка захвата фото: ${exc.message}", exc)
}
}
)
}

companion object {
private const val TAG = "CameraXFragment"
}
}

Это весь конвейер предпросмотра и фото. Обратите внимание на полное отсутствие HandlerThread, CameraDevice.StateCallback, CameraCaptureSession.StateCallback, ImageReader.OnImageAvailableListener или ручных вызовов close(). CameraX берет всё это на себя.


Резюме​

CameraX — это Camera2 с фасадом, учитывающим жизненный цикл и ориентированным на сценарии использования, подкрепленным базой данных особенностей устройств от Google. Архитектура выстраивает цепочку: ваше приложение → UseCases → CameraX Core → Camera2 → HAL, а вызов ProcessCameraProvider.bindToLifecycle() заменяет сотни строк ручной настройки. Для 20% параметров, которые CameraX не предоставляет на уровне UseCase, Camera2Interop.Extender внедряет «сырые» ключи CaptureRequest и считывает «сырые» значения TotalCaptureResult. Решение о том, когда использовать эту библиотеку, простое: CameraX является выбором по умолчанию, если только ваша функция не требует RAW, ZSL, физических потоков мультикамеры, скоростного видео или кастомной топологии сеанса, которую селектор CameraX не может выразить.

Что дальше​

CameraX — это все еще код на Java/Kotlin Dalvik/ART, находящийся над границей Binder. Что если даже эти накладные расходы слишком велики для 16-миллисекундного бюджета кадра вашего AR-движка? В главе 25 мы перейдем границу JNI и откроем камеру напрямую из C++, используя нативный стек камер NDK, привязывая кадры как текстуры Vulkan с нулевым копированием.