RambosPlayer 拉流播放实战:从异步探测到自动重连的完整设计

最近给 RambosPlayer 多媒体播放器加了拉流播放功能,支持 RTMP、RTSP、HTTP-FLV、SRT 四种协议的网络流拉取。这篇文章记录整个功能的设计思路和踩坑经验。
效果图:
27

先说需求

RambosPlayer 之前已经能推流(把本地视频推到 RTMP 服务器或局域网浏览器),但反过来从网络拉流播放一直没做。实际场景中拉流需求很常见:看监控摄像头的 RTSP 流、从 SRS 拉 RTMP 直播、两台播放器之间通过 HTTP-FLV
直接互传视频。

拉流和推流本质上共享同一个 av_read_frame 循环,区别只在数据源是网络而非本地文件。但网络流有三个本地文件没有的问题:连接可能阻塞数秒、随时可能断线、直播流没有总时长。

异步探测:probeOpen() 的设计

这是整个功能最关键的一环。

avformat_open_input 打开网络流时要 DNS 解析、TCP 握手,RTSP 还有 DESCRIBE/SETUP/PLAY 交互,整个过程可能 3-5 秒。如果在 UI 线程做这件事,界面直接卡死。

我的做法是把探测过程拆成一个纯静态方法,完全不访问 this 的任何成员:

  class DemuxThread : public QThread {
  public:
      struct ProbeResult {
          bool ok = false;
          AVFormatContext* fmtCtx = nullptr;
          int videoIdx = -1;
          int audioIdx = -1;
          int64_t duration = 0;
          bool isNetwork = false;
          QString url;
      };

      static ProbeResult probeOpen(const QString& path);
      void adopt(const ProbeResult& r,
                 FrameQueue<AVPacket*>* videoQueue,
                 FrameQueue<AVPacket*>* audioQueue);
  };

probeOpen() 是静态方法,可以在任何线程调用。adopt() 在主线程调用,负责把探测结果写入成员变量。这样探测过程完全不碰 this,线程安全问题天然不存在。

PlayerController 用 QThread::create 启动一个 worker 线程跑探测:

  void PlayerController::open(const QString& path) {
      stop();
      ++probeGeneration_;
      int gen = probeGeneration_;
      auto result = std::make_shared<DemuxThread::ProbeResult>();

      QThread* worker = QThread::create([path, result]{
          *result = DemuxThread::probeOpen(path);
      });
      connect(worker, &QThread::finished, this, [this, worker, result, gen]{
          worker->deleteLater();
          onProbeFinished(result, gen);
      });
      worker->start();
  }

这里有个 probeGeneration_ 计数器,用来处理一个边界场景:用户输入 URL 点"连接",等了两秒没连上,又改了 URL 再点"连接"。此时有两个 worker 在跑,第一个的回调不应该被执行。onProbeFinished 里检查 gen
是否匹配当前值,不匹配就丢弃结果释放 fmtCtx。

网络超时保护

avformat_open_input 默认没有超时,网络不通会永久阻塞。需要根据协议类型设置不同的超时选项:

static AVDictionary* buildNetworkOptions(const QString& url, bool& isNetwork) {
    AVDictionary* opts = nullptr;
    if (url.startsWith("rtsp://", Qt::CaseInsensitive)) {
        isNetwork = true;
        av_dict_set(&opts, "rtsp_transport", "tcp", 0);
        av_dict_set(&opts, "stimeout", "5000000", 0);   // 5 秒
    } else if (url.startsWith("http://", Qt::CaseInsensitive) ||
               url.startsWith("https://", Qt::CaseInsensitive)) {
        isNetwork = true;
        av_dict_set(&opts, "rw_timeout", "5000000", 0); // 5 秒
    } else if (url.startsWith("rtmp://", Qt::CaseInsensitive) ||
               url.startsWith("srt://", Qt::CaseInsensitive)) {
        isNetwork = true;
    }
    return opts;
}

RTSP 特别指定了 rtsp_transport=tcp,因为 UDP 在某些网络环境下丢包严重。调用方在 avformat_open_input 返回后必须 av_dict_free(&opts) 释放未被消费的选项,否则内存泄漏。

自动重连:reconnect()

本地文件读到 EOF 就结束线程,但直播流断线后应该重连。重连逻辑的核心是一个 2 秒间隔的循环:

bool DemuxThread::reconnect() {
     emit networkStateChanged(static_cast<int>(NetworkState::Reconnecting));
     if (fmtCtx_) avformat_close_input(&fmtCtx_);
     videoIdx_ = -1;
     audioIdx_ = -1;

     constexpr int kRetryIntervalMs = 2000;
     while (!abort_.load(std::memory_order_relaxed)) {
         AVDictionary* opts = buildNetworkOptions(url_, isNetwork_);
         int ret = avformat_open_input(&fmtCtx_, url_.toUtf8().constData(),
                                        nullptr, &opts);
         av_dict_free(&opts);

         if (ret == 0 && avformat_find_stream_info(fmtCtx_, nullptr) >= 0) {
             for (unsigned i = 0; i < fmtCtx_->nb_streams; ++i) {
                 auto type = fmtCtx_->streams[i]->codecpar->codec_type;
                 if (type == AVMEDIA_TYPE_VIDEO && videoIdx_ < 0) videoIdx_ = (int)i;
                 if (type == AVMEDIA_TYPE_AUDIO && audioIdx_ < 0) audioIdx_ = (int)i;
             }
             emit networkStateChanged(static_cast<int>(NetworkState::Connected));
             return true;
         }

         if (fmtCtx_) avformat_close_input(&fmtCtx_);

         for (int waited = 0; waited < kRetryIntervalMs &&
              !abort_.load(std::memory_order_relaxed); waited += 100)
             QThread::msleep(100);
     }

     emit networkStateChanged(static_cast<int>(NetworkState::Disconnected));
     return false;
 }

