22、Camera与显示同步:VSYNC机制与Camera预览同步

各位好,我是老林。今天聊的话题,说白了就是让Camera拍到的画面,顺滑地显示在屏幕上。你可能会想,这有什么难的?拍到了就显示呗。嗯,真没那么简单。

我刚开始做Camera驱动那会儿,就踩过一个大坑。预览画面看着像“撕裂”了一样,上半截是上一帧,下半截是下一帧。用户反馈说“这手机拍照像得了帕金森”。后来我才明白,这是没处理好显示同步的问题。

这一章,我们就来彻底搞懂Camera和显示是怎么“握手”的。

VSYNC机制:显示系统的“心跳”

VSYNC,全称Vertical Synchronization,垂直同步。你可以把它想象成显示器的“心跳”。屏幕刷新不是一下子全刷新的,而是一行一行从上往下扫。扫到底部后,再跳回顶部重新开始。这个“跳回”的时刻,就是VSYNC信号发出的时机。

为什么要关心这个?因为如果你在屏幕正在扫描中间的时候,突然把Buffer里的数据换了,那画面就会撕裂——上半截是旧数据,下半截是新数据。

核心原则:Buffer的交换,必须发生在VSYNC信号到来时。也就是屏幕刚扫完一帧,准备扫下一帧的那个瞬间。

在Android系统中,VSYNC信号由SurfaceFlinger统一管理。它会定期向各个模块发送VSYNC事件。Camera HAL层需要做的就是:在VSYNC信号到来时,准备好新的图像数据

SurfaceFlinger与Camera Buffer流转

Camera预览的Buffer流转,其实是一条流水线。我画了个图,你一看就明白:

Camera预览Buffer流转图 Camera HAL 产生图像数据 dequeueBuffer Gralloc Buffer 共享内存 queueBuffer SurfaceFlinger 合成与调度 VSYNC触发 显示屏幕 最终呈现 Buffer返还复用 关键点:Camera HAL从Gralloc申请Buffer → 填充数据 → 交给SurfaceFlinger → VSYNC到来时送显 整个过程必须与VSYNC同步,否则会出现画面撕裂

流程其实不复杂:

  1. Camera HAL 从Gralloc分配器申请Buffer
  2. HAL把图像数据填进Buffer
  3. Buffer被queue到SurfaceFlinger的队列里
  4. SurfaceFlinger等到下一个VSYNC信号,才把Buffer交给显示硬件
  5. 显示硬件扫描屏幕,把画面呈现出来

我的经验:Buffer数量至少要有3个。一个正在被Camera填充,一个在SurfaceFlinger队列里等待,一个正在显示。这样才不会出现“等Buffer”的情况。我曾经只用了2个Buffer,结果帧率死活上不去,后来才发现是Buffer不够用。

显示刷新率与Camera帧率匹配

这里有个常见的误区:是不是Camera帧率越高越好?

不一定。如果你的屏幕刷新率是60Hz,Camera输出90fps,那多出来的30帧根本显示不出来,反而浪费功耗和带宽。反过来,如果屏幕是120Hz,Camera只输出30fps,那画面就会显得卡顿。

最佳实践是:Camera帧率 = 屏幕刷新率 或 屏幕刷新率的整数分之一

屏幕刷新率 推荐Camera帧率 说明
60Hz 30fps 或 60fps 30fps省电,60fps流畅
90Hz 30fps 或 45fps 或 90fps 注意45fps需要特殊处理
120Hz 30fps 或 60fps 或 120fps 120fps对ISP压力大

注意:不要盲目追求高帧率。我曾经在一个项目里,把Camera调到120fps,结果手机发热到烫手,电池半小时就空了。后来发现用户根本不需要那么高的帧率,60fps完全够用。

低延迟预览实现:Preview Buffer直接送显

传统的预览路径是:Camera → 内存 → SurfaceFlinger → 显示。这中间至少经过两次内存拷贝,延迟在30-50ms左右。

对于普通预览,这个延迟可以接受。但如果你在做AR应用、无人机图传、或者游戏直播,这个延迟就太大了。用户会感觉“画面跟不上手”。

