
1. 项目概述为什么需要同时操作多个摄像头在Android应用开发中处理单个摄像头已经是常规操作但当你需要同时预览前后摄像头、实现双目视觉、或者接入红外等特殊摄像头时事情就变得复杂起来。我最初接到类似需求是为了一个需要实时人脸比对和活体检测的金融类App。客户要求前置摄像头捕捉用户面部同时后置摄像头扫描身份证件确保“人证合一”。听起来简单但Android系统对摄像头资源的独占性管理让同时打开多个Camera实例成了第一个拦路虎。这不仅仅是金融场景的需求。在工业检测中你可能需要多角度同时监控一个零件在社交或直播应用里画中画、前后摄像头同屏展示能极大增强互动性在安防领域多路视频流同时分析更是基础。因此掌握多摄像头并发处理技术是突破许多创新功能瓶颈的关键。这个项目要解决的就是如何在Android上以最简洁、稳定的方式实现多个摄像头的同步打开、预览、控制缩放、对焦以及图像获取截图并处理好旋转、镜像等显示细节。我会避开那些庞大臃肿的第三方库带你从Android原生的Camera2 API入手构建一个清晰、可复用的多摄像头管理框架。2. 核心架构设计与Camera2 API选型2.1 为何放弃Camera1与第三方库在Android相机开发中我们主要有Camera1已废弃但仍有使用、Camera2Android 5.0以及诸如Google的CameraX等封装库。对于多摄像头并发这种相对底层的需求我的选择是Camera2 API。Camera1 API设计陈旧它通过Camera.open(cameraId)来获取相机实例但系统对相机服务的全局锁管理使得几乎不可能稳定地同时打开两个以上的摄像头实例即使有些设备通过Hack方式实现也充满了兼容性陷阱和不可预知的崩溃。而Camera2 API采用了全新的、基于管道的异步模型将相机抽象为一个可以输出图像流的设备通过CameraManager来管理。这种设计本身就更适合并发操作允许我们为不同的摄像头创建独立的CameraCaptureSession。为什么不直接用CameraXCameraX确实极大地简化了生命周期管理和基础用例如预览、拍照、分析。然而在撰写本文时CameraX对多摄像头并发预览的原生支持仍处于实验阶段ExperimentalMultipleCameras且其抽象层级较高当我们需要精细控制每个摄像头的对焦模式、缩放比例、输出图像格式如处理红外摄像头YUV数据时可能会感到束手束脚。从学习和掌控的角度出发直接使用Camera2 API能让你透彻理解背后的机制一旦掌握便能灵活应对各种复杂场景。2.2 多摄像头管理核心思路我们的目标是构建一个MultiCameraManager其核心职责是枚举与筛选获取设备上所有可用的摄像头并根据特性前后置、红外、逻辑摄像头进行筛选和配对。资源隔离与分配为每个需要打开的摄像头独立分配资源包括CameraDevice、CaptureRequest.Builder、Surface用于预览和ImageReader用于截图/分析。会话并行管理为每个摄像头创建独立的CameraCaptureSession避免会话间的命令冲突。线程安全与同步确保所有回调CameraDevice.StateCallbackCameraCaptureSession.StateCallback在正确的后台线程执行并妥善处理生命周期如Activity暂停时释放所有资源。一个关键点是理解“逻辑摄像头”。在高端机型上系统可能将多个物理摄像头例如主摄、超广角、长焦虚拟成一个逻辑摄像头通过软件切换来提供“无损变焦”等功能。在我们的多摄像头并发场景中通常需要绕过逻辑摄像头直接打开特定的物理摄像头ID。这需要通过CameraCharacteristics来检查LENS_FACING前后置和SCALER_STREAM_CONFIGURATION_MAP支持的输出格式和分辨率。3. 实战一步步构建多摄像头预览3.1 环境准备与权限处理首先确保你的AndroidManifest.xml中声明了必要的权限uses-permission android:nameandroid.permission.CAMERA / !-- 如果需要在外部存储保存截图 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / !-- 针对Android 10以下 -- uses-feature android:nameandroid.hardware.camera android:requiredfalse / uses-feature android:nameandroid.hardware.camera.autofocus android:requiredfalse/注意从Android 6.0 (API 23)开始需要在运行时动态申请CAMERA权限。对于多摄像头你只需要申请一次相机权限该权限即涵盖对所有摄像头资源的访问。一个健壮的权限申请和摄像头枚举流程如下// 在Activity/Fragment中 private lateinit var cameraManager: CameraManager private val cameraIdList mutableListOfString() private val cameraCharacteristicsMap mutableMapOfString, CameraCharacteristics() private fun setupCameras() { cameraManager getSystemService(Context.CAMERA_SERVICE) as CameraManager try { // 1. 获取所有摄像头ID val ids cameraManager.cameraIdList for (id in ids) { val characteristics cameraManager.getCameraCharacteristics(id) cameraCharacteristicsMap[id] characteristics // 2. 筛选出我们需要的摄像头例如前后置各一个 val lensFacing characteristics.get(CameraCharacteristics.LENS_FACING) // 根据需求筛选例如找前置和后置 if (lensFacing CameraCharacteristics.LENS_FACING_FRONT) { frontCameraId id } else if (lensFacing CameraCharacteristics.LENS_FACING_BACK) { backCameraId id } cameraIdList.add(id) } // 3. 检查设备是否支持多摄像头并发非必须但可做友好提示 val concurrentCameraIds cameraManager.concurrentCameraIds Log.d(TAG, 支持并发的摄像头组合: $concurrentCameraIds) } catch (e: CameraAccessException) { Log.e(TAG, 访问摄像头信息失败, e) } }注意concurrentCameraIds是Android 11 (API 30)引入的用于查询系统明确支持同时打开的摄像头组合。在低版本设备上即使系统未明确报告我们仍可以尝试打开多个摄像头但失败率会更高必须做好异常处理。3.2 创建独立的预览Surface与CaptureSession这是最核心的一步。我们需要为每个摄像头准备一个Surface供其渲染画面。通常这个Surface来自TextureView或SurfaceView。为了实现同屏预览我们可以在布局中放置两个TextureView。// 假设我们有两个TextureViewtextureViewFront, textureViewBack private fun openCamera(cameraId: String, textureView: TextureView) { // 确保在后台线程操作 val backgroundHandler HandlerThread(CameraBackground_$cameraId).apply { start() } val handler Handler(backgroundHandler.looper) try { // 1. 打开摄像头设备 cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { // 保存device引用例如放入Map: cameraDevicesMap[cameraId] device cameraDevicesMap[cameraId] device // 2. 创建预览请求构建器 val previewRequestBuilder device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { // 3. 从TextureView获取Surface val surface Surface(textureView.surfaceTexture.apply { setDefaultBufferSize(previewSize.width, previewSize.height) // 需要先计算合适的预览尺寸 }) addTarget(surface) // 将此Surface作为输出目标 // 设置预览参数如自动对焦、自动曝光 set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH) } // 4. 创建CaptureSession仅使用这一个Surface device.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { // 保存session引用 captureSessionsMap[cameraId] session // 5. 设置重复请求开始预览 session.setRepeatingRequest(previewRequestBuilder.build(), null, handler) } override fun onConfigureFailed(session: CameraCaptureSession) { Log.e(TAG, 摄像头 $cameraId 的CaptureSession创建失败) } }, handler) } override fun onDisconnected(device: CameraDevice) { /* 清理资源 */ } override fun onError(device: CameraDevice, error: Int) { /* 处理错误 */ } }, handler) } catch (e: CameraAccessException) { Log.e(TAG, 打开摄像头 $cameraId 失败, e) } }关键点解析独立线程每个摄像头最好拥有自己独立的HandlerThread和Handler避免回调阻塞。线程名最好带上cameraId以便调试。Session独立每个CameraDevice创建自己独立的CaptureSession。这是实现并发预览的关键确保两个摄像头的图像流处理管道互不干扰。Surface绑定Surface来自TextureView我们需要先为其设置一个合适的缓冲区大小setDefaultBufferSize这个大小应该从摄像头支持的预览尺寸列表中选取一个与TextureView比例最匹配、分辨率适中的尺寸。3.3 计算与选择最佳预览尺寸摄像头支持的输出尺寸千差万别选择一个合适的预览尺寸至关重要它影响性能、功耗和预览质量。我们需要从StreamConfigurationMap中获取支持的尺寸并筛选。private fun chooseOptimalPreviewSize(cameraId: String, textureView: TextureView): Size { val characteristics cameraCharacteristicsMap[cameraId]!! val map characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! // 获取摄像头支持的所有输出尺寸针对SurfaceTexture类即预览 val supportedSizes map.getOutputSizes(SurfaceTexture::class.java) val displayRotation windowManager.defaultDisplay.rotation val isPortrait displayRotation Surface.ROTATION_0 || displayRotation Surface.ROTATION_180 val viewWidth if (isPortrait) textureView.height else textureView.width val viewHeight if (isPortrait) textureView.width else textureView.height val aspectRatio viewWidth.toFloat() / viewHeight.toFloat() // 筛选逻辑1. 比例匹配2. 分辨率不超过1080p兼顾性能与清晰度3. 选择最大可用尺寸。 var optimalSize supportedSizes[0] for (size in supportedSizes) { // 有些设备返回的尺寸可能为null或0 if (size.width 0 || size.height 0) continue // 计算当前尺寸的比例需要考虑传感器方向这里简化处理 val ratio size.width.toFloat() / size.height // 允许比例有微小误差例如0.01 if (Math.abs(ratio - aspectRatio) 0.01) continue // 限制最大分辨率 if (size.width 1920 || size.height 1920) continue // 选择面积最大的一个 if (size.width * size.height optimalSize.width * optimalSize.height) { optimalSize size } } Log.i(TAG, 摄像头 $cameraId 选择的预览尺寸: ${optimalSize.width}x${optimalSize.height}) return optimalSize }实操心得不要盲目选择最高分辨率。对于预览而言1080p1920x1080通常是性能与清晰度的最佳平衡点。过高的分辨率会加重GPU渲染负担增加功耗对后续的图像分析如人脸识别也没有额外好处反而可能因为需要降采样而增加延迟。4. 高级功能实现控制、截图与显示处理4.1 独立控制缩放与对焦每个摄像头的控制缩放、对焦、曝光都是通过更新其对应的CaptureRequest并提交给CaptureSession来实现的。关键在于操作独立的CaptureRequest.Builder。数码变焦Zoomfun setZoom(cameraId: String, zoomRatio: Float) { val device cameraDevicesMap[cameraId] ?: return val characteristics cameraCharacteristicsMap[cameraId] ?: return val session captureSessionsMap[cameraId] ?: return // 1. 检查摄像头是否支持缩放 val zoomRange characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) val minZoom zoomRange?.lower ?: 1.0f val maxZoom zoomRange?.upper ?: 1.0f val clampedZoom zoomRatio.coerceIn(minZoom, maxZoom) // 2. 更新该摄像头的预览请求构建器 val previewBuilder previewRequestBuildersMap[cameraId] ?: return previewBuilder.set(CaptureRequest.CONTROL_ZOOM_RATIO, clampedZoom) // 3. 提交新的请求 session.setRepeatingRequest(previewBuilder.build(), null, null) }触摸对焦fun handleTouchToFocus(cameraId: String, x: Float, y: Float, textureView: TextureView) { val characteristics cameraCharacteristicsMap[cameraId] ?: return val session captureSessionsMap[cameraId] ?: return val previewBuilder previewRequestBuildersMap[cameraId] ?: return // 1. 将触摸点坐标转换为传感器坐标系下的MeteringRectangle val sensorArraySize characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)!! val meteringRect calculateMeteringRectangle(x, y, sensorArraySize, textureView) // 2. 创建对焦请求 previewBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL) previewBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) val focusMetering MeteringRectangle(meteringRect, MeteringRectangle.METERING_WEIGHT_MAX) previewBuilder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(focusMetering)) previewBuilder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(focusMetering)) // 3. 先提交一个单次请求触发对焦 session.capture(previewBuilder.build(), null, null) // 4. 再重新设置触发器为IDLE并恢复连续对焦的预览 previewBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE) session.setRepeatingRequest(previewBuilder.build(), null, null) }4.2 高质量截图保存使用ImageReader来捕获高质量的静态图片。每个摄像头可以关联一个独立的ImageReader。// 在打开摄像头时同时创建ImageReader private fun prepareImageReader(cameraId: String): ImageReader { val characteristics cameraCharacteristicsMap[cameraId]!! val map characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! // 选择最大的JPEG输出尺寸用于拍照 val largestJpegSize map.getOutputSizes(ImageFormat.JPEG).maxByOrNull { it.width * it.height }!! // 创建ImageReader最多缓冲2张图像 val imageReader ImageReader.newInstance(largestJpegSize.width, largestJpegSize.height, ImageFormat.JPEG, 2) imageReader.setOnImageAvailableListener({ reader - // 在后台线程处理图像 val image reader.acquireLatestImage() image?.use { val buffer it.planes[0].buffer val bytes ByteArray(buffer.remaining()) buffer.get(bytes) // 保存bytes到文件需在后台线程执行IO saveImageToFile(bytes, cameraId) } }, backgroundHandlerMap[cameraId]) // 在创建CaptureSession时将这个ImageReader的Surface也加入目标列表 // val surfaces listOf(previewSurface, imageReader.surface) // device.createCaptureSession(surfaces, ...) return imageReader } // 触发截图 fun captureStillPicture(cameraId: String) { val session captureSessionsMap[cameraId] ?: return val device cameraDevicesMap[cameraId] ?: return val previewBuilder previewRequestBuildersMap[cameraId] ?: return // 1. 停止预览防止画面变化 session.stopRepeating() // 2. 创建专门的拍照请求 val captureBuilder device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(imageReaderMap[cameraId]?.surface!!) // 指向ImageReader的Surface // 继承预览的部分设置如对焦模式 set(CaptureRequest.CONTROL_AF_MODE, previewBuilder.get(CaptureRequest.CONTROL_AF_MODE)) // 设置JPEG方向、质量等 set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation(cameraId)) set(CaptureRequest.JPEG_QUALITY, 95.toByte()) } // 3. 执行拍照 session.capture(captureBuilder.build(), null, null) // 4. 拍照完成后恢复预览 session.setRepeatingRequest(previewBuilder.build(), null, null) }注意事项ImageReader的回调onImageAvailable可能在任意线程被调用务必在其中进行快速的图像获取操作然后将耗时的保存、处理工作抛到另一个工作线程避免阻塞相机流水线。4.3 处理旋转、镜像与画面适配摄像头传感器的方向、设备自然方向、UI方向三者不一致时会导致预览画面旋转。此外前置摄像头通常需要做水平镜像。计算JPEG方向private fun getJpegOrientation(cameraId: String): Int { val characteristics cameraCharacteristicsMap[cameraId]!! val sensorOrientation characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 val deviceRotation windowManager.defaultDisplay.rotation return (sensorOrientation ORIENTATIONS.get(deviceRotation) 360) % 360 } companion object { private val ORIENTATIONS SparseIntArray().apply { append(Surface.ROTATION_0, 90) append(Surface.ROTATION_90, 0) append(Surface.ROTATION_180, 270) append(Surface.ROTATION_270, 180) } }TextureView的变换矩阵 为了在预览时正确显示我们需要对TextureView应用变换矩阵。这通常在TextureView.SurfaceTextureListener的onSurfaceTextureAvailable回调中设置。override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { val cameraId ... // 关联的摄像头ID val characteristics cameraCharacteristicsMap[cameraId]!! val previewSize chooseOptimalPreviewSize(cameraId, textureView) val sensorOrientation characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 val isFrontCamera characteristics.get(CameraCharacteristics.LENS_FACING) CameraCharacteristics.LENS_FACING_FRONT // 计算变换矩阵 val matrix Matrix() val viewWidth textureView.width.toFloat() val viewHeight textureView.height.toFloat() val previewWidth previewSize.width.toFloat() val previewHeight previewSize.height.toFloat() // 1. 计算缩放使预览填满View并保持比例 val scaleX viewWidth / previewWidth val scaleY viewHeight / previewHeight val scale max(scaleX, scaleY) // 或 min取决于你是想填满还是包含 matrix.setScale(scale, scale) // 2. 平移使画面居中 matrix.postTranslate((viewWidth - previewWidth * scale) / 2, (viewHeight - previewHeight * scale) / 2) // 3. 应用传感器旋转 matrix.postRotate(sensorOrientation.toFloat(), viewWidth / 2, viewHeight / 2) // 4. 如果是前置摄像头应用水平镜像 if (isFrontCamera) { matrix.postScale(-1f, 1f, viewWidth / 2, viewHeight / 2) } textureView.setTransform(matrix) }这个矩阵计算是正确显示画面的核心顺序很重要先缩放平移再旋转最后镜像。5. 双目摄像头、红外与人脸识别的特殊集成5.1 双目摄像头同步策略双目摄像头通常指两个物理位置固定、参数一致的摄像头用于计算深度信息。在Android上它们可能表现为两个独立的cameraId。关键挑战是同步确保左右摄像头捕获的图像时间戳尽可能接近。Camera2 API的CaptureRequest.CONTROL_AE_MODE设置为OFF并手动设置相同的曝光时间(SENSOR_EXPOSURE_TIME)有助于同步但硬件同步通常需要厂商特定的扩展API。一个实用的软件同步方案是同时触发使用CameraManager的createCaptureSession和setRepeatingRequest尽可能同时在同一帧回调内对两个摄像头进行操作。时间戳对齐使用ImageReader获取图像时记录Image.getTimestamp()。在后续处理中根据时间戳配对左右图像。外部触发对于高精度需求可以考虑使用外部硬件信号触发两个摄像头同时采集但这超出了标准API范畴。5.2 接入红外IR摄像头红外摄像头通常输出YUV或RAW格式的数据而非标准的JPEG。你需要识别摄像头通过CameraCharacteristics的LENS_FACING和SENSOR_INFO_COLOR_FILTER_ARRANGEMENT等特性或尝试打开后检查支持的输出格式来识别红外摄像头。配置ImageReader使用ImageFormat.YUV_420_888或ImageFormat.RAW_SENSOR格式创建ImageReader。处理YUV数据从Image对象的planes中获取Y、U、V分量数据进行后续处理如转换为灰度图、温度分析等。val imageReader ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 2) imageReader.setOnImageAvailableListener({ reader - val image reader.acquireLatestImage() image?.use { val yPlane it.planes[0] // Y分量 val uPlane it.planes[1] // U分量 val vPlane it.planes[2] // V分量 // 处理YUV数据... } }, handler)5.3 集成人脸识别与活体检测这属于计算机视觉范畴我们的多摄像头框架为其提供了稳定的视频流输入。通常流程是获取预览帧除了用于显示的预览Surface可以再为每个摄像头创建一个ImageReader格式为ImageFormat.YUV_420_888或ImageFormat.PRIVATE用于获取分析用的帧数据。选择分析库可以使用Google ML Kit的Face DetectionAPI或集成更专业的SDK如虹软、商汤等。数据传递在ImageReader的回调中将Image对象或从中提取的字节数组、Bitmap传递给识别模块。务必注意Image对象必须及时关闭(.close()或.use{})否则会迅速耗尽相机缓冲区。活体检测通常需要多帧或特定动作眨眼、张嘴、摇头。可以利用双目摄像头获取的深度信息进行3D活体检测准确度更高。我们的多摄像头框架为这种方案提供了基础设施——你可以同时获取前置RGB摄像头和红外或另一颗前置摄像头的图像流供算法进行融合判断。6. 避坑指南与性能优化6.1 常见问题与排查CameraAccessException: MAX_CAMERAS_IN_USE原因系统或其它应用占用了摄像头资源。解决确保在onPause或onDestroy中正确关闭所有摄像头调用CameraDevice.close()。检查是否有其它相机应用在后台运行。预览画面拉伸、变形或方向错误原因TextureView的变换矩阵计算错误或预览尺寸选择不当。排查打印出选择的预览尺寸、TextureView的宽高、传感器方向逐步检查矩阵计算每一步的结果。确保setDefaultBufferSize设置正确。同时打开失败但单独打开正常原因设备硬件或驱动限制。并非所有设备都支持多路摄像头并发高分辨率流。解决降低预览分辨率如降至720p或尝试先打开一个摄像头再延迟几百毫秒打开第二个。使用CameraManager.concurrentCameraIdsAPI 30进行能力查询。应用卡顿或发热严重原因预览分辨率过高或ImageReader回调中进行了耗时操作阻塞了相机线程。优化将预览分辨率限制在1080p以下。确保ImageReader回调中只做最必要的图像获取和传递将保存、分析等操作移交到单独的ExecutorService或协程中。前后摄像头帧率不同步现象同屏预览时两个画面刷新速度肉眼可见不一致。原因两个摄像头可能运行在不同的帧率下例如一个30fps一个60fps或者CPU/GPU负载不均衡。缓解在创建预览请求时尝试通过CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE将两个摄像头的帧率范围设置为一致如[30,30]。但这并非所有设备都支持。6.2 性能优化要点资源按需创建不要一次性为所有摄像头创建所有资源如ImageReader。只在需要截图或分析时才创建并添加对应的ImageReaderSurface到CaptureSession。及时释放在摄像头不可见如切换到其他Fragment时及时关闭摄像头释放资源。CameraDevice、CaptureSession、ImageReader都是重量级对象。复用CaptureRequest.Builder对于预览请求在CaptureSession创建后应复用同一个CaptureRequest.Builder只更新需要改变的参数如缩放比例然后提交新请求。后台线程管理为每个摄像头使用独立的HandlerThread避免回调堆积相互影响。使用ExecutorService管理图像保存、分析等CPU密集型任务。内存管理ImageReader的maxImages参数不要设置过大2-3张通常足够。及时调用Image.close()。构建一个健壮的多摄像头应用就像指挥一个小型交响乐团每个乐器摄像头都需要独立的乐谱配置和指挥棒会话但又必须在统一的节拍主线程/生命周期下协同工作。从权限管理、设备枚举到会话创建、画面控制每一步的严谨处理都至关重要。经过多个项目的打磨我发现最稳定的方案往往不是最复杂的而是那些充分考虑了设备差异性和生命周期、并做了大量异常捕获和回退处理的方案。当你成功在屏幕上看到两个摄像头流畅同步预览时那种成就感会告诉你这些细致的工作都是值得的。