关键细节是 sleep 的写法。不能直接 QThread::msleep(2000),因为用户在等待期间点"断开"需要立即响应。拆成 100ms 的小段循环,每次检查 abort_ 标志,确保用户操作不被阻塞。

NetworkState 状态机

定义了四个状态:Disconnected、Connecting、Connected、Reconnecting。状态变化通过 Qt 信号链传递:DemuxThread → PlayerController → MainWindow。用 int 而非枚举类型传递信号,省去了 qRegisterMetaType 的注册步骤。

MainWindow 收到信号后更新状态栏标签,同时根据状态控制按钮文案(连接/断开)和输入框的可用性。

码率和帧率统计

在 av_read_frame 主循环中嵌入统计逻辑,用 QElapsedTimer 做 1 秒滑动窗口:

statsBytes_ += pkt->size;
 if (pkt->stream_index == videoIdx_) ++statsVideoFrames_;

 qint64 elapsed = statsTimer_.elapsed();
 if (elapsed >= 1000) {
     int kbps = (int)(statsBytes_ * 8 / elapsed);
     double fps = statsVideoFrames_ * 1000.0 / elapsed;
     emit statsUpdated(kbps, fps);
     statsBytes_ = 0;
     statsVideoFrames_ = 0;
     statsTimer_.restart();
 }

没有用额外的 QTimer,因为统计逻辑本身就在读包循环里,每读一个包顺便累加,满 1 秒就发信号,开销几乎为零。

直播模式的 UI 适配

直播流没有 duration,avformat_find_stream_info 返回的 fmtCtx->duration 为 0。进度条需要禁用并显示 "LIVE":

  void MainWindow::onDurationChanged(int64_t ms) {
      duration_ = ms;
      if (ms <= 0) {
          ui->progressSlider->setEnabled(false);
          ui->timeLabel->setText("LIVE");
      } else {
          ui->progressSlider->setEnabled(true);
          ui->timeLabel->setText("00:00 / " + formatTime(ms));
      }
  }

拉流地址也有记忆功能:每次成功连接后用 QSettings 存储 URL,下次打开拉流面板自动回填,重复测试同一个流不用反复输入。

FFmpeg 版本兼容的一个坑

在适配 ARM64 板卡时发现,FFmpeg 7.0 把 avio_alloc_context 的 write_packet 回调签名从 uint8_t* 改成了 const uint8_t*。板卡用的是 FFmpeg 4.x(旧版),本机开发用的是 7.0,同一份代码编译报错。

解决方案是用条件编译定义类型别名:

  #if LIBAVFORMAT_VERSION_MAJOR >= 61
  using AvioBuf = const uint8_t;
  #else
  using AvioBuf = uint8_t;
  #endif

  static int writeCallback(void* opaque, AvioBuf* buf, int size);

HttpFlvServer 和 MpegTsServer 共用这个别名,编译时自动适配链接的 FFmpeg 版本。

两台播放器直连

拉流功能一个有趣的用法是两台 RambosPlayer 之间直接互传视频。A 端打开本地视频后开启 HTTP-FLV 推流(内置在 8080 端口),B 端在拉流播放地址栏输入
http://A的IP:8080/stream.flv ,点击连接即可实时观看。整个过程零配置,不需要 SRS 等外部服务器,只要两台设备在同一局域网。

设计决策总结

为什么用静态方法而不是普通成员函数做探测? 静态方法不访问 this,天然线程安全,可以在任意 worker 线程调用,不需要加锁。

为什么用 probeGeneration_ 而不是取消旧线程? 取消一个正在执行 avformat_open_input 的线程很困难(FFmpeg 没有提供中断回调的简单接口),用计数器丢弃过期结果更简单可靠。

为什么重连用分段 sleep? 直接 sleep 2 秒会阻塞用户操作响应。100ms 步进的循环确保用户点"断开"后最多等 100ms 就能退出。

为什么状态用 int 传递? Qt 的跨线程信号对自定义类型需要 qRegisterMetaType 注册,用 int 省去这一步,代码更简洁。

整个功能新增约 600 行代码,涉及 FFmpeg 网络协议、异步线程编程、状态机设计、UI 适配、版本兼容等多个技术点。核心思路就是一句话:把阻塞操作移出 UI 线程,用信号桥接异步结果。

github: https://github.com/johnjiamzhong-project

posted @ 2026-06-15 18:37  rambos1996  阅读(16)  评论(0)    收藏  举报