1. 项目缘起:为什么需要同时操作多个摄像头?

在Android应用开发里,处理单个摄像头预览和拍照,对于大多数开发者来说已经不是什么难题。但当你接到一个需求,要求同时打开手机的前后两个摄像头,或者处理一个外接的双目摄像头模组,把两个摄像头的画面并排显示在屏幕上时,很多人可能就会觉得有点棘手了。这不仅仅是“再打开一个Camera实例”那么简单,它涉及到资源管理、生命周期同步、性能优化等一系列更深层次的问题。

我最初接触这个需求,是在一个需要实时人脸比对和活体检测的项目里。客户要求前置摄像头捕捉用户面部进行活体判断,同时后置摄像头扫描环境或文档,两个画面需要实时同步展示。市面上很多现成的SDK要么只支持单摄,要么封装得太死,无法满足我们自定义UI和灵活控制的需求。于是,我们决定从Android Camera2 API的底层开始,自己动手实现一套稳定、高效的多摄像头同显方案。

这个方案的核心价值在于它的“实用性”和“可扩展性”。它不仅解决了“同时预览”这个基本问题,还完整覆盖了在实际开发中你一定会遇到的细节:比如如何处理不同摄像头的分辨率差异?如何独立控制每个摄像头的缩放、对焦和旋转?截图时如何确保两个画面同步?以及,如何优雅地处理像红外摄像头这样的特殊设备?接下来,我将把这个从零到一踩坑、填坑的过程,毫无保留地分享出来。

2. 技术选型与架构设计:为什么是Camera2 API?

在开始敲代码之前,选择一个正确的API是成功的一半。Android的摄像头开发主要经历了Camera、Camera2和最新的CameraX三个阶段。对于多摄像头开发,我的结论非常明确: 必须使用Camera2 API

Camera API(android.hardware.Camera) 是旧时代的产物,它在设计之初就没有充分考虑多摄像头的场景。虽然通过一些Hack手段(比如先打开后置,再快速打开前置)可能实现双开,但这种方式极不稳定,容易导致预览卡顿、画面撕裂甚至应用崩溃。更重要的是,它缺乏对每个摄像头参数的精细控制能力,无法满足我们独立调整缩放、焦距等需求。

CameraX 是Google力推的现代化开发库,它基于Camera2构建,提供了更简洁的生命周期感知和用例(Use Case)模型。对于常见的单摄像头拍照、预览等场景,CameraX无疑是首选,能大幅降低开发复杂度。然而,在撰写本文时,CameraX对 多个摄像头流的同时输出和独立控制 的支持还不够成熟和灵活。它的用例绑定模型更倾向于“一个用例绑定一个摄像头”,要同时管理两个独立的预览流,需要更复杂的配置,并且可能无法直接访问底层Camera2的某些高级特性(如手动对焦环、精确的缩放控制)。对于追求极致控制和自定义的复杂多摄场景,直接使用Camera2 API仍然是更强大、更可靠的选择。

Camera2 API(android.hardware.camera2) 采用了全新的管道(Pipeline)模型,将摄像头抽象为一个可以向多个目标(Surface)输出图像数据的设备。这正是我们需要的核心模型:我们可以为前后两个摄像头分别创建独立的 CameraCaptureSession ,每个Session绑定到不同的预览Surface(例如两个 TextureView ),从而实现完全独立的控制和数据流。Camera2 API提供了对摄像头硬件的底层访问能力,包括:

  • 摄像头枚举 :通过 CameraManager 获取设备上所有可用的摄像头ID列表,并查询每个摄像头的特性(如朝向、是否支持逻辑多摄等)。
  • 精细控制 :通过 CaptureRequest 可以独立设置每个摄像头的几乎所有参数,包括缩放(通过 SCALER_CROP_REGION )、对焦模式、曝光补偿、输出分辨率等。
  • 同步管理 :虽然两个摄像头会话是独立的,但我们可以通过应用层逻辑来协调它们的启动、停止和状态同步。

