效果展示
前言
在 iOS 开发中,系统原生的 AVPlayer 虽然功能强大,但对某些视频格式的支持存在局限性,比如 WebM 格式。WebM 是 Google 推出的开放视频格式,在 Web 端应用广泛,但 iOS 系统并不原生支持。
本文将详细介绍如何从零开始,基于 FFmpeg 实现一个支持 WebM 格式的 iOS 视频播放器,涵盖音视频解码、渲染、同步等核心技术。
技术选型
核心依赖库
- FFmpeg:强大的音视频处理库,支持几乎所有主流格式的编解码
- SDL2:跨平台的音频播放库,用于音频输出
- libyuv:Google 开源的图像处理库,用于高效的色彩空间转换
- AVFoundation:iOS 系统框架,用于视频帧渲染
为什么选择这些库?
- FFmpeg:WebM 格式使用 VP8/VP9 视频编码和 Vorbis/Opus 音频编码,FFmpeg 对这些编码器提供了完整支持
- SDL2:提供了简单易用的音频回调机制,便于实现自定义音频播放
- libyuv:相比 FFmpeg 的 swscale,在 YUV 到 RGB 转换上性能更优
- AVSampleBufferDisplayLayer:iOS 原生的高性能视频渲染层,支持硬件加速
整体架构设计
核心类设计
VideoPlayer (播放器主类)
├── 音视频解码循环 (decodeLoop)
├── 视频渲染 (AVSampleBufferDisplayLayer)
├── 音频播放 (SDL2 回调)
└── 播放控制 (播放、暂停、跳转等)
VideoTool (视频帧转换工具)
├── AVFrame → CVPixelBuffer
├── CVPixelBuffer → CMSampleBuffer
└── 像素缓冲池管理
线程模型
- 主线程:UI 交互、视频帧渲染
- 解码线程:音视频解码循环(串行队列)
- SDL 音频线程:音频数据回调和播放
核心实现步骤
1. 初始化 FFmpeg 上下文
AVFormatContext *fmtCtx = avformat_alloc_context();
// 打开视频文件
if (avformat_open_input(&fmtCtx, url_cstr, NULL, NULL) != 0) {
NSLog(@"❌ 打开视频文件失败");
return;
}
// 查找流信息
if (avformat_find_stream_info(fmtCtx, NULL) < 0) {
NSLog(@"❌ 查找视频流信息失败");
return;
}
2. 查找并初始化解码器
对于 WebM 格式,通常包含:
- 视频流:VP8 或 VP9 编码
- 音频流:Vorbis 或 Opus 编码
// 查找视频流索引
videoStreamIndex = av_find_best_stream(fmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
// 获取视频解码器
AVCodecParameters *videoCodecPar = fmtCtx->streams[videoStreamIndex]->codecpar;
const AVCodec *videoCodec = avcodec_find_decoder(videoCodecPar->codec_id);
// 创建解码器上下文
videoCodecCtx = avcodec_alloc_context3(videoCodec);
avcodec_parameters_to_context(videoCodecCtx, videoCodecPar);
avcodec_open2(videoCodecCtx, videoCodec, NULL);
// 音频流同理
audioStreamIndex = av_find_best_stream(fmtCtx, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
// ... 初始化音频解码器
3. 实现音视频解码循环
这是播放器的核心部分,需要处理:
- 音视频数据包的读取
- 帧解码
- 音视频同步
- 暂停/恢复控制
AVPacket *pkt = av_packet_alloc();
AVFrame *videoFrame = av_frame_alloc();
AVFrame *audioFrame = av_frame_alloc();
while (!stopRequested && av_read_frame(fmtCtx, pkt) >= 0) {
if (pkt->stream_index == videoStreamIndex) {
// 解码视频
avcodec_send_packet(videoCodecCtx, pkt);
while (avcodec_receive_frame(videoCodecCtx, videoFrame) == 0) {
// 转换并渲染视频帧
[self renderVideoFrame:videoFrame];
}
}
else if (pkt->stream_index == audioStreamIndex) {
// 解码音频
avcodec_send_packet(audioCodecCtx, pkt);
while (avcodec_receive_frame(audioCodecCtx, audioFrame) == 0) {
// 处理音频帧
[self processAudioFrame:audioFrame];
}
}
av_packet_unref(pkt);
}
4. 视频帧渲染流程
这是技术难点之一,需要完成格式转换链:
AVFrame (YUV420P) → CVPixelBuffer (BGRA) → CMSampleBuffer → AVSampleBufferDisplayLayer
4.1 创建 CVPixelBuffer 池
使用池化技术避免频繁创建/销毁,提升性能:
- (void)setupPixelBufferPoolWithWidth:(int)width height:(int)height {
NSDictionary *attributes = @{
(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
(id)kCVPixelBufferWidthKey: @(width),
(id)kCVPixelBufferHeightKey: @(height),
(id)kCVPixelBufferIOSurfacePropertiesKey: @{}
};
CVPixelBufferPoolCreate(kCFAllocatorDefault, NULL,
(__bridge CFDictionaryRef)attributes, &_pixelBufferPool);
}
4.2 YUV 到 RGBA 转换
使用 libyuv 进行高效转换:
CVPixelBufferRef pixelBuffer;
CVPixelBufferPoolCreatePixelBuffer(NULL, _pixelBufferPool, &pixelBuffer);
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
uint8_t *pixelData = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
int dstStride = (int)CVPixelBufferGetBytesPerRow(pixelBuffer);
// 使用 libyuv 进行转换
libyuv::I420ToARGB(
avFrame->data[0], avFrame->linesize[0], // Y 平面
avFrame->data[1], avFrame->linesize[1], // U 平面
avFrame->data[2], avFrame->linesize[2], // V 平面
pixelData, dstStride,
avFrame->width, avFrame->height
);
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
4.3 创建 CMSampleBuffer
CMFormatDescriptionRef formatDescription;
CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault,
pixelBuffer, &formatDescription);
// 设置时间戳
CMTime frameDuration = CMTimeMake(1000, (int32_t)(_frameFPS * 1000));
CMTime presentationTimeStamp = CMTimeMultiply(frameDuration, frameIndex);
CMSampleTimingInfo sampleTiming = {
.presentationTimeStamp = presentationTimeStamp,
.duration = frameDuration,
.decodeTimeStamp = kCMTimeInvalid
};
CMSampleBufferRef sampleBuffer;
CMSampleBufferCreateForImageBuffer(kCFAllocatorDefault,
pixelBuffer,
true,
NULL, NULL,
formatDescription,
&sampleTiming,
&sampleBuffer);
4.4 渲染到显示层
dispatch_async(dispatch_get_main_queue(), ^{
if (self.displayLayer && !stopRequested) {
[self.displayLayer enqueueSampleBuffer:sampleBuffer];
}
CFRelease(sampleBuffer);
});
5. 音频播放实现
使用 SDL2 的回调机制实现音频播放:
5.1 初始化 SDL 音频
SDL_AudioSpec spec;
spec.freq = audioCodecCtx->sample_rate; // 采样率
spec.format = AUDIO_S16SYS; // 16位有符号整型
spec.channels = audioCodecCtx->channels; // 声道数
spec.samples = 1024; // 缓冲区大小
spec.callback = sdl_audio_callback; // 音频回调函数
spec.userdata = (__bridge void *)self; // 传递上下文
SDL_OpenAudio(&spec, NULL);
SDL_PauseAudio(0); // 开始播放
5.2 音频数据转换
将解码后的音频帧转换为 PCM 16bit 格式:
// 初始化重采样上下文
swrCtx = swr_alloc_set_opts(NULL,
av_get_default_channel_layout(out_channels),
AV_SAMPLE_FMT_S16, // 目标格式
out_sample_rate,
av_get_default_channel_layout(audioCodecCtx->channels),
audioCodecCtx->sample_fmt, // 源格式
audioCodecCtx->sample_rate,
0, NULL);
swr_init(swrCtx);
// 转换音频数据
int16_t *outBuffer = (int16_t *)malloc(nb_samples * out_channels * sizeof(int16_t));
uint8_t *outPtr[1] = {(uint8_t *)outBuffer};
swr_convert(swrCtx, outPtr, nb_samples,
(const uint8_t **)audioFrame->data, nb_samples);
// 放入音频缓冲区
{
std::lock_guard<std::mutex> lock(buffer_mutex);
audio_buffer.insert(audio_buffer.end(),
outBuffer,
outBuffer + nb_samples * out_channels);
}
free(outBuffer);
5.3 SDL 音频回调
void sdl_audio_callback(void *userdata, Uint8 *stream, int len) {
VideoPlayer *player = (__bridge VideoPlayer *)userdata;
std::lock_guard<std::mutex> lock(player->buffer_mutex);
if (player->audio_buffer.empty()) {
SDL_memset(stream, 0, len); // 静音
return;
}
int copy_size = len / 2; // 16bit = 2 bytes
int available_size = player->audio_buffer.size();
if (copy_size > available_size) copy_size = available_size;
SDL_memcpy(stream, player->audio_buffer.data(), copy_size * 2);
player->audio_buffer.erase(player->audio_buffer.begin(),
player->audio_buffer.begin() + copy_size);
}
6. 音视频同步策略
音视频同步是播放器的关键,这里采用以视频为基准的同步策略:
// 计算帧率和帧间隔
AVRational fps = av_guess_frame_rate(fmtCtx, videoStream, NULL);
double frameRate = (double)fps.num / fps.den;
double frameDuration = 1.0 / frameRate;
// 在解码循环中控制帧速率
CFAbsoluteTime expectedTime = startTime + frameIndex * frameDuration;
CFAbsoluteTime currentTime = CFAbsoluteTimeGetCurrent();
if (currentTime < expectedTime) {
// 当前时间早于期望时间,需要等待
usleep((expectedTime - currentTime) * 1000000);
}
// 渲染视频帧
[self renderVideoFrame:videoFrame];
frameIndex++;
7. 暂停与恢复功能
使用信号量实现优雅的暂停控制:
// 初始化信号量
pauseSemaphore = dispatch_semaphore_create(0);
// 暂停
- (void)pause {
self.isPaused = YES;
SDL_PauseAudio(1); // 暂停音频
[_displayLayer.sampleBufferRenderer stopRequestingMediaData];
}
// 恢复
- (void)resume {
self.isPaused = NO;
SDL_PauseAudio(0); // 恢复音频
[_displayLayer.sampleBufferRenderer requestMediaDataWhenReadyOnQueue:dispatch_get_main_queue()
usingBlock:^{}];
dispatch_semaphore_signal(pauseSemaphore); // 唤醒解码线程
}
// 在解码循环中处理暂停
if (self.isPaused) {
CFAbsoluteTime pauseStartTime = CFAbsoluteTimeGetCurrent();
// 等待恢复信号
dispatch_semaphore_wait(pauseSemaphore, DISPATCH_TIME_FOREVER);
// 计算暂停时长,调整播放时间基准
CFAbsoluteTime pauseEndTime = CFAbsoluteTimeGetCurrent();
totalPauseTime += (pauseEndTime - pauseStartTime);
}
8. 进度控制与跳转
8.1 进度回调
// 设置进度回调
- (void)setProgressCallback:(void (^)(double currentTime, double duration))callback {
_progressCallback = callback;
}
// 在解码循环中定期回调
if (frameIndex % 10 == 0 && self.progressCallback && videoDuration > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
if (self.progressCallback) {
self.progressCallback(currentPlayTime, videoDuration);
}
});
}
8.2 进度跳转
- (void)seekToTime:(double)time completion:(void (^)(double))completion {
if (!self.isPlaying) return;
if (time < 0) time = 0;
if (time > videoDuration) time = videoDuration;
self.isSeeking = YES;
self.seekToTime = time;
self.seekCompletion = completion;
// 清空音频缓冲区
{
std::lock_guard<std::mutex> lock(buffer_mutex);
audio_buffer.clear();
}
// 清空视频缓冲区
dispatch_async(dispatch_get_main_queue(), ^{
[self.displayLayer.sampleBufferRenderer
flushWithRemovalOfDisplayedImage:NO
completionHandler:^{
// 跳转将在解码循环中处理
}];
});
}
// 在解码循环中处理跳转
if (self.isSeeking) {
int64_t seekTarget = (int64_t)(self.seekToTime * AV_TIME_BASE);
if (av_seek_frame(fmtCtx, -1, seekTarget, AVSEEK_FLAG_BACKWARD) >= 0) {
// 清空解码器缓冲区
avcodec_flush_buffers(videoCodecCtx);
if (audioCodecCtx) {
avcodec_flush_buffers(audioCodecCtx);
}
// 重置时间基准
currentPlayTime = self.seekToTime;
startTime = CFAbsoluteTimeGetCurrent() - currentPlayTime;
totalPauseTime = 0;
frameIndex = (int)(currentPlayTime * frameRate);
}
self.isSeeking = NO;
}
9. 资源管理与清理
正确的资源管理对于避免内存泄漏至关重要:
- (void)clearCacheWithCompletion:(void (^)())completion {
dispatch_group_t group = dispatch_group_create();
dispatch_group_enter(group);
dispatch_async(decodeQueue, ^{
// 释放 FFmpeg 资源
if (videoCodecCtx) {
avcodec_free_context(&videoCodecCtx);
videoCodecCtx = NULL;
}
if (audioCodecCtx) {
avcodec_free_context(&audioCodecCtx);
audioCodecCtx = NULL;
}
if (fmtCtx) {
avformat_close_input(&fmtCtx);
fmtCtx = NULL;
}
if (swrCtx) {
swr_free(&swrCtx);
swrCtx = NULL;
}
// 清空音频缓冲区
{
std::lock_guard<std::mutex> lock(buffer_mutex);
audio_buffer.clear();
}
dispatch_group_leave(group);
});
// 等待 FFmpeg 资源释放后,清理视频缓冲区
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
[self.displayLayer.sampleBufferRenderer
stopRequestingMediaData];
[self.displayLayer.sampleBufferRenderer
flushWithRemovalOfDisplayedImage:YES
completionHandler:^{
if (completion) completion();
}];
});
}
难点与解决方案
1. 线程安全问题
问题:音频缓冲区在解码线程和 SDL 音频线程之间共享,存在竞态条件。
解决方案:使用 C++ 的 std::mutex 和 std::lock_guard 保护共享数据:
std::vector<int16_t> audio_buffer;
std::mutex buffer_mutex;
// 写入
{
std::lock_guard<std::mutex> lock(buffer_mutex);
audio_buffer.insert(audio_buffer.end(), data, data + size);
}
// 读取
{
std::lock_guard<std::mutex> lock(buffer_mutex);
// ... 读取操作
}
2. 内存管理
问题:FFmpeg 和 CoreMedia 的资源需要手动管理,容易泄漏。
解决方案:
- 严格遵循"谁创建谁释放"原则
- 使用
dispatch_group确保异步释放完成 - 在析构函数中确保所有资源被释放
- (void)dealloc {
[self stopWithCompletion:nil];
// 确保所有资源被释放
}
3. 时间同步精度
问题:简单的时间戳计算会导致音视频逐渐不同步。
解决方案:
- 记录暂停时长,调整时间基准
- 使用高精度的
CFAbsoluteTime - 在 CMSampleBuffer 时间戳计算时提高精度
// 提高时间戳精度
CMTime frameDuration = CMTimeMake(1000, (int32_t)(_frameFPS * 1000)); // 使用更大的 timescale
4. 停止播放时的崩溃
问题:调用 stop 后,解码线程可能仍在向已释放的 displayLayer 添加帧。
解决方案:
- 使用
stopRequested标志在多个关键点检查 - 主线程操作前检查 displayLayer 有效性
- 使用 weak-strong dance 避免循环引用
dispatch_async(dispatch_get_main_queue(), ^{
if (!self->stopRequested && self.displayLayer) {
[self.displayLayer enqueueSampleBuffer:sbuf];
}
CFRelease(sbuf);
});
5. 不同像素格式的兼容性
问题:WebM 视频可能使用不同的像素格式(YUV420P、YUVJ420P 等)。
解决方案:
- 优先使用 libyuv 处理常见的 YUV420P 格式
- 对于其他格式,回退到 FFmpeg 的 swscale
if (pix_fmt == AV_PIX_FMT_YUV420P || pix_fmt == AV_PIX_FMT_YUVJ420P) {
// 使用 libyuv(更快)
libyuv::I420ToARGB(...);
} else {
// 使用 swscale(兼容性更好)
struct SwsContext *swsCtx = sws_getContext(...);
sws_scale(swsCtx, ...);
sws_freeContext(swsCtx);
}
使用示例
@interface ViewController ()
@property (nonatomic, strong) VideoPlayer *player;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 1. 创建播放器
self.player = [[VideoPlayer alloc] init];
// 2. 添加显示层到视图
self.player.displayLayer.frame = self.view.bounds;
[self.view.layer addSublayer:self.player.displayLayer];
// 3. 设置进度回调
[self.player setProgressCallback:^(double currentTime, double duration) {
NSLog(@"播放进度: %.2f / %.2f", currentTime, duration);
// 更新 UI
}];
// 4. 播放 WebM 视频
NSString *url = @"https://example.com/video.webm";
[self.player playWithURL:url repeats:NO];
}
// 暂停
- (void)pauseVideo {
[self.player pause];
}
// 恢复
- (void)resumeVideo {
[self.player resume];
}
// 跳转到 30 秒
- (void)seekTo30Seconds {
[self.player seekToTime:30.0 completion:^(double finishedTime) {
NSLog(@"跳转完成,当前时间: %.2f", finishedTime);
}];
}
// 停止播放
- (void)stopVideo {
[self.player stopWithCompletion:^{
NSLog(@"已停止播放");
}];
}
@end
性能优化建议
1. 使用对象池
频繁创建 CVPixelBuffer 会影响性能,使用 CVPixelBufferPool 进行池化管理。
2. 减少主线程压力
将耗时操作放在后台线程,只在主线程进行必要的 UI 更新和渲染。
3. 控制解码缓冲
避免一次性解码过多帧导致内存暴涨,可以根据缓冲区大小控制解码速度。
4. 使用硬件解码
对于支持的格式,可以配置 FFmpeg 使用硬件解码器:
av_dict_set(&opts, "hwaccel", "videotoolbox", 0);
avcodec_open2(codecCtx, codec, &opts);
5. 预加载优化
对于网络视频,可以先下载一部分数据再开始播放,提升用户体验。
扩展功能
基于这个基础播放器,还可以扩展更多功能:
- 倍速播放:调整音频采样率和视频帧间隔
- 截图功能:保存当前 CVPixelBuffer 为图片
- 添加滤镜:在渲染前对 CVPixelBuffer 进行处理
- 字幕支持:解析并渲染字幕轨道
- 画中画:利用 iOS 的 PiP API
- 后台播放:配置音频会话支持后台播放
常见问题
Q1: 为什么视频播放卡顿?
- 检查是否在主线程进行了耗时操作
- 确认网络带宽是否足够
- 查看是否有内存警告
- 检查解码性能,考虑降低分辨率
Q2: 音视频不同步怎么办?
- 检查时间戳计算是否正确
- 确认暂停时间是否被正确处理
- 查看帧率计算是否准确
- 考虑改用音频时钟作为同步基准
Q3: 内存持续增长?
- 检查 FFmpeg 资源是否正确释放
- 确认 CVPixelBuffer 和 CMSampleBuffer 是否及时 release
- 使用 Instruments 分析内存泄漏点
Q4: 为什么播放某些 WebM 视频失败?
- 检查视频编码是否为 VP8/VP9
- 确认 FFmpeg 编译时是否包含相应解码器
- 查看控制台日志获取详细错误信息
总结
实现一个完整的视频播放器涉及多个技术领域:
- 音视频解码:FFmpeg 的使用
- 图形渲染:CoreMedia 框架
- 音频播放:SDL2 音频系统
- 多线程编程:GCD 和线程同步
- 内存管理:C/C++/Objective-C 混合编程的资源管理
本文介绍的播放器已经具备了基本的功能,但在实际应用中还需要考虑更多细节,如错误处理、边界情况、性能优化等。
通过这个项目,你不仅能够支持 WebM 格式的播放,还能深入理解音视频处理的底层原理,为后续开发更复杂的功能打下坚实基础。
参考资料
如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题欢迎在评论区讨论。
完整代码已开源:GitHub 仓库地址