iOS 从0写一个Webm播放器

177 阅读11分钟

效果展示

播放webm视频.gif

前言

在 iOS 开发中,系统原生的 AVPlayer 虽然功能强大,但对某些视频格式的支持存在局限性,比如 WebM 格式。WebM 是 Google 推出的开放视频格式,在 Web 端应用广泛,但 iOS 系统并不原生支持。

本文将详细介绍如何从零开始,基于 FFmpeg 实现一个支持 WebM 格式的 iOS 视频播放器,涵盖音视频解码、渲染、同步等核心技术。

技术选型

核心依赖库

  1. FFmpeg:强大的音视频处理库,支持几乎所有主流格式的编解码
  2. SDL2:跨平台的音频播放库,用于音频输出
  3. libyuv:Google 开源的图像处理库,用于高效的色彩空间转换
  4. 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::mutexstd::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. 预加载优化

对于网络视频,可以先下载一部分数据再开始播放,提升用户体验。

扩展功能

基于这个基础播放器,还可以扩展更多功能:

  1. 倍速播放:调整音频采样率和视频帧间隔
  2. 截图功能:保存当前 CVPixelBuffer 为图片
  3. 添加滤镜:在渲染前对 CVPixelBuffer 进行处理
  4. 字幕支持:解析并渲染字幕轨道
  5. 画中画:利用 iOS 的 PiP API
  6. 后台播放:配置音频会话支持后台播放

常见问题

Q1: 为什么视频播放卡顿?

  • 检查是否在主线程进行了耗时操作
  • 确认网络带宽是否足够
  • 查看是否有内存警告
  • 检查解码性能,考虑降低分辨率

Q2: 音视频不同步怎么办?

  • 检查时间戳计算是否正确
  • 确认暂停时间是否被正确处理
  • 查看帧率计算是否准确
  • 考虑改用音频时钟作为同步基准

Q3: 内存持续增长?

  • 检查 FFmpeg 资源是否正确释放
  • 确认 CVPixelBuffer 和 CMSampleBuffer 是否及时 release
  • 使用 Instruments 分析内存泄漏点

Q4: 为什么播放某些 WebM 视频失败?

  • 检查视频编码是否为 VP8/VP9
  • 确认 FFmpeg 编译时是否包含相应解码器
  • 查看控制台日志获取详细错误信息

总结

实现一个完整的视频播放器涉及多个技术领域:

  • 音视频解码:FFmpeg 的使用
  • 图形渲染:CoreMedia 框架
  • 音频播放:SDL2 音频系统
  • 多线程编程:GCD 和线程同步
  • 内存管理:C/C++/Objective-C 混合编程的资源管理

本文介绍的播放器已经具备了基本的功能,但在实际应用中还需要考虑更多细节,如错误处理、边界情况、性能优化等。

通过这个项目,你不仅能够支持 WebM 格式的播放,还能深入理解音视频处理的底层原理,为后续开发更复杂的功能打下坚实基础。

参考资料


如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题欢迎在评论区讨论。

完整代码已开源GitHub 仓库地址