10. 拍照流程优化:ZSL(零延迟快门)原理与实现、减少拍照到回显的延迟

拍照慢,是用户吐槽最多的问题之一。你按下快门,转圈圈,等个一两秒才看到照片——这种体验,说实话,很劝退。

我做过好几款相机项目的性能优化,每次一提到拍照延迟,产品经理的眼神就变得特别犀利。嗯,今天我们就来聊聊怎么干掉这个延迟。核心武器就是——ZSL,零延迟快门

10.1 传统拍照流程的痛点

先看看传统流程长什么样。你按下快门,系统才开始做一系列动作:

  1. 请求Camera HAL重新配置流(从预览流切换到拍照流)
  2. 重新申请内存、创建GraphicBuffer
  3. 等待下一帧数据到达
  4. HAL处理这一帧(做3A、降噪、编码)
  5. 把JPEG数据送回App
  6. App解码、显示缩略图

这一套下来,少说800ms,多则2秒以上。问题出在哪?每一步都在等。等配置、等帧、等处理、等回传。

核心瓶颈:拍照和预览是两个独立的流水线。每次拍照都要重新建立一条拍照流水线,这就是延迟的根源。

10.2 ZSL的核心思想

ZSL的思路其实很简单——别等,提前准备好

预览的时候,Camera HAL其实一直在输出帧。这些帧里,大部分被丢掉了,只有一小部分显示到屏幕上。ZSL的做法是:在预览阶段,就维持一条完整的拍照流水线。你按下快门的那一刻,直接从流水线里取一帧出来,编码成JPEG。

说白了,就是用空间换时间。多占一点内存,换来几乎零延迟的快门响应。

我个人习惯把ZSL分成两种实现方式:

  • 软件ZSL:App层维护一个帧缓冲区,预览帧不断入队,拍照时从缓冲区取最近的一帧。
  • 硬件ZSL:Camera HAL内部维护拍照流,App通过CaptureRequest直接获取已准备好的帧。

硬件ZSL延迟更低,但实现复杂度高。软件ZSL灵活,但需要处理好帧同步和内存问题。

10.3 硬件ZSL的实现路径

以Android Camera2 API为例,硬件ZSL的核心是同时维持预览流和拍照流

你需要创建两个Surface:一个给预览用,一个给拍照用。然后通过createCaptureSession把它们一起传进去。

// 创建两个Surface
Surface previewSurface = surfaceView.getHolder().getSurface();
Surface photoSurface = imageReader.getSurface();

// 同时配置预览流和拍照流
List<OutputConfiguration> outputs = new ArrayList<>();
outputs.add(new OutputConfiguration(previewSurface));
outputs.add(new OutputConfiguration(photoSurface));

// 创建Session
cameraDevice.createCaptureSession(
    new OutputConfiguration(outputs),
    new CameraCaptureSession.StateCallback() {
        @Override
        public void onConfigured(CameraCaptureSession session) {
            // 开始预览,同时拍照流也在后台运行
            session.setRepeatingRequest(previewRequest, null, handler);
        }
    },
    handler
);

这里有个关键点:拍照流的帧率不需要和预览流一样高。我建议拍照流用15fps就够了,太高了浪费内存和带宽。

我的经验:拍照流的ImageReader建议用YUV_420_888格式,而不是JPEG格式。YUV转JPEG可以在后台线程做,不阻塞主流程。我曾经在一个项目里直接用JPEG格式,结果编码卡了300ms,ZSL的效果大打折扣。

10.4 拍照到回显的延迟优化

ZSL解决了「按下快门到拍下照片」的延迟。但用户还关心另一件事——拍完多久能看到照片

这个回显延迟,通常来自三个环节:

  • JPEG编码:YUV转JPEG需要CPU计算,高分辨率下耗时明显。
  • 文件写入:写到磁盘,尤其是写SD卡时,IO延迟不可控。
  • 缩略图生成:解码JPEG生成缩略图,又是一次IO+解码。

怎么优化?我分享几个实战中验证过的方法。

10.4.1 异步编码 + 双缓冲

别在主线程做编码。用HandlerThread或者WorkManager把编码任务丢到后台。同时,用双缓冲机制:一个缓冲区在编码,另一个缓冲区在接收新帧。