因此,我们的架构设计思路如下:

  1. 独立会话 :为每个需要打开的摄像头(如 "0" 代表后置, "1" 代表前置)创建独立的 CameraDevice CameraCaptureSession
  2. 独立预览目标 :每个会话绑定到独立的 Surface (通常来自 TextureView SurfaceView ),用于渲染各自的画面。
  3. 集中式管理器 :创建一个 MultiCameraManager 类,统一管理所有摄像头会话的生命周期(打开、关闭、异常处理),并协调它们与Activity/Fragment生命周期的同步。
  4. 参数隔离 :每个摄像头的控制参数(缩放、对焦等)完全独立,通过各自的 CaptureRequest.Builder 进行设置。

这个架构清晰地将责任分离,使得代码易于维护和扩展,例如未来增加第三个外接USB摄像头也会相对容易。

3. 核心实现步骤详解:从权限到双画面预览

理论说完了,我们进入实战环节。我会按照开发的自然顺序,一步步拆解关键代码和配置。请注意,以下代码是核心逻辑的提炼,在实际项目中需要结合完整的生命周期管理和错误处理。

3.1 环境与权限准备

多摄像头操作需要声明更多的权限,并在运行时动态申请。

AndroidManifest.xml 配置:

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<!-- 如果支持,可添加相机闪光灯等特性 -->

<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 10 (API 29) 及以上,如果需要在后台访问摄像头,可能需要此权限 -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- 注意:后台位置权限敏感,非必要不申请。通常预览功能不需要。 -->

重要提示 :从Android 10开始,如果应用切换到后台后仍需访问摄像头,可能会受到限制。绝大多数多摄像头预览场景都是前台行为,因此无需声明后台位置权限。务必遵循最小权限原则。

运行时权限申请: 在Activity中,我们需要在 onCreate 或合适的时机请求 CAMERA 权限。由于可能同时需要读写存储权限来保存截图,通常一并申请。

// 本例使用Kotlin,Java逻辑类似
private val REQUIRED_PERMISSIONS = arrayOf(
    Manifest.permission.CAMERA,
    Manifest.permission.WRITE_EXTERNAL_STORAGE // API 33以下需要,33及以上使用MediaStore
)

private fun requestPermissions() {
    if (checkAllPermissionsGranted()) {
        setupMultiCamera()
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
        )
    }
}

override fun onRequestPermissionsResult(...) {
    if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
        setupMultiCamera()
    } else {
        Toast.makeText(this, "权限被拒绝,无法使用摄像头", Toast.LENGTH_SHORT).show()
    }
}

3.2 摄像头枚举与选择

不是所有手机都有前后双摄,甚至有的设备有多个后置或前置摄像头。我们不能硬编码摄像头ID,必须动态查询。

private lateinit var cameraManager: CameraManager
private var backCameraId: String? = null
private var frontCameraId: String? = null

private fun setupCameraIds() {
    cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
    val cameraIdList = cameraManager.cameraIdList

    for (id in cameraIdList) {
        val characteristics = cameraManager.getCameraCharacteristics(id)
        val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)

        when (lensFacing) {
            CameraCharacteristics.LENS_FACING_BACK -> {
                if (backCameraId == null) {
                    backCameraId = id // 通常取第一个后置摄像头
                    // 可以进一步根据分辨率、是否支持RAW等特性选择最优摄像头
                }
            }
            CameraCharacteristics.LENS_FACING_FRONT -> {
                if (frontCameraId == null) {
                    frontCameraId = id
                }
            }
            CameraCharacteristics.LENS_FACING_EXTERNAL -> {
                // 处理外接USB或红外摄像头
                Log.d(TAG, "发现外接摄像头: $id")
            }
        }
    }

    if (backCameraId == null || frontCameraId == null) {
        Log.e(TAG, "未找到足够可用的摄像头")
        // 处理错误,例如只显示一个摄像头或提示用户
    }
}

