20. 连拍与高速连拍:配置连拍CaptureRequest、处理连拍回调、连拍性能优化

连拍这个功能,说白了就是让摄像头在短时间内连续输出多帧图像。你按下一次快门,系统帮你拍下一串照片。嗯,听起来简单,但真正落地的时候,坑还是挺多的。

我个人习惯把连拍分成两种场景:一种是普通连拍,每秒几张到十几张,主要用于抓拍日常瞬间;另一种是高速连拍,每秒几十帧甚至上百帧,常用于运动分析、慢动作合成或者科研场景。两者的实现思路其实差不多,但性能要求天差地别。

连拍的核心思路

Android Camera2 的连拍,本质上就是重复发送 CaptureRequest。你想想看,每次拍照就是一个 request,连拍就是一口气发一堆 request 出去。但这里有个关键点:你不能等上一张拍完再发下一张,那样太慢了。正确的做法是批量提交,让底层 pipeline 自己去排队处理。

核心要点:连拍的速度取决于三个因素——传感器的帧率、ISP 的处理能力、以及你 App 侧的回调处理速度。任何一个环节卡住,连拍就快不起来。

配置连拍 CaptureRequest

先看代码。我一般会这样构建连拍的 request:

// 构建连拍用的 CaptureRequest
val requestBuilder = cameraDevice.createCaptureRequest(
    CameraDevice.TEMPLATE_STILL_CAPTURE
)

// 设置对焦模式为连续自动对焦
requestBuilder.set(
    CaptureRequest.CONTROL_AF_MODE,
    CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
)

// 设置曝光模式
requestBuilder.set(
    CaptureRequest.CONTROL_AE_MODE,
    CaptureRequest.CONTROL_AE_MODE_ON
)

// 如果你需要固定曝光,可以锁住 AE
// requestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, true)

// 设置输出 Surface
requestBuilder.addTarget(imageReader.surface)

// 批量提交
for (i in 0 until burstCount) {
    // 注意:每个 request 可以微调参数
    if (i % 2 == 0) {
        requestBuilder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, 0)
    } else {
        requestBuilder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, -1)
    }
    captureSession.capture(requestBuilder.build(), callback, handler)
}

这里有个细节:TEMPLATE_STILL_CAPTURETEMPLATE_PREVIEW 的区别。我建议连拍用 TEMPLATE_STILL_CAPTURE,因为它会优先保证画质。但如果你需要极高速连拍,可以试试 TEMPLATE_ZERO_SHUTTER_LAG,不过这个模板不是所有设备都支持。

小技巧:我曾经在某个项目里发现,连续提交相同的 request 时,部分手机会出现曝光抖动。后来我在每个 request 里微调了曝光补偿,让它们交替变化,反而更稳定。你可以试试看。

处理连拍回调

连拍的回调比单拍要复杂一些。你需要区分每一帧,并且要处理好回调的并发问题。看这个例子:

private val burstCallback = object : CameraCaptureSession.CaptureCallback() {
    private var frameCount = 0

    override fun onCaptureStarted(
        session: CameraCaptureSession,
        request: CaptureRequest,
        timestamp: Long,
        frameNumber: Long
    ) {
        super.onCaptureStarted(session, request, timestamp, frameNumber)
        // 这一帧开始拍了
        Log.d("Burst", "Frame $frameCount started at $timestamp")
    }

    override fun onCaptureCompleted(
        session: CameraCaptureSession,
        request: CaptureRequest,
        result: TotalCaptureResult
    ) {
        super.onCaptureCompleted(session, request, result)
        // 这一帧拍完了,可以取数据了
        val frameNumber = result.frameNumber
        val timestamp = result.timestamp
        
        // 注意:这里是在 camera 线程回调的
        // 不要做耗时操作
        frameCount++
        
        // 通知 UI 线程更新进度
        runOnUiThread {
            updateProgress(frameCount, totalCount)
        }
    }

    override fun onCaptureFailed(
        session: CameraCaptureSession,
        request: CaptureRequest,
        failure: CaptureFailure
    ) {
        super.onCaptureFailed(session, request, failure)
        // 这一帧失败了
        Log.e("Burst", "Frame failed: ${failure.reason}")
    }
}

为什么要注意线程?因为 onCaptureCompleted 是在 Camera2 的内部线程池里回调的。如果你在这里直接操作 UI 或者做大量计算,会阻塞后续的回调,导致连拍速度下降。我建议回调里只做轻量级的状态记录,真正的图像处理放到工作线程去。

注意:连拍时 onCaptureCompleted 的调用频率非常高。如果你在回调里打印日志,记得控制频率,否则 logcat 会被刷爆。我曾经吃过这个亏,调试时手机直接卡死。

ImageReader 的配置

连拍的数据是通过 ImageReader 拿到的。它的配置直接影响连拍性能:

// 配置 ImageReader
val imageReader = ImageReader.newInstance(
    width, height,
    ImageFormat.JPEG,  // 用 JPEG 格式,体积小
    maxImages = 10     // 缓冲区大小,很重要
)

