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

先说需求
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 线程,用信号桥接异步结果。

浙公网安备 33010602011771号