这里有一个 关键细节 :有些高端手机拥有多个后置摄像头(广角、长焦、超广角)。上述代码简单取了第一个找到的后置摄像头。在实际产品中,你可能需要根据 StreamConfigurationMap 查询每个摄像头支持的分辨率,然后选择最适合预览分辨率(如1080P)的那个摄像头ID。对于双目摄像头模组(通常是外接USB摄像头),它们会被标记为 LENS_FACING_EXTERNAL ,你需要根据供应商提供的特性(如通过 CameraCharacteristics 查询自定义的Key)来识别哪个是左目、哪个是右目。

3.3 创建预览界面与Surface

我们使用两个 TextureView 来分别显示前后摄像头的画面。布局文件很简单:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <TextureView
        android:id="@+id/texture_view_back"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />

    <TextureView
        android:id="@+id/texture_view_front"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
</LinearLayout>

在Activity中,我们需要等待 TextureView 准备就绪( SurfaceTexture 可用)。

private val textureListener = object : TextureView.SurfaceTextureListener {
    override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
        // 两个TextureView的监听器可以合并处理
        if (::textureViewBack.isInitialized && ::textureViewFront.isInitialized) {
            if (textureViewBack.isAvailable && textureViewFront.isAvailable) {
                // 两个Surface都准备好了,开始打开摄像头
                openAllCameras()
            }
        }
    }
    // ... 其他重写方法 onSurfaceTextureSizeChanged, onSurfaceTextureDestroyed, onSurfaceTextureUpdated
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_multi_camera)
    textureViewBack.surfaceTextureListener = textureListener
    textureViewFront.surfaceTextureListener = textureListener
}

3.4 打开摄像头并创建会话(核心中的核心)

这是整个流程最复杂的一步。我们必须为每个摄像头独立执行“打开设备 -> 创建会话”的流程,并且要处理好异步回调可能带来的时序问题。

我们定义一个 CameraSessionWrapper 类来封装单个摄像头的所有状态:

class CameraSessionWrapper(
    val cameraId: String,
    val textureView: TextureView,
    val isFrontFacing: Boolean
) {
    var cameraDevice: CameraDevice? = null
    var captureSession: CameraCaptureSession? = null
    lateinit var previewRequestBuilder: CaptureRequest.Builder
    var currentZoom = 1.0f // 默认无缩放

    fun openCamera(cameraManager: CameraManager, handler: Handler = Handler()) {
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
            return
        }
        cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
            override fun onOpened(device: CameraDevice) {
                this@CameraSessionWrapper.cameraDevice = device
                createPreviewSession(device)
            }
            override fun onDisconnected(device: CameraDevice) {
                close()
            }
            override fun onError(device: CameraDevice, error: Int) {
                Log.e(TAG, "打开摄像头 $cameraId 失败,错误码: $error")
                close()
            }
        }, handler)
    }

    private fun createPreviewSession(device: CameraDevice) {
        val surfaceTexture = textureView.surfaceTexture
        // 设置默认预览尺寸,这里取摄像头支持的最大预览尺寸,实际应根据TextureView大小和性能权衡选择
        val characteristics = cameraManager.getCameraCharacteristics(cameraId)
        val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
        val previewSize = map?.getOutputSizes(SurfaceTexture::class.java)?.maxByOrNull { it.width * it.height }
        previewSize?.let {
            surfaceTexture.setDefaultBufferSize(it.width, it.height)
        }
        val previewSurface = Surface(surfaceTexture)

        // 创建预览请求
        previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
        previewRequestBuilder.addTarget(previewSurface)

        // 配置会话参数,这里只添加预览Surface
        device.createCaptureSession(listOf(previewSurface), object : CameraCaptureSession.StateCallback() {
            override fun onConfigured(session: CameraCaptureSession) {
                this@CameraSessionWrapper.captureSession = session
                // 开始重复发送预览请求,画面就会显示了
                updatePreview()
            }
            override fun onConfigureFailed(session: CameraCaptureSession) {
                Log.e(TAG, "摄像头 $cameraId 会话配置失败")
            }
        }, null)
    }

    fun updatePreview() {
        captureSession?.setRepeatingRequest(previewRequestBuilder.build(), null, null)
    }

    fun close() {
        captureSession?.close()
        cameraDevice?.close()
        captureSession = null
        cameraDevice = null
    }
}