imageReader.setOnImageAvailableListener({ reader ->
    // 这里要尽快消费掉 Image
    val image = reader.acquireLatestImage()
    image?.let {
        // 处理图像数据
        processImage(it)
        // 一定要 close,否则缓冲区会满
        it.close()
    }
}, backgroundHandler)

maxImages 这个参数很关键。它决定了 ImageReader 内部能缓存多少帧。设得太小,底层会等你消费完才给新帧,连拍速度受限。设得太大,内存占用高,而且处理不及时的话,旧帧会被丢弃。

我个人习惯设成 10 左右。如果你的连拍速度特别高,可以适当增大,但不要超过 20。嗯,这里要提醒一下:一定要及时 close Image,否则缓冲区满了之后,新的帧就进不来了。

连拍性能优化

这部分是我踩坑最多的。直接上干货:

优化方向 具体做法 效果
减少 JPEG 压缩 使用 YUV_420_888 格式,自己编码 减少 ISP 负担,提升帧率
降低分辨率 使用较小的输出尺寸 显著提升连拍速度
批量提交 captureBurst 代替循环 capture 减少 IPC 开销
异步处理 回调中只做轻量操作 避免阻塞 pipeline
减少 AE/AF 计算 锁定曝光和对焦 帧率更稳定

其中 captureBurst 这个方法值得单独说一下:

// 批量提交,比循环 capture 效率高
val requests = List(burstCount) { index ->
    val builder = cameraDevice.createCaptureRequest(
        CameraDevice.TEMPLATE_STILL_CAPTURE
    )
    builder.addTarget(imageReader.surface)
    // 可以给每个 request 设置不同的参数
    builder.set(CaptureRequest.JPEG_QUALITY, 95)
    builder.build()
}

captureSession.captureBurst(requests, callback, handler)

为什么 captureBurst 更快?因为它把多个 request 打包成一次 IPC 调用,减少了内核态和用户态的切换次数。你想想看,循环调用 capture 每次都要跨进程通信,而 captureBurst 一次搞定,效率自然高。

性能数据参考:在我测试过的某款骁龙 8 Gen2 设备上,使用 captureBurst 配合 YUV 格式,1080p 分辨率下可以达到 30fps 的连拍速度。而用 JPEG 格式循环 capture,只能跑到 12fps 左右。差距很明显。

高速连拍的特殊处理

如果你需要 60fps 以上的高速连拍,普通的套路就不够用了。这时候需要一些特殊手段:

  • 使用 PRIVATE 格式:不走 JPEG 编码,直接从 sensor 拿原始数据
  • 降低输出分辨率:720p 甚至更小,减少数据量
  • 关闭所有后处理:关掉美颜、HDR、降噪等
  • 使用 SurfaceView 直接消费:绕过 ImageReader 的拷贝开销

我曾经在一个运动检测项目里,需要 120fps 的连拍。折腾了很久,最后发现瓶颈不在 Camera2 API,而在 App 侧的处理速度。后来我用了双缓冲机制:一个线程负责接收帧,另一个线程负责处理,才勉强跑通。

经验之谈:高速连拍时,建议把图像处理放到 Native 层去做。用 C++ 处理 YUV 数据,比 Java/Kotlin 快一个数量级。如果你用 NDK,还可以直接操作 Surface 的 buffer,省掉一次内存拷贝。

连拍流程总览

下面这张图是我自己总结的连拍流程,你可以对照着看:

连拍与高速连拍核心流程 配置 CaptureRequest TEMPLATE_STILL_CAPTURE 设置输出 Surface ImageReader / SurfaceView 批量提交 captureBurst() 回调处理(CaptureCallback) onCaptureStarted → onCaptureCompleted → onCaptureFailed 数据消费(ImageReader) acquireLatestImage() → 处理 → close() 优化:YUV格式 / 降低分辨率 / 异步处理 / 锁定AE/AF

从流程图可以看到,整个连拍链路其实不复杂。但每个环节都有优化空间。我个人觉得,最容易出问题的是回调处理和数据消费这两个环节。很多人只关注怎么发 request,忽略了消费端的处理能力,结果连拍速度上不去。

避坑指南

最后分享几个我踩过的坑:

  • 不要在主线程处理图像:我曾经把 JPEG 解码放在主线程,结果连拍时 UI 直接卡死。后来改成工作线程,问题解决。
  • 注意内存抖动:连拍时频繁创建对象会导致 GC 频繁触发,影响帧率。建议复用对象,或者用对象池。
  • 不同设备差异很大:同一套代码,在 Pixel 上跑 30fps,在低端机上可能只有 5fps。建议根据设备能力动态调整连拍参数。
  • 别忘了释放资源:连拍结束后,记得关闭 ImageReader 和 CaptureSession,否则下次连拍可能失败。

嗯,连拍这块的内容差不多就这些。核心就是:配置要合理,回调要轻量,消费要快速。把这三点做好,连拍功能基本就稳了。