RTMP 直播播放器卡顿排查与修复记录

环境信息

  • 平台:Windows 10/11,Qt 5.14.2 + MSVC2017 + FFmpeg(vcpkg)
  • 拉流地址:rtmp://192.168.0.***/live/desk,SRS 推流,纯视频(无音频轨,audioIdx=-1),编码器约 14.6fps,GOP=8
  • 客户端:自研 RambosPlayer(Qt/C++17)

问题现象

  1. 每次打开播放器连接直播流,画面首次出现要等约 5 秒。
  2. 播放器窗口最小化一段时间后还原,画面延迟明显增大,延迟量随最小化时长线性增长;全程不最小化则延迟正常。

排查后确认:两个现象有共同根因,详见问题四。

问题一:最小化期间无追赶机制,延迟随最小化时长线性增长

现象

日志显示窗口最小化期间,渲染端连续 47.7 秒无新帧,随后渲染出一帧,PTS 比上一帧跳跃 363 秒,期间丢帧计数器 dropCount 始终为 0。

原因分析

dropCount 自增语句位于音频时钟分支内:

double audioClock = sync_ ? sync_->audioClock() : -1.0;
 double diff = (audioClock >= 0.0) ? pts - audioClock : 0.0;
 if (audioClock >= 0.0 && diff < -0.4) {
     ++dropCount_;   // 该流无音频轨,audioClock 恒为 -1,此行永不执行
 }

 dropCount=0 不代表无丢帧发生,而是该计数逻辑从未被触发。真正执行路径是队列为空时的提前返回:

 if (!frame) {
     if (!frameQueue_ || !frameQueue_->tryPop(frame, 0)) {
         if (noFrameTimerStarted_ && noFrameTimer_.elapsed() > 500 && !noFrameLogged_) {
             qInfo() << "no frame for" << noFrameTimer_.elapsed() << "ms, dropCount=" << dropCount_;
             noFrameLogged_ = true;
         }
         return;   // 47.7 秒内反复落在此处,无追赶逻辑
     }
 }

根因:Windows 限流最小化窗口的消息泵/定时器调度,GUI 线程渲染定时器长时间不被调度;本地队列(约 0.3~0.4 秒缓冲)很快填满,反压传导至网络读取线程使其暂停读取;积压堆积在 TCP 缓冲区与 SRS
服务端发送队列中,本地无法感知。窗口恢复后只能按 1x 速度播完全部积压。

解决方案

在节拍控制分支中新增落后量检测,超过阈值丢弃本地积压旧帧并跳到最新帧,超过更高阈值触发整条流重连:

  double behindSec = (actualMs - expectedMs) / 1000.0;
  if (behindSec > kCatchUpBehindSec) {           // 1.0s
      while (frameQueue_ && frameQueue_->tryPop(newer, 0)) {
          av_frame_free(&frame);
          frame = newer;
          ++skipped;
      }
      if (behindSec > kForceReconnectBehindSec && !reconnectRequested_) {  // 5.0s
          emit fellBehindLive(behindSec);
      }
  }

另增加独立看门狗线程,避免依赖同样受限流影响的 GUI 定时器:

void VideoRenderer::startWatchdog() {
     lastOnTimerEpochMs_.store(QDateTime::currentMSecsSinceEpoch(), std::memory_order_relaxed);
     watchdog_ = QThread::create([this] {
         bool stalled = false;
         qint64 stallStartMs = 0;
         while (!QThread::currentThread()->isInterruptionRequested()) {
             QThread::msleep(300);
             qint64 gap = QDateTime::currentMSecsSinceEpoch()
                        - lastOnTimerEpochMs_.load(std::memory_order_relaxed);
             if (!stalled && gap > 1000) {
                 stalled = true;
                 qWarning() << "onTimer 已停止被调度,距上次调用已过去" << gap << "ms";
             } else if (stalled && gap <= 1000) {
                 qInfo() << "onTimer 恢复调度";
                 stalled = false;
             }
         }
     });
     watchdog_->start();
 }

说明:现有日志中未出现过 catching up to live 或看门狗停摆记录,原因是问题二的触发机制总是更早生效。该逐帧追赶/看门狗逻辑目前未被真实场景验证触发,仅作为设计上的兜底保留。

问题二:触发条件仅依赖 isMinimized(),遮挡场景检测不到

现象

修复问题一后,真正最小化场景测试正常;用其他窗口遮挡播放器(未最小化)数分钟后切回,仍有延迟。

原因分析

对比两组日志:

最小化场景:

  applicationStateChanged -> Qt::ApplicationInactive
  windowStateChange isMinimized= true
  windowStateChange isMinimized= false
  applicationStateChanged -> Qt::ApplicationActive

遮挡场景:全程只有 applicationStateChanged -> Inactive,无 isMinimized= true 记录。