在Activity中,我们创建两个 CameraSessionWrapper 实例并管理它们:

private lateinit var backSession: CameraSessionWrapper
private lateinit var frontSession: CameraSessionWrapper

private fun openAllCameras() {
    backCameraId?.let {
        backSession = CameraSessionWrapper(it, textureViewBack, isFrontFacing = false)
        backSession.openCamera(cameraManager)
    }
    frontCameraId?.let {
        // **关键点:轻微延迟打开第二个摄像头**
        // 部分手机硬件资源有限,同时发出两个openCamera调用可能导致其中一个失败。
        // 延迟100-200ms可以显著提高成功率。
        Handler(Looper.getMainLooper()).postDelayed({
            frontSession = CameraSessionWrapper(it, textureViewFront, isFrontFacing = true)
            frontSession.openCamera(cameraManager)
        }, 150)
    }
}

这里有一个非常重要的经验 :部分手机(尤其是中低端机型)的摄像头硬件或驱动可能无法承受两个摄像头被“同时”瞬间打开的巨大资源请求,会导致其中一个 onError 。因此,对第二个摄像头的 openCamera 调用做一个短暂的延迟(如150毫秒),是一个在实践中被证明有效的“土办法”,能极大提升双开成功率。这不是API的设计缺陷,而是对硬件资源竞争的一种现实妥协。

3.5 处理生命周期

我们必须确保在Activity进入后台时及时释放摄像头资源,否则其他应用将无法使用摄像头,且系统可能会强制关闭你的应用。

override fun onPause() {
    super.onPause()
    backSession.close()
    frontSession.close()
}

override fun onResume() {
    super.onResume()
    if (textureViewBack.isAvailable && textureViewFront.isAvailable) {
        // 如果Surface仍然可用(例如屏幕旋转但未销毁TextureView),则重新打开
        openAllCameras()
    }
    // 否则,等待TextureView的onSurfaceTextureAvailable回调
}

对于屏幕旋转等配置变更,更优雅的做法是在 AndroidManifest.xml 中为Activity配置 android:configChanges="orientation|screenSize" ,并手动处理预览画面的旋转,避免Activity重建导致摄像头会话频繁开关。但这会引入额外的旋转逻辑处理。对于Demo,最简单的办法是让Activity重建,我们在 onCreate 中重新走一遍初始化流程。

4. 高级功能实现:缩放、对焦、旋转与镜像

当两个摄像头画面稳定预览后,下一步就是实现对每个摄像头的独立控制。这些功能都通过操作 CaptureRequest.Builder 来实现。

4.1 独立缩放控制

Camera2的缩放不是直接设置倍数,而是通过设置一个裁剪区域( CROP_REGION )来实现。传感器的有效像素区域是一个矩形,我们通过缩小这个矩形区域来实现“放大”画面的效果(相当于数码变焦)。

fun setZoom(sessionWrapper: CameraSessionWrapper, zoomLevel: Float) {
    val device = sessionWrapper.cameraDevice ?: return
    val characteristics = cameraManager.getCameraCharacteristics(sessionWrapper.cameraId)
    val sensorRect = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return

    // 计算裁剪区域
    val ratio = 1.0f / zoomLevel
    val cropWidth = (sensorRect.width() * ratio).toInt()
    val cropHeight = (sensorRect.height() * ratio).toInt()
    val cropX = (sensorRect.width() - cropWidth) / 2
    val cropY = (sensorRect.height() - cropHeight) / 2
    val zoomRect = Rect(cropX, cropY, cropX + cropWidth, cropY + cropHeight)

    // 更新预览请求
    sessionWrapper.previewRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, zoomRect)
    sessionWrapper.currentZoom = zoomLevel
    sessionWrapper.updatePreview()
}