// 双缓冲示例
class ZslBuffer {
    Image buffer1;
    Image buffer2;
    AtomicBoolean isEncoding = new AtomicBoolean(false);

    void onImageAvailable(ImageReader reader) {
        Image image = reader.acquireLatestImage();
        if (image == null) return;

        if (isEncoding.compareAndSet(false, true)) {
            // 当前没有编码任务,直接处理
            encodeAndSave(image);
        } else {
            // 有编码任务,存到备用缓冲区
            backupBuffer = image;
        }
    }

    void encodeAndSave(Image image) {
        // 后台线程编码
        executor.execute(() -> {
            byte[] jpeg = yuvToJpeg(image);
            saveToDisk(jpeg);
            image.close();
            isEncoding.set(false);

            // 检查备用缓冲区
            if (backupBuffer != null) {
                Image pending = backupBuffer;
                backupBuffer = null;
                encodeAndSave(pending);
            }
        });
    }
}

10.4.2 缩略图提前生成

别等JPEG写完了再生成缩略图。我建议在编码JPEG的同时,直接从YUV帧里缩放出缩略图。YUV缩放到小图,比解码JPEG快得多。

// 从YUV直接生成缩略图
Bitmap thumbnail = yuvToBitmap(image, 256, 256);
// 显示缩略图
runOnUiThread(() -> imageView.setImageBitmap(thumbnail));
// 后台继续编码JPEG
executor.execute(() -> {
    byte[] jpeg = yuvToJpeg(image);
    saveToDisk(jpeg);
});

这样做,用户按下快门后几乎立刻就能看到缩略图。虽然清晰度不高,但心理体验好很多。

注意:YUV转Bitmap也是CPU密集操作。如果缩略图生成影响了JPEG编码速度,可以考虑用RenderScript或者GPU来做缩放。我曾经在一个低端机上踩过这个坑,缩略图生成占了50ms,导致JPEG编码延迟,最后用户看到的缩略图是出来了,但全屏查看时照片还没存好——体验反而更差了。

10.5 ZSL的代价与取舍

ZSL不是银弹。它有几个明显的代价:

代价 说明 缓解方案
内存占用 多一路拍照流,需要额外分配缓冲区 降低拍照流分辨率或帧率
功耗增加 ISP需要同时处理两路数据 只在预览开启后延迟几秒再启动ZSL
帧同步复杂 拍照流和预览流的帧序号可能不对齐 使用时间戳或帧序号做匹配
HAL兼容性 部分低端HAL不支持多流同时运行 降级到软件ZSL或传统模式

我个人建议:ZSL默认开启,但留一个降级开关。在低内存设备或者极端场景下,可以切回传统模式。别让ZSL成为崩溃的导火索。

10.6 整体流程示意图

下面这张图展示了ZSL模式下,从预览到拍照再到回显的完整数据流。你可以看到,拍照流和预览流是并行的,按下快门只是从拍照流里取一帧。

ZSL拍照流程架构图 Camera HAL Sensor + ISP 预览流 30fps → SurfaceView 拍照流 15fps → ImageReader 帧数据 帧数据(并行) 帧缓冲区 最近N帧缓存 用户按下快门 触发取帧 取最近一帧 JPEG编码(后台) 缩略图生成 回显缩略图 保存JPEG到磁盘 ZSL核心:预览流与拍照流并行,按下快门直接从缓冲区取帧

10.7 避坑指南

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

  • ImageReader的maxImages别设太大。设成2或3就够了。设大了内存暴涨,设小了容易丢帧。我一般设2,配合双缓冲刚好。
  • 别忘了close Image。ImageReader取出来的Image如果不close,缓冲区很快耗尽,预览会卡住。这个bug我排查了整整一天。
  • 低端机慎用硬件ZSL。有些HAL实现有bug,同时跑两路流会导致预览掉帧。建议在运行时检测设备性能,动态决定是否启用ZSL。
  • 缩略图尺寸要合适。别生成全尺寸缩略图,256x256足够了。太大反而增加解码延迟,得不偿失。

ZSL优化好了,拍照体验真的能上一个台阶。用户按下快门的那一刻,照片「嗖」地就出来了——这种爽感,值得你花时间去打磨。