怎么优化?Preview Buffer直接送显

思路是这样的:Camera HAL直接把Buffer的物理地址告诉显示硬件,跳过SurfaceFlinger的合成步骤。显示硬件直接从Camera的Buffer里读取数据。

// 伪代码示例:直接送显配置
// 在Camera HAL的configureStreams中

camera_stream_t previewStream;
previewStream.stream_type = CAMERA_STREAM_PREVIEW;
previewStream.width = 1920;
previewStream.height = 1080;
previewStream.format = HAL_PIXEL_FORMAT_IMPLEMENTATION_DEFINED;
previewStream.usage = GRALLOC_USAGE_HW_CAMERA_WRITE | 
                      GRALLOC_USAGE_HW_COMPOSER |  // 关键:直接给Composer用
                      GRALLOC_USAGE_HW_TEXTURE;

// 在processCaptureRequest中
// 直接使用显示驱动分配的Buffer,不做额外拷贝
buffer_handle_t buf = request->buffers[0].buffer;
// 填充YUV数据到buf
fill_yuv_data(buf, frame_data);
// 直接queue到显示队列
display_queue_buffer(buf);

这样做的好处是延迟可以降到10ms以内。但代价是:你失去了SurfaceFlinger的合成能力。如果界面上还有UI元素(比如拍照按钮),就需要用硬件Overlay来叠加。

核心要点:直接送显的关键是Buffer的Usage标记。必须同时包含GRALLOC_USAGE_HW_CAMERA_WRITEGRALLOC_USAGE_HW_COMPOSER,这样Gralloc才会分配一个Camera和显示硬件都能直接访问的Buffer。

实战:实现60fps无撕裂预览

好了,理论说完了,我们来点实际的。怎么实现一个60fps无撕裂的预览?

我总结了几步:

  1. 确认硬件能力:ISP能否稳定输出60fps?传感器是否支持60fps?显示面板是否支持60Hz?
  2. 配置Stream:在Camera HAL中,把预览Stream的fpsRange设置为[60,60]
  3. Buffer管理:至少分配4个Buffer,形成流水线
  4. VSYNC对齐:Camera的帧中断要和VSYNC对齐。我习惯在HAL里监听VSYNC事件,然后调整传感器的曝光时序
  5. 直接送显:如果延迟要求高,就用上一节说的直接送显方式
  6. 验证:用dumpsys SurfaceFlinger查看帧率,用systrace看是否有掉帧
// 实际项目中的VSYNC对齐代码片段
// 在Camera HAL的processCaptureRequest中

static int64_t last_vsync_time = 0;

void on_vsync_event(int64_t timestamp_ns) {
    last_vsync_time = timestamp_ns;
}

status_t processCaptureRequest(...) {
    // 等待下一个VSYNC
    int64_t current_vsync = last_vsync_time;
    int64_t frame_duration_ns = 1000000000LL / 60; // 60fps对应16.67ms
    
    // 计算下一个VSYNC时间
    int64_t next_vsync = current_vsync + frame_duration_ns;
    
    // 调整传感器曝光,使帧结束时间对齐到VSYNC
    sensor_set_exposure_time(next_vsync - frame_duration_ns - 2000000); // 预留2ms
    
    // 填充Buffer
    fill_buffer(request);
    
    return OK;
}

避坑指南:我曾经在某个平台上发现,即使代码写得再完美,预览还是偶尔会撕裂。查了三天,最后发现是显示驱动的一个Bug——它在VSYNC信号到来时没有正确等待Buffer更新完成。解决方案是给显示驱动打了一个补丁,在VSYNC处理函数里加了一个内存屏障。

最后,验证是否真的无撕裂,有个土办法:拍一个快速移动的物体,比如甩动的笔。如果画面是干净的,没有错位,那就说明同步成功了。如果看到画面像“撕开”了一样,那就回去检查VSYNC对齐吧。

嗯,这一章的内容就到这里。记住一句话:Camera和显示,就像两个人跳舞,步调必须一致。VSYNC就是那个节拍器。


公众号:蓝海资料掘金营,微信deep3321