注意事项

  1. 缩放范围 :需要通过 CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM 查询该摄像头支持的最大数码变焦倍数。 zoomLevel 应介于1.0和该最大值之间。
  2. 性能与画质 :数码变焦本质是裁剪和插值,放大倍数过大会导致画面模糊和锯齿。如果手机支持双摄融合变焦(通过切换不同物理焦距的摄像头实现),逻辑会更复杂,需要查询 SCALER_AVAILABLE_STREAM_USE_CASES 并可能涉及多个摄像头ID的切换,这超出了本文基础范围。
  3. 独立控制 :我们为 backSession frontSession 分别调用 setZoom ,它们之间的缩放倍数互不影响。

4.2 独立对焦控制

对焦模式同样通过 CaptureRequest.Builder 设置。常见的模式有 CONTROL_AF_MODE_AUTO (自动对焦)、 CONTROL_AF_MODE_CONTINUOUS_PICTURE (连续对焦,适合预览)、 CONTROL_AF_MODE_CONTINUOUS_VIDEO (连续对焦,适合录像)和 CONTROL_AF_MODE_OFF (手动固定焦距)。

fun setFocusMode(sessionWrapper: CameraSessionWrapper, afMode: Int) {
    sessionWrapper.previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, afMode)
    // 如果切换到自动对焦模式,可以触发一次对焦扫描
    if (afMode == CaptureRequest.CONTROL_AF_MODE_AUTO) {
        sessionWrapper.previewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START)
    }
    sessionWrapper.updatePreview()
}

触摸对焦 的实现稍微复杂一些:需要在 TextureView 上监听触摸事件,将触摸坐标转换为传感器坐标系的 MeteringRectangle ,然后通过 CaptureRequest.Builder 设置 CONTROL_AF_REGIONS 并触发对焦。

textureViewBack.setOnTouchListener { v, event ->
    if (event.action == MotionEvent.ACTION_DOWN) {
        handleTouchFocus(event.x, event.y, backSession)
        true
    } else {
        false
    }
}
// 对frontSession同理

关键点 :前后摄像头的对焦区域计算是独立的。因为两个摄像头的传感器尺寸、镜头朝向可能不同,所以从 TextureView 坐标到传感器坐标的转换矩阵需要分别根据各自的 CameraCharacteristics 来计算。

4.3 处理画面旋转与镜像

这是多摄像头预览UI正确显示的关键,也是最容易出错的地方。

1. 画面旋转: 手机有自然方向(通常竖屏为0度),但摄像头传感器的安装方向是固定的。当用户旋转手机时,我们需要调整预览画面的显示方向,使其与用户期望的方向一致。同时,我们还需要考虑 TextureView 本身的旋转。

private fun getDisplayRotation(): Int {
    return when (windowManager.defaultDisplay.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> 0
    }
}

private fun configureTransform(sessionWrapper: CameraSessionWrapper) {
    val textureView = sessionWrapper.textureView
    val characteristics = cameraManager.getCameraCharacteristics(sessionWrapper.cameraId)
    val sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
    val deviceRotation = getDisplayRotation()

    // 计算预览需要旋转的角度
    val isFrontCamera = sessionWrapper.isFrontFacing
    val previewRotation = if (isFrontCamera) {
        // 前置摄像头需要特殊处理镜像,这里先算基础旋转
        (sensorOrientation + deviceRotation) % 360
    } else {
        // 后置摄像头
        (sensorOrientation - deviceRotation + 360) % 360
    }

    // 应用旋转到TextureView
    val matrix = Matrix()
    val viewWidth = textureView.width.toFloat()
    val viewHeight = textureView.height.toFloat()
    val centerX = viewWidth / 2
    val centerY = viewHeight / 2
    matrix.postRotate(previewRotation.toFloat(), centerX, centerY)

    // 前置摄像头需要额外处理镜像(见下文)
    textureView.setTransform(matrix)
}

