Seek 实现详解:播放器里最容易写“表面可用、实际不稳”的功能

系列导航

1. 为什么 Seek 难

Seek 不是单函数逻辑,而是跨线程、跨模块的一次“状态迁移”。

如果处理顺序不对,常见现象是:

  • 拖动后画面跳了,但声音还在旧位置。
  • 拖动后短时间黑屏或花屏。
  • 连续拖动后偶发卡死或崩溃。

2. 本项目的 Seek 事务拆解

当前实现可以概括为“六步事务”:

  1. 标记进入 seek 态。
  2. 暂停音视频解码。
  3. Demuxer 执行 av_seek_frame
  4. 清空包/帧队列并 flush 解码器。
  5. 重置同步时钟。
  6. 解除 seek 态并恢复解码。

这套流程分散在 PlayerDemuxerDecoder 三个模块。

图解:Seek 事务时序

Seek Sequence Diagram

图注:先由 Player 冻结解码,再由 Demuxer 执行 av_seek_frame + clear + flush + reset,最后恢复解码器消费。

3. 入口:Player::seek

Player::seek(positionMs) 里做了几件关键事:

  • 范围校验(避免越界 seek)。
  • m_isSeeking = true 防止 UI 进度被旧时钟覆盖。
  • m_currentSeekId 递增,支持“新 seek 覆盖旧 seek”。
  • 通过 QMetaObject::invokeMethod(..., Qt::QueuedConnection) 异步执行。

这一步把 seek 做成“可重入、可覆盖”的异步请求,而不是阻塞 UI。

