10. 拍照流程优化:ZSL(零延迟快门)原理与实现、减少拍照到回显的延迟
拍照慢,是用户吐槽最多的问题之一。你按下快门,转圈圈,等个一两秒才看到照片——这种体验,说实话,很劝退。
我做过好几款相机项目的性能优化,每次一提到拍照延迟,产品经理的眼神就变得特别犀利。嗯,今天我们就来聊聊怎么干掉这个延迟。核心武器就是——ZSL,零延迟快门。
10.1 传统拍照流程的痛点
先看看传统流程长什么样。你按下快门,系统才开始做一系列动作:
- 请求Camera HAL重新配置流(从预览流切换到拍照流)
- 重新申请内存、创建GraphicBuffer
- 等待下一帧数据到达
- HAL处理这一帧(做3A、降噪、编码)
- 把JPEG数据送回App
- 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模式下,从预览到拍照再到回显的完整数据流。你可以看到,拍照流和预览流是并行的,按下快门只是从拍照流里取一帧。
10.7 避坑指南
最后,分享几个我踩过的坑:
- ImageReader的maxImages别设太大。设成2或3就够了。设大了内存暴涨,设小了容易丢帧。我一般设2,配合双缓冲刚好。
- 别忘了close Image。ImageReader取出来的Image如果不close,缓冲区很快耗尽,预览会卡住。这个bug我排查了整整一天。
- 低端机慎用硬件ZSL。有些HAL实现有bug,同时跑两路流会导致预览掉帧。建议在运行时检测设备性能,动态决定是否启用ZSL。
- 缩略图尺寸要合适。别生成全尺寸缩略图,256x256足够了。太大反而增加解码延迟,得不偿失。
ZSL优化好了,拍照体验真的能上一个台阶。用户按下快门的那一刻,照片「嗖」地就出来了——这种爽感,值得你花时间去打磨。