2. 前置摄像头镜像: 用户习惯看到的前置摄像头画面是镜像的(就像照镜子)。但Camera2 API输出的原始数据是非镜像的。因此,我们需要在旋转的基础上,对X轴进行缩放-1来实现水平翻转。

// 在 configureTransform 函数中,针对前置摄像头添加镜像
if (isFrontCamera) {
    // 水平镜像翻转
    matrix.postScale(-1f, 1f, centerX, centerY)
}

一个常见的坑 :如果你同时设置了旋转和镜像, 顺序很重要 。通常先进行旋转,再进行镜像,这样得到的画面才符合用户直觉。顺序错了会导致画面上下或左右颠倒。

4.4 同时截图保存

同时为两个摄像头截图,意味着需要为每个摄像头创建一个临时的 ImageReader 来捕获一帧静态图像,并处理好两个异步回调的同步问题。

fun captureBothCameras() {
    val backImageReader = ImageReader.newInstance(1920, 1080, ImageFormat.JPEG, 2)
    val frontImageReader = ImageReader.newInstance(1920, 1080, ImageFormat.JPEG, 2)

    var backImage: Image? = null
    var frontImage: Image? = null
    val lock = Object() // 用于同步两个回调

    backImageReader.setOnImageAvailableListener({ reader ->
        val image = reader.acquireLatestImage()
        synchronized(lock) {
            backImage = image
            if (frontImage != null) {
                saveBothImages(backImage!!, frontImage!!)
            }
        }
    }, backgroundHandler)

    frontImageReader.setOnImageAvailableListener({ reader ->
        val image = reader.acquireLatestImage()
        synchronized(lock) {
            frontImage = image
            if (backImage != null) {
                saveBothImages(backImage!!, frontImage!!)
            }
        }
    }, backgroundHandler)

    // 为每个摄像头创建单独的拍照请求
    val backCaptureBuilder = backSession.cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
    backCaptureBuilder?.addTarget(backImageReader.surface)
    backCaptureBuilder?.set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation(backSession.cameraId))
    backSession.captureSession?.capture(backCaptureBuilder?.build(), null, null)

    val frontCaptureBuilder = frontSession.cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
    frontCaptureBuilder?.addTarget(frontImageReader.surface)
    frontCaptureBuilder?.set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation(frontSession.cameraId))
    frontSession.captureSession?.capture(frontCaptureBuilder?.build(), null, null)
}

private fun saveBothImages(backImage: Image, frontImage: Image) {
    // 将Image转换为byte数组,再保存为文件
    val backBuffer = backImage.planes[0].buffer
    val backBytes = ByteArray(backBuffer.remaining())
    backBuffer.get(backBytes)

    val frontBuffer = frontImage.planes[0].buffer
    val frontBytes = ByteArray(frontBuffer.remaining())
    frontBuffer.get(frontBytes)

    // 使用后台线程保存文件,文件名可以加上时间戳区分
    val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
    saveImageToFile(backBytes, "BACK_$timestamp.jpg")
    saveImageToFile(frontBytes, "FRONT_$timestamp.jpg")

    // 重要:关闭Image对象,释放资源
    backImage.close()
    frontImage.close()
}

