1.什么是模块化架构?
模块化架构是一种软件设计方法,它将一个大型系统分解为若干个独立的、可替换的模块,每个模块封装了特定的功能,并通过明确的接口与其他模块交互。这种架构的核心目标是降低系统的复杂性,提高可维护性、可测试性和可复用性。
2.FFmpeg实现无拷贝解码主要依赖于以下几种机制:
基于硬件加速(最主流的方式)
这是最常见且最有效的零拷贝场景。其基本思路是让解码器输出一个“引用”或“句柄”,指向硬件设备(如GPU)内存中的数据,而不是将实际像素数据下载到系统内存中。
- 使用硬件像素格式:解码器会输出特定的硬件像素格式(如
AV_PIX_FMT_VAAPI、AV_PIX_FMT_CUDA、AV_PIX_FMT_MMAL、AV_PIX_FMT_DRM_PRIME)。这种格式的AVFrame中的数据指针 (data[0],data[1]) 并非指向普通的CPU内存,而是指向一个由硬件驱动管理的特殊内存对象。 - 获取硬件帧上下文 (
AVHWFramesContext):解码上下文 (AVCodecContext) 中的hw_frames_ctx字段携带了关于硬件帧的重要信息,如内存池、像素格式、宽高等。其他需要处理这些帧的模块(如滤镜、编码器)可以通过此上下文来正确访问硬件帧。 - 避免
av_hwframe_transfer_data():在传统的硬解+回读场景中,需要使用此函数将数据从GPU传输到CPU。要实现零拷贝,就需要避免调用此函数,而是直接将硬件帧传递给下一个处理阶段。
基于引用计数和内存复用
在FFmpeg内部,即使是在软件层面,也通过引用计数机制来避免不必要的拷贝。
- 引用语义:
AVPacket和AVFrame本质上是容器。av_packet_ref()和av_frame_ref()函数并非复制底层数据,而是创建一个指向同一数据缓冲区的新引用,并增加其引用计数。只有当所有引用都被释放 (av_packet_unref()/av_frame_unref()) 时,底层数据才会被真正销毁。 - 直接传递指针:在解码器-滤镜-编码器的流水线中,通过传递这些引用,可以避免在模块间复制整个帧的像素数据。
3.什么是 Pimpl 策略?
Pimpl(Pointer to Implementation,指向实现的指针)是一种 C++ 惯用法,用于将类的内部实现细节与其公共接口完全分离。其核心思想是:在头文件中只声明一个指向实际实现类的指针(通常是一个不透明指针),而将所有的私有成员函数和变量都放到这个实现类中。这样,公共头文件只暴露必要的接口,所有实现细节都被隐藏。
// widget.h(公共头文件)
class Widget {
public:
Widget();
~Widget();
void doSomething();
private:
class Impl; // 前置声明
std::unique_ptr<Impl> pImpl; // 指向实现的指针
};
// widget.cpp(实现文件)
#include "widget.h"
#include <iostream>
#include <vector>
class Widget::Impl {
public:
void doSomething() {
std::cout << "内部实现" << std::endl;
}
private:
std::vector<int> data; // 隐藏的数据成员
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 必须在此处定义,因为 Impl 已完整
void Widget::doSomething() { pImpl->doSomething(); }
SDK在这里的实现方式实现源文件(sdk.cpp)
class MySDK::VideoProcessor::Impl {
public:
bool open(const char* filename) {
return true;
}
void start() {
worker = std::thread([this] { run(); });
}
void stop() {
// 停止并等待
if (worker.joinable())
worker.join();
}
private:
void run() { /* 处理循环 */ }
std::thread worker;
std::vector<uint8_t> buffer;
// ... 其他私有成员
};
// 构造函数
MySDK::VideoProcessor::VideoProcessor()
: pImpl(std::make_unique<Impl>()) {}
// 析构函数必须在实现文件中定义,因为此时 Impl 是完整类型
MySDK::VideoProcessor::~VideoProcessor() = default;
// 公共方法转发
bool MySDK::VideoProcessor::open(const char* filename) {
return pImpl->open(filename);
}
void MySDK::VideoProcessor::start() {
pImpl->start();
}
void MySDK::VideoProcessor::stop() {
pImpl->stop();
}
跨模块内存管理
如果 SDK 以动态库形式提供,客户和库可能使用不同的堆(例如在 Windows 上,调试版和发布版使用不同的 CRT)。直接使用 std::unique_ptr 的默认删除器可能导致崩溃,因为删除操作发生在客户模块,而 new 发生在库模块。解决方案:
- 使用
std::shared_ptr:shared_ptr的控制块在同一 DLL 内分配,删除器也随控制块,因此可以安全跨模块。 - 自定义删除器:为
unique_ptr提供来自库内部的删除器,例如std::unique_ptr<Impl, void(*)(Impl*)>并传递库导出的删除函数。 - 导出销毁函数:提供
void destroyVideoProcessor(VideoProcessor* p),由库负责释放。
最简洁且安全的方式是让库提供工厂函数,返回 std::unique_ptr 配合自定义删除器:
Pimpl 策略是 C++ 中实现“接口与实现分离”的经典手段,特别适合用于封装 SDK。它通过一个不透明指针隐藏所有内部细节,保证了 ABI 稳定、编译隔离和代码保密。在实际应用中,需注意跨模块内存管理问题,并合理设计接口以提供清晰的用户体验。正确使用 Pimpl 可以显著提升 SDK 的可维护性和健壮性。
4.如何修改CMake去完成静态库文件的产出
| 步骤 | 动态库配置 | 静态库配置 |
|---|---|---|
| 1. 库类型 | SHARED |
STATIC |
| 2. 导出宏 | 需要定义并应用 | 移除所有导出宏相关代码 |
| 3. 编译定义 | 添加 -DMYPLAYER_LIBRARY |
删除该定义 |
| 4. 安装规则 | 包含 RUNTIME、LIBRARY、ARCHIVE |
仅保留 ARCHIVE |
| 5. 公共头文件 | 类前加 MYPLAYER_EXPORT |
类前不加任何宏 |
| 6. 生成导出头 | 可能需要 | 完全不需要 |
典型的打包结构如下:
MyPlayerSDK/
├── include/
│ └── myplayer/
│ ├── player.h
│ └── videooutput.h
├── lib/
│ ├── libmyplayer.a # 静态库
│ ├── libmyplayer.so # 动态库(或带版本号的 so)
│ └── pkgconfig/
│ └── myplayer.pc # pkg-config 配置(可选,推荐)
├── examples/
│ ├── demo.cpp
│ └── Makefile.example
└── README.md
5.你的项目里锁的精度以及线程安排
- 队列锁(
RingBuffer内部锁)
- 粒度:粗粒度(每个队列一把互斥锁,保护整个队列状态)。
- 适用场景:单生产者-单消费者(SPSC)或多个队列分离时,竞争较小,性能足够。
- 优化建议:
- 若未来出现多生产者-多消费者(MPMC)或极高帧率,可考虑使用无锁队列(如
boost::lockfree::spsc_queue)或分段锁。 - 当前设计下,各队列独立(视频包队列、音频包队列、视频帧队列、音频帧队列),锁竞争分散,可接受。
- 若未来出现多生产者-多消费者(MPMC)或极高帧率,可考虑使用无锁队列(如
- 解码器锁(
video_codec_mutex_,audio_codec_mutex_,swr_mutex_)
- 粒度:中等粒度(每个解码器/重采样器一把锁)。
- 作用:保护 FFmpeg 非线程安全的解码上下文和重采样器,确保同一时间只有一个线程访问。
- 优化建议:
- 锁的持有时间应尽量短,避免在锁内执行耗时操作(如日志、队列操作)。已在
decodeVideoPacket中只在 FFmpeg 调用时加锁,处理帧时解锁,这是合理的。 - 对于
flush()和seek()等操作,它们会持有锁进行刷新,可能短暂阻塞解码线程,但频率低,影响小。
- 锁的持有时间应尽量短,避免在锁内执行耗时操作(如日志、队列操作)。已在
- 状态锁(保护
pause_fetch_,state_等)
- 粒度:细粒度(使用原子变量,无锁)。
- 实现:将
pause_fetch_、state_、stop_requested_改为std::atomic,实现无锁读/写,性能高。 - 注意:当与条件变量配合时,仍需要互斥锁来保护条件变量的等待和通知,原子变量仅用于状态检查,不替代条件变量的锁。
- 全局锁(如
m_codecMutex在旧代码中)
- 粒度:过粗(一把锁保护多个解码器)。
- 问题:早期代码中
m_codecMutex同时保护视频和音频解码器,导致视频和音频解码线程相互阻塞,浪费并行性。 - 改进:已拆分为独立锁,粒度细化到每个资源。
6.音视频同步策略
核心公式
- 对齐偏移:
av_offset = frame_pts_ms - audio_time_ms(第一帧对齐时计算)。 - 期望音频时间:
expected_audio_time = frame_pts_ms - av_offset。 - 同步差值:
sync_diff = expected_audio_time - audio_time_ms。 差值 > 0 表示帧“提前”于音频,需要等待;差值 < 0 表示帧“落后”于音频,可能丢弃。
对齐机制
- 仅在
sync_state_.aligned为 false 且音频时间有效时计算一次偏移。这假设音视频起始点相同,通常正确。但若播放中途因 seek 或长时间不同步导致偏移变化,当前机制不会重新对齐。可以考虑在差值持续过大时触发重对齐。
同步决策(checkSync)
| 条件 | 动作 | 说明 |
|---|---|---|
sync_diff < -drop_threshold_ms_ |
关键帧:显示;非关键帧:丢弃 | 严重过时,非关键帧丢弃避免累积延迟 |
sync_diff < 0 |
显示 | 稍微落后,仍可显示(人眼可容忍) |
0 <= sync_diff <= sync_threshold_ms_ |
显示 | 在同步窗口内 |
sync_diff <= drop_threshold_ms_ |
等待 | 提前但未超过丢弃阈值,等待 |
sync_diff > drop_threshold_ms_ |
关键帧:等待;非关键帧:丢弃 | 严重提前,非关键帧丢弃,关键帧等待 |
阈值设定:通常 sync_threshold_ms_ 取 30~50ms,drop_threshold_ms_ 取 200~300ms,合理。
等待策略
calculateWaitTime限制等待时间不超过帧间隔(约 33ms@30fps)且 ≤50ms,避免长时间阻塞渲染线程。- 等待后重新计算差值,若仍不满足则继续循环,确保帧在恰当时间显示。
7.用了什么设计模式
生产者-消费者模式,单例模式,工厂模式,Pimpl模式。
8.数据传递链路
UrlResolver 与工厂
- 职责:根据传入的 URL(如
http://.../playlist.m3u8、file:///...)解析协议类型,并通过工厂模式创建对应的解复用器实例(如HLSDemuxer、FileDemuxer)。 - 数据传递:返回一个抽象基类指针(如
BaseDemuxer*),后续模块通过该接口操作解复用器。
Demuxer(解复用器)
- 职责:封装 FFmpeg 的
AVFormatContext和相关操作。提供以下接口:readPacket():读取下一个 AVPacket(可能是视频、音频或字幕)。- 获取流信息(视频流索引、音频流索引、时间基等)。
- 内部机制:通常
readPacket()内部调用av_read_frame(),这是一个阻塞调用,因此需要在独立线程中执行(你的fetchThread)。
Fetcher 线程
- 职责:持续从 Demuxer 拉取 AVPacket,并按照流类型分发到对应的线程安全队列。
- 数据传递:
- 从 Demuxer 获取
std::shared_ptr<AVData>(包装了 AVPacket)。 - 将视频包推入
video_packet_queue_,音频包推入audio_packet_queue_。 - 当遇到 EOS 或读取结束时,向队列推入特殊标记(如
AVData::createEOS())。
- 从 Demuxer 获取
- 线程同步:使用原子标志(如
stop_requested_、pause_fetch_)控制循环;通过队列的阻塞 push/pop 实现流控。
Decoder 模块(包含解码线程)
- 职责:从各自的 Packet 队列中取包,调用 FFmpeg 解码器生成 AVFrame,并将帧推送到 Frame 队列。
- 内部结构:
- 视频解码线程:独立循环,取视频包 →
avcodec_send_packet→avcodec_receive_frame→ 创建AVData包装帧 → 推入video_frame_queue_。 - 音频解码线程:类似,但可能涉及重采样(
swr_convert)。
- 视频解码线程:独立循环,取视频包 →
- 数据传递:
- 输入:
video_packet_queue_/audio_packet_queue_。 - 输出:
video_frame_queue_/audio_frame_queue_。 - 每个帧都包含 PTS、持续时间等信息。
- 输入:
- 线程安全:为每个解码器加独立锁(如
video_codec_mutex_),避免与主线程的flush/seek冲突。
AVSync 模块
- 职责:管理音视频同步,对外提供同步后的帧。
- 数据传递:
- 从视频帧队列取帧,同时获取当前音频时钟(由音频渲染线程更新)。
- 计算同步差值,决策显示、丢弃或等待。
- 提供接口
getNextVideoFrame(timeout_ms)和getNextAudioFrame(timeout_ms),外部调用者(渲染模块)通过这些接口获取帧。
- 关键算法:以音频为时钟,维护一个
av_offset对齐音视频起始点,计算sync_diff,根据阈值决策。
渲染模块
- 视频渲染(通常在主线程或 OpenGL 上下文线程):
- 循环调用
AVSync::getNextVideoFrame()获取已同步的视频帧。 - 将帧数据上传到纹理并绘制在
QOpenGLWidget上。
- 循环调用
- 音频渲染(SDL2 回调线程):
- 在回调中调用
AVSync::getNextAudioFrame()获取音频帧(或从解码器队列取,取决设计)。 - 将 PCM 数据填充到 SDL 缓冲区,同时更新音频时钟(
setAudioClock),供同步模块使用。
- 在回调中调用
- 数据流:渲染模块是消费者,从同步模块获取帧;同步模块内部维护帧队列,并通过条件变量阻塞等待。
9.流媒体的重连策略与稳定性策略
| 对比维度 | RTMP (Real-Time Messaging Protocol) | HLS (HTTP Live Streaming) |
|---|---|---|
| 传输方式 | 长连接 (TCP),持续推送数据 | 短连接 (HTTP),分段下载 (ts片段 + m3u8索引) |
| 数据单位 | 流式数据包 (AVPacket) | 文件片段 (ts文件,通常 2-10秒) |
| 延迟特性 | 低延迟 (通常 2-5秒) | 高延迟 (通常 10-30秒) |
| 重连性质 | 连接层重连 (重建TCP/RTMP会话) | 应用层重拉 (重新请求新的m3u8和ts) |
| 中断表现 | 连接断开立即停止接收数据 | 当前ts下载失败,需请求新的ts |
RTMP重连策略的核心是通过检测网络中断、执行指数退避重试、并在重连成功后恢复播放状态,以保证流媒体播放的稳定性。主要步骤包括:
- 超时检测:通过设置
FFmpeg的rw_timeout参数或自定义中断回调,及时感知连接断开或长时间无数据,触发重连流程。 - 重连触发与退避:一旦检测到错误,播放器进入重连状态,采用指数退避算法逐步增加重试间隔(如1秒、2秒、4秒…),并加入随机抖动避免大量客户端同时重连造成服务器压力。重试达到最大次数后停止并报告失败。
- 资源重建:重连时需关闭旧的
AVFormatContext,重新打开RTMP URL,并重新查找流信息。若流参数(如分辨率、采样率)发生变化,需重新初始化解码器;若未变,则仅刷新解码器内部缓冲。 - 解码器刷新与队列清空:重连成功后调用
avcodec_flush_buffers清理解码器缓存,并清空音视频数据队列,防止残留的旧数据影响新流播放。 - 状态通知:通过信号向上层反馈重连进度(如“正在重连第几次”)和最终结果,以便UI展示提示或处理错误。
HLS(HTTP Live Streaming)基于HTTP短连接,其重连策略与RTMP长连接有本质区别。由于HLS将流分割为一个个独立的TS片段并通过M3U8索引文件管理,因此它的稳定性策略主要围绕片段的可靠下载和索引的持续更新展开。
- 分层重试机制 播放器针对清单(M3U8)和媒体片段(TS)采用不同的重试策略。加载主索引或更新直播流的索引失败时,会按照固定间隔(例如2秒)进行重试,重试次数可配置(如5次),多次失败后判定流不可用。下载某个TS片段失败(如HTTP 404或超时)时,则立即以较短间隔(如1秒)重试该片段,重试次数有限(如3次),若该片段永久缺失则跳过它继续请求下一个片段,避免播放卡死。
- 智能离线检测 播放器会记录连续失败次数。当清单连续多次更新失败或片段连续缺失超过阈值时,认为直播流已永久中断,停止重试并向上层抛出错误事件,防止无限重试。
- 源切换与多路备份 支持配置多个备选源(如不同清晰度或CDN地址),当主源频繁失败时自动切换到备选源继续播放。切换可基于清晰度降级或CDN优先级,提高播放可用性。
- 数据连续性处理 重连后可能遇到时间线不连续的情况。若服务器在M3U8中插入了EXT-X-DISCONTINUITY标签,播放器需重置解码器(如调用avcodec_flush_buffers)并清空缓冲区,防止音画不同步或解码错误。
- 缓冲区配合 播放器的音视频缓冲(如RingBuffer)能够平滑短暂的下载中断。当缓冲数据足够时(例如剩余3秒以上),即使当前片段下载失败,播放器仍可继续播放,同时在后台静默重试,为网络恢复争取时间。
- 超时控制 对每个HTTP请求设置合理的超时时间(如5秒),避免因单个片段阻塞而影响整体播放。
10.你的启动延迟,音视频同步误差怎么来的。
在资源Dumex阶段,我使用Debug在控制台的数据差来确定启动延迟,也就是资源加载用时,音视频同步方面我根据滚动的qDebug来确定同步误差的。
11.你的Seek流程是怎样的
- 获取媒体总时长
解复用器打开输入后,从
AVFormatContext的duration字段可读取以微秒为单位的媒体总时长。若该值为AV_NOPTS_VALUE,表示无法获取时长(如直播流),此时Seek行为需根据协议特性调整:对于点播文件,时长有效;对于直播流,通常只能跳转到当前可用的时间窗口内(如HLS的最新片段)。 - 验证目标时间
上层传入的目标时间一般以毫秒为单位,需要转换为解复用器内部的时间基,并与总时长比较:
- 小于0时,强制跳转到0毫秒(开头)。
- 大于总时长时,对于点播文件,可跳转到总时长位置(即末尾),但通常播放器会将其限制为总时长,避免超出;对于直播流,若目标超出当前窗口,则拒绝Seek或跳转到最新可用位置。
- 关键帧对齐
为保证跳转后解码器能正常工作,需要将目标时间对齐到关键帧。FFmpeg的
av_seek_frame函数配合AVSEEK_FLAG_BACKWARD标志,会自动定位到目标时间之前的最近关键帧。这避免了因非关键帧缺少参考帧而导致的花屏问题。 - 边界情况处理 若Seek发生在播放暂停或缓冲状态,需先暂停数据生产,再执行跳转;若播放已结束,则需重置内部状态后再跳转。对于不支持Seek的媒体(如某些直播协议),应直接拒绝并向上层报告错误。
二、Seek流程的完整步骤
Seek流程涉及播放器多个模块的协同,需严格按顺序执行,确保线程安全和数据一致性:
- 暂停数据生产
设置原子标志(如
pause_fetch_和state_ = Paused),通知fetch线程和解码线程停止生产数据,并通过条件变量唤醒可能阻塞的线程,使其进入等待状态。 - 清空所有数据队列 清空视频/音频包队列和帧队列,丢弃所有旧数据。清空后必须唤醒可能因等待数据而阻塞的消费者线程(如渲染线程),防止永久阻塞。
- 刷新解码器内部缓冲
通过互斥锁保护解码器上下文,调用
avcodec_flush_buffers清理解码器中缓存的帧,避免残留数据影响新流。 - 执行解复用器跳转
在加锁保护下,使用
av_seek_frame将解复用器的读取位置移动到目标关键帧。跳转参数需转换为对应流的时间基,并选择AVSEEK_FLAG_BACKWARD确保定位到关键帧。 - 重置同步模块
调用同步模块的
reset方法,清除音视频对齐偏移和音频时钟值,使同步回到未对齐状态,等待新位置的第一帧重新建立同步关系。 - 恢复播放
清除暂停标志,将播放器状态置为
Decoding,唤醒所有等待的工作线程。fetch线程从新位置读取数据包,解码线程处理新包并产生帧,渲染线程从同步模块获取新帧,恢复流畅播放。