对应代码(Player.cpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Player::seek(int64_t positionMs)
{
if (positionMs < 0 || positionMs >= m_durationMs) return; // [1] 越界保护

m_isSeeking.store(true); // [2] 进入 seek 态
const int seekId = ++m_currentSeekId; // [3] 递增请求 ID(新请求覆盖旧请求)
m_pendingSeek.store(true); // [4] 标记 seek 处理中
emit positionChanged(positionMs); // [5] 先更新 UI,提升拖动手感

QMetaObject::invokeMethod(this, [this, positionMs, seekId]() {
if (seekId != m_currentSeekId.load()) return; // [6] 过期请求直接丢弃

m_audioDecoder->pause(); // [7] 冻结解码消费
m_videoDecoder->pause(); // [8]
m_demuxer->seek(positionMs); // [9] 交给 demux 线程执行真实跳转
m_audioDecoder->resume(); // [10] 解冻
m_videoDecoder->resume(); // [11]
m_pendingSeek.store(false); // [12] seek 事务结束
}, Qt::QueuedConnection);
}

代码阅读提示:

  • [3] + [6] 是连续拖动时不卡的关键机制。
  • [7][8][9][10][11] 对应“先冻结、再跳转、后解冻”的事务顺序。

4. 执行:Demuxer::demuxLoop 中处理 seekPending

Demuxer 线程检测到 m_seekPending 后进入专用分支:

  1. seekRequested,通知解码器进入 seek 模式。
  2. 调用 av_seek_frame 跳转到目标时间。
  3. 成功后清理四个队列(音频包、视频包、音频帧、视频帧)。
  4. avcodec_flush_buffers 清掉解码器内部缓存。
  5. 重置同步器时钟。
  6. seekCompleted,通知各模块恢复。

这一步是“旧数据切断”的核心,没有它就会出现“拖动后先播一段旧帧”的倒灌问题。

对应代码(Demuxer::demuxLoop()):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (m_seekPending.load()) {
QMutexLocker locker(&GlobalDecoderLock::instance()); // [1] seek + flush 期间加全局锁
int64_t target = av_rescale_q(m_seekTargetUs.load() * AV_TIME_BASE,
AV_TIME_BASE_Q,
m_formatCtx->streams[m_videoStreamIndex]->time_base);

Q_EMIT seekRequested(); // [2] 通知解码器切 seek 模式
int ret = av_seek_frame(m_formatCtx, m_videoStreamIndex, target, AVSEEK_FLAG_BACKWARD);
if (ret >= 0) {
m_videoPacketQueue->clear(); // [3] 清掉旧包/旧帧,避免倒灌
m_videoFrameQueue->clear();
m_audioPacketQueue->clear();
m_audioFrameQueue->clear();

avcodec_flush_buffers(m_audioContext); // [4] 清空 codec 内部缓存
avcodec_flush_buffers(m_videoContext);
AVSynchronizer::instance().reset((double)target / AV_TIME_BASE); // [5] 重置时钟基准
m_seekPending.store(false); // [6] seek pending 清零
}
Q_EMIT seekCompleted(); // [7] 通知上层恢复正常消费
continue;
}

代码阅读提示:

  • [3][4] 缺一不可:只清队列不 flush,或只 flush 不清队列都会残留旧数据。
  • [5] 解决的是 seek 后一段时间音画不同步的问题。

5. 解码器协作:Decoder::handleSeekRequest / handleSeekCompleted

Decoder 在 run loop 里会检查 m_seek 标记:

  • seek 期间跳过包消费,避免旧包继续解码。
  • seek 完成后恢复正常消费。

同时,pause()/resume() 通过条件变量控制线程等待与唤醒,保证 seek 阶段不会持续推进解码。

对应代码(Decoder.cpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto packet_ptr = m_packetQueue.dequeue();
if (!packet_ptr || m_seek.load()) {
continue; // [1] seek 期间跳过旧包消费
}

void Decoder::handleSeekRequest()
{
m_seek.store(true); // [2] 进入 seek 态
}

void Decoder::handleSeekCompleted()
{
m_seek.store(false); // [3] 恢复正常解码
}

代码阅读提示:

  • [1] 这行让解码线程在 seek 期间“只等待,不乱解”。
  • [2][3] 由 Demuxer 的 seekRequested/seekCompleted 驱动,是跨线程协作点。

6. 为什么还要有 m_currentSeekId

用户拖动滑条时,短时间内会产生多个 seek 请求。

m_currentSeekId 的意义是:

  • 只让最新请求生效。
  • 旧请求即便排队到执行点,也会被主动丢弃。

这对“快速拖拽预览”非常关键。

7. 当前实现中值得肯定的点

  1. seek 流程不是单点函数,已经具备事务意识。
  2. 有解码暂停/恢复配合,降低并发冲突。
  3. 有 seek 请求覆盖机制,交互体验更稳定。
  4. 有时钟重置,避免 seek 后同步漂移持续扩大。

8. 需要重点关注的坑位

8.1 时间单位统一

接口命名是 positionMs,Demuxer 的注释是“微秒”。

建议统一约定并显式转换,避免“看起来能跳,但总有偏差”。

8.2 全局解码锁的临界区

seek 时使用全局锁保护 av_seek_frame + flush 是正确方向,但临界区过大时会抬高阻塞时间,需要平衡。

8.3 进度条回写

seek 期间已通过 m_isSeeking 抑制旧位置回写,这一步非常必要。建议在“seek 完成 + 首帧到达”后再解除,体验更稳。

8.4 连续 seek 的节流策略

当前 onSeek 会在滑动时直接触发。对于 RTSP 场景,建议增加 30~80ms 防抖,减少无效 seek 风暴。

9. 建议的增强版 Seek 状态机

1
2
3
4
5
6
7
8
IDLE
-> SEEK_REQUESTED
-> SEEK_FREEZE_PIPELINE
-> SEEK_DO_JUMP
-> SEEK_DRAIN_OLD_DATA
-> SEEK_RESET_CLOCK
-> SEEK_WAIT_FIRST_FRAME
-> IDLE

其中 SEEK_WAIT_FIRST_FRAME 很实用:

  • 收到新位置第一帧后再开放 UI 进度自动刷新。
  • 能明显减少“拖完后进度条来回跳”的视觉抖动。

10. 可复用的 Seek 伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function seek(target_ms):
if target_ms out_of_range:
return

mark_seeking(true)
seek_id = ++current_seek_id
emit_ui_position(target_ms)

async:
if seek_id != current_seek_id:
return

pause_decoders()
demuxer.request_seek(target_ms)

# demux thread:
# 1) notify seekRequested
# 2) av_seek_frame
# 3) clear packet/frame queues
# 4) avcodec_flush_buffers
# 5) reset clocks
# 6) notify seekCompleted

resume_decoders()
mark_seeking(false)

11. 测试用例建议

至少覆盖以下场景:

  1. 本地 MP4:每 1 秒拖动一次,连续 30 次。
  2. RTSP:快速来回拖动,观察是否卡死。
  3. 暂停态 seek:拖动后恢复播放是否正确。
  4. 临近结尾 seek:接近末尾跳转后是否正常结束。
  5. stop 后 seek:确保不触发野指针路径。

12. 小结

Seek 的本质是“跨线程数据流切换”。

这套实现已经具备可用骨架,关键是继续把三件事做扎实:

  • 单位统一(ms/us)。
  • 状态机收敛(显式阶段)。
  • 连续拖动治理(覆盖 + 防抖 + 首帧确认)。

这三点到位后,seek 的稳定性和手感会提升一个层级。


系列上一篇