原触发逻辑挂在 isMinimized() 状态变化上,遮挡场景该值全程为 false,触发条件不可能满足。

解决方案

改为监听 QGuiApplication::applicationStateChanged,该信号在两种场景下均正确变化:

  connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) {
      if (state == Qt::ApplicationActive && player_ && player_->isOpened()
          && player_->isNetworkStream()) {
          player_->forceLiveResync();
      }
  });

验证

[22:03:32.032] windowStateChange isMinimized= true
[22:06:56.375] windowStateChange isMinimized= false
[22:06:56.375] applicationStateChanged -> Qt::ApplicationActive
[22:06:56.376] regained foreground, forcing live resync
[22:06:56.376] forcing stream reconnect
[22:06:56.982] render frame pts= 0.543 (after 406 ms, drops= 0 )

问题三:本地追赶判断指标失真,误判跳过重连

背景

为减少重连黑屏感,加入"恢复前台时先本地丢帧追赶,足够则跳过重连"的优化,依据"上次渲染时刻+经过时间"反推预期 PTS,与队列最新帧比较算出落后量。

验证方法与数据

设计三组等待时长差异明显的对照测试,记录结果:

测试 等待时长 behind
1 79.82s -0.763
2 4.45s -0.724
3 137.14s -0.77

原始日志(测试 2):

[20:55:11.005] windowStateChange isMinimized= true
[20:55:15.450] windowStateChange isMinimized= false
[20:55:15.452] tryLocalCatchUp skipped 10 frame(s), newest pts= 417.21 expected~ 416.486 behind= -0.724
[20:55:15.452] local catch-up sufficient, skipping reconnect

原因分析

等待时长跨度约 31 倍,behind 结果几乎不变,说明该指标测量的是流水线固有缓冲深度(结构性常量),而非真实落后量。该指标几乎总判定"足够",实质上绕过了问题一/二已验证有效的重连机制,且无任何报错提示。

解决方案

撤回该优化,恢复直接重连:

  void PlayerController::forceLiveResync() {
      videoPacketQ_.abort();
      audioPacketQ_.abort();
      videoFrameQ_.abort();
      demux_.requestReconnect();
  }

问题四:重连(及首次连接)固定卡 5 秒

现象

重连机制生效后,每次触发仍伴随约 5.2~5.8 秒黑屏。

原因分析

对 avformat_open_input、avformat_find_stream_info 分别加计时:

  QElapsedTimer stepTimer;
  stepTimer.start();
  int ret = avformat_open_input(&fmtCtx_, url_.toUtf8().constData(), nullptr, &opts);
  qInfo() << "avformat_open_input took" << stepTimer.elapsed() << "ms";
  stepTimer.restart();
  bool probeOk = (ret == 0 && avformat_find_stream_info(fmtCtx_, nullptr) >= 0);
  qInfo() << "avformat_find_stream_info took" << stepTimer.elapsed() << "ms";

实测(修改前):

  avformat_open_input took 177ms / 191ms / 197ms
  avformat_find_stream_info took 5059ms / 5063ms / 5064ms

首次打开路径用同样计时测得同样约 5059ms 的耗时,证明问题一、问题四共享同一根因,首次连接 5 秒延迟此前未被注意是因为发生在画面出现之前。

定位代码:

r.fmtCtx->max_analyze_duration = 0; // 原意:拿到 SPS/PPS 立即返回,实测退化为默认 5 秒探测窗口

解决方案

  r.fmtCtx->max_analyze_duration = AV_TIME_BASE;  // 1 秒
  r.fmtCtx->probesize = 32 * 1024;                // 32KB

  probeOpen() 与 reconnect() 两处同步修改。

验证

  avformat_open_input took 206ms
  avformat_find_stream_info took 22ms
  ...
  avformat_open_input took 191ms
  avformat_find_stream_info took 2ms

  avformat_find_stream_info 由 5059/5064ms 降至 22ms/2ms;"切回前台→触发重连→画面恢复"总耗时由 5.6~5.8s 降至 0.6s

总结

编号 问题 根因 解决方案
1 最小化恢复延迟累积 无音频流的丢帧逻辑未触发,阻塞队列反压导致积压不可见 本地追赶 + 看门狗 + 强制重连
2 遮挡场景检测不到 触发条件仅判断 isMinimized() 改用 applicationStateChanged
3 本地追赶误判 指标测量的是结构性常量而非真实落后量 撤回优化,恢复直接重连
4 重连/首次连接卡 5 秒 max_analyze_duration=0 未按预期立即返回 改为 AV_TIME_BASE + probesize

github地址:https://github.com/johnjiamzhong-project/RambosPlayer

posted @ 2026-06-19 00:22  rambos1996  阅读(10)  评论(0)    收藏  举报