关键细节

  • 同步等待 :我们无法预知哪个摄像头的图像先到达。使用一个共享锁和状态变量来等待两张图片都就绪后再一起保存,确保“同时”的概念。
  • 资源释放 Image 对象是重量级资源,必须在使用后及时调用 close() 方法释放,否则会导致内存泄漏和后续图像捕获失败。
  • 方向设置 JPEG_ORIENTATION 确保了保存的图片文件带有正确的旋转信息,这样在相册中查看时方向才是正确的。 getJpegOrientation 函数需要综合传感器方向和设备旋转来计算,逻辑与预览旋转类似但略有不同。

5. 特殊设备与进阶话题:红外摄像头与活体检测

标题中提到了红外摄像头和活体检测,这属于更专业的领域,但基于我们已搭建的多摄像头框架,可以很好地接入。

5.1 接入红外摄像头

红外摄像头通常作为外接USB设备或手机上的特殊模组出现。在Android系统中,它们同样通过Camera2 API暴露。

识别红外摄像头 :遍历 CameraManager.getCameraIdList() ,对于每个ID,通过 CameraCharacteristics 查询其支持的 CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES 。有些红外摄像头会标记为 REQUEST_AVAILABLE_CAPABILITIES_MONOCHROME (单色),或者通过自定义的Key(需要查阅设备厂商的SDK文档)来标识。更简单的方法是,外接USB摄像头通常其 LENS_FACING EXTERNAL ,你可以通过尝试打开并检查其输出的图像格式(如 ImageFormat.Y8 代表单色8位灰度图)来判断。

处理灰度图像 :红外摄像头输出的是灰度图像(没有颜色信息)。 TextureView 默认期望的是RGB格式。因此,你需要:

  1. 使用 ImageReader ImageFormat.Y8 ImageFormat.YUV_420_888 格式捕获图像。
  2. 将YUV或Y8数据通过OpenCV、RenderScript或自定义的 Shader 程序转换为RGB,再渲染到一个 SurfaceView 或自定义的 View 上。或者,更简单的方式是,直接将 ImageReader Surface 作为 CameraCaptureSession 的输出目标,然后从 ImageReader 中读取字节数据进行处理(如活体检测算法),而不直接显示原始红外画面。

在框架中集成 :你可以在 MultiCameraManager 中增加一个 IRCameraSessionWrapper ,其打开和预览流程与普通摄像头一致,只是在创建 ImageReader 和渲染时使用处理灰度图的逻辑。

5.2 结合活体检测与人脸识别

这是一个算法层面的集成。我们的多摄像头框架为算法提供了稳定的视频流输入源。

典型工作流

  1. 输入源 :前置彩色摄像头提供RGB视频流用于人脸检测和特征点定位。
  2. 活体线索
    • 红外活体 :如果设备有红外摄像头,同步获取红外图像。真实人脸在红外下具有特定的温度分布特征,而照片或屏幕翻拍则没有,这是非常有效的防御手段。
    • 动作指令 :通过语音或UI提示用户完成眨眼、张嘴、摇头等动作。利用前置摄像头的视频流进行动作检测。
    • 3D结构光 (如有):利用结构光摄像头获取深度图,判断是否为立体人脸。
  3. 框架集成
    • onImageAvailableListener 回调中,分别从前后摄像头的 ImageReader 获取最新的图像帧。
    • 将图像帧转换为算法库(如OpenCV、libfacedetection,或商业SDK如虹软、商汤)所需的格式。
    • 调用算法库进行人脸检测、特征点提取、活体判断。
    • 将判断结果和可视化框(如人脸矩形、特征点)通过UI线程绘制到对应的 TextureView 上。

性能考量 :人脸识别和活体检测算法通常比较耗时。务必在后台线程执行算法逻辑,避免阻塞摄像头预览线程。可以使用双缓冲或线程池来处理图像帧,确保预览流畅性和算法实时性的平衡。

6. 避坑指南与性能优化

在实际开发中,我踩过不少坑,这里总结几个最有代表性的:

坑1:双开失败或预览黑屏/卡顿

  • 原因 :硬件资源(如ISP图像信号处理器)竞争,或相机服务内部状态冲突。
  • 解决方案
    1. 延迟打开 :如前所述,第二个摄像头延迟100-200ms打开。
    2. 降低分辨率 :不要同时使用两个摄像头最高分辨率进行预览。通过 StreamConfigurationMap.getOutputSizes() 选择适中的分辨率(如720P或1080P)。
    3. 检查可用性 :在 openCamera 前,通过 CameraCharacteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) 检查摄像头是否支持 REQUEST_AVAILABLE_CAPABILITIES_CONCURRENT_CAMERA (并发摄像头)。但注意,很多设备即使不支持此标志,也能通过我们的方法成功双开。
    4. 确保Surface就绪 :绝对要在 TextureView SurfaceTexture 可用( onSurfaceTextureAvailable 回调后)后再去创建 CameraCaptureSession

坑2:画面旋转或镜像错误

  • 原因 :旋转和镜像矩阵的计算顺序或参数错误。
  • 解决方案
    1. 严格按照 “先旋转,后镜像” 的顺序应用变换。
    2. 仔细计算 sensorOrientation (摄像头传感器安装方向)和 deviceRotation (设备当前旋转角度)的关系。记住,后置摄像头和前置摄像头的计算公式是 不同 的。
    3. setTransform 后,调用 textureView.requestLayout() 有时能解决显示异常。

坑3:内存泄漏与资源未释放

  • 原因 CameraDevice CameraCaptureSession ImageReader Image 等对象未在 onPause onDestroy 中正确关闭。
  • 解决方案
    1. CameraSessionWrapper 中实现 close() 方法,并确保按顺序关闭:先 captureSession?.stopRepeating() 然后 captureSession?.close() ,最后 cameraDevice?.close()
    2. 所有 Image 对象在使用完毕后立即 close()
    3. 在Activity的 onDestroy 中,确保所有Handler也被正确释放。

坑4:不同机型兼容性问题

  • 现象 :在A手机上运行正常,在B手机上闪退或画面异常。
  • 解决方案
    1. 宽高比适配 :不同摄像头支持的分辨率宽高比可能不同(如4:3, 16:9, 18:9)。在设置 SurfaceTexture 的缓冲区大小时,需要选择一个与 TextureView 显示区域宽高比最接近的预览尺寸,否则画面会被拉伸变形。可以通过计算宽高比差值来选择最佳尺寸。
    2. 特性检查 :任何API调用前,先通过 CameraCharacteristics 检查是否支持该特性。例如,调用 setZoom 前,检查 SCALER_AVAILABLE_MAX_DIGITAL_ZOOM 是否大于1.0。
    3. 异常捕获 :在所有相机操作的回调中( onError , onDisconnected )做好日志记录和优雅降级处理(如关闭出错的摄像头,仅显示另一个)。

性能优化建议

  1. 预览尺寸 :选择 TextureView 尺寸的2倍左右分辨率即可,过高的预览分辨率消耗更多CPU/GPU和电量。
  2. ImageReader缓冲 :创建 ImageReader 时,第三个参数 maxImages 不要设置过大,2-3张即可,够用就行,避免内存浪费。
  3. 后台线程 :所有相机操作(打开设备、创建会话、处理图像)都应使用后台线程的 Handler ,避免阻塞主线程。可以使用 HandlerThread 来管理。
  4. 避免频繁操作 :像缩放、对焦模式切换这类操作,不要每帧都更新 CaptureRequest 。可以在用户交互完成后一次性更新。

这套从底层Camera2 API构建的多摄像头控制框架,虽然初期搭建有一定复杂度,但它提供了无与伦比的灵活性和控制力。一旦跑通,你可以在此基础上轻松扩展出各种有趣且实用的功能,比如画中画、双摄对比、3D扫描等。希望这篇详尽的指南能帮助你绕过我当年踩过的那些坑,更顺畅地实现你的创意。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