基于FFmpeg的简单Android视频播放器

文章目录

  • 1. 模块分割
  • 2. 解码器实现
  • 3. 播放控制
  • 4. 音视频同步
  • 5. 总结

之前的博客中已经使用了FFmpeg进行音频文件的解码,并且基于OpenSLES实现了一个简单的音乐播放器。最近正在学习《音视频开发进阶指南》,看到了视频部分。不如就干脆再写一个视频播放器。代码存放在我的github:Android-VideoPlayer。

1. 模块分割

首先对这个视频播放器所采用的一些部件要清楚。这个播放器主要可以拆分为4个部分:

  1. 解码:FFmpeg
  2. 音频输出:OpenSLES
  3. 视频渲染:OpenGLES

这些框架都是基于C的api,因此这次我们的主要工作将会集中在NDK部分。而关于NDK的一些知识,之前的博客也有讲过,所以这个工程会是对之前知识的一次综合运用。

按照视频播放器的功能,我们将分出以下几个模块:

  1. 图像显示
  2. 音频输出
  3. 解码
  4. 播放控制
  5. 音视频同步

为了提高可移植性,对关键部件使用接口来规范其API接口。

1. IAudioPlayer:音频播放器接口。它规定的接口如下

class IAudioPlayer {
     
public:
    virtual bool create() = 0;
    virtual void release() = 0;
    virtual void start() = 0;
    virtual void stop() = 0;
    virtual bool isPlaying() = 0;
    virtual void setAudioFrameProvider(IAudioFrameProvider *provider) = 0;
    virtual void removeAudioFrameProvider(IAudioFrameProvider *provider) = 0;
};

2. IVideoPlayer:视频播放接口。

class IVideoPlayer {
     
public:
    virtual bool create() = 0;
    virtual void release() = 0;
    virtual void refresh() = 0;
    virtual void setVideoFrameProvider(IVideoFrameProvider *provider) = 0;
    virtual void removeVideoFrameProvider(IVideoFrameProvider *provider) = 0;
    virtual void setWindow(void *window) = 0;
    virtual void setSize(int32_t width, int32_t height) = 0;
    virtual bool isReady() = 0;
};

3. AudioFrame:存储解码好的音频数据。
对于播放器内部,播放的音频数据格式为16位PCM,44.1kHz采样率,双声道。为了避免每一段音频数据都要重新申请内存,我们将会复用AudioFrame,因此要给它设置一个最大音频数据存储空间。

struct AudioFrame{
     
    // present time stamp
    int64_t pts;
    int16_t *data;
    int32_t sampleCount;

    int32_t maxDataSizeInByte = 0;

    AudioFrame(int32_t dataLenInByte)
    {
     
        this->maxDataSizeInByte = dataLenInByte;
        pts = 0;
        sampleCount = 0;
        data = (int16_t *)malloc(maxDataSizeInByte);
        memset(data, 0, maxDataSizeInByte);
    }

    ~AudioFrame(){
     
        if(data != NULL)
        {
     
            free(data);
        }
    }
};

4. VideoFrame:存储解码好的视频数据:
对于播放器内部使用的视频数据格式,分辨率为1920*1080,像素格式RGB888,每种颜色一个字节,一个像素占3个字节。对于VideoFrame同样会复用。

struct VideoFrame
{
     
    int64_t pts;
    uint8_t *data;
    int32_t width;
    int32_t height;

    int32_t maxDataSizeInByte = 0;;

    VideoFrame(int32_t dataLenInByte)
    {
     
        this->maxDataSizeInByte = dataLenInByte;
        data = (uint8_t *)malloc(maxDataSizeInByte);
        memset(data, 0, maxDataSizeInByte);
    }

    ~VideoFrame()
    {
     
        if(data != NULL)
        {
     
            free(data);
        }
    }
};

5. IAudioFrameProvider:面向IAudioPlayer的音频数据的提供源,它为IAudioPlayer提供解码好的音频数据
由于要复用AudioFrame,因此要设置一个接口,让IAudioPlayer将使用完的AudioFrame归还给我们。

class IAudioFrameProvider {
     
public:
    virtual AudioFrame* getAudioFrame() = 0;
    virtual void putBackUsed(AudioFrame *data) = 0;
};

6. IVideoFrameProvider:和IAudioFrameProvider一样。

class IVideoFrameProvider {
     
public:
    virtual VideoFrame* getVideoFrame() = 0;
    virtual void putBackUsed(VideoFrame* data) = 0;
};

7. IMediaDataReceiver:用于接收解码好的音视频数据的接口。
它是用来维护并存储已经解码好的音视频数据和使用过的音视频数据。

class IMediaDataReceiver {
     
public:
    virtual void receiveAudioFrame(AudioFrame *audioData) = 0;
    virtual void receiveVideoFrame(VideoFrame *videoData) = 0;
    virtual AudioFrame* getUsedAudioFrame() = 0;
    virtual VideoFrame* getUsedVideoFrame() = 0;

    virtual void putUsedAudioFrame(AudioFrame *audioData) = 0;
    virtual void putUsedVideoFrame(VideoFrame *videoData) = 0;
};

8. BlockRecyclerQueue:同步复用队列。
c++内并没有线程安全的队列模型。因此我们自己实现一个。并且由于播放器内很多的数据都会需要复用,因此给这个队列加一个复用功能。这样,这个类内部会有两个队列,一个存储未使用的数据,一个存储已使用的数据。使用两把锁,分别对两个队列进行线程保护。当然,实际上你也可以以更小的粒度来考虑这件事,只要使用一个队列,然后对队列进行线程保护即可,至于里面存储的到底是用过的数据还是没用过的数据,完全可以由上层来决定。

播放器中的多线程都使用c++11自带的thread。

这个同步复用队列实际上就是生产者消费者模式中的管道。它有以下几个特点:

  1. 如果设置capacity=-1,那么这个队列是不限大小的。如果限制了大小,当内部存储的数据满的时候,put操作就会等待,这是为了防止解码器过快导致内存占用过高。
  2. 对于get操作和put操作,你可以通过设置wait来决定当数据空或满的时候是否等待。对于get操作,队列空时,如果wait = true,那么它就会一直等待直到有数据;如果wait = false,那么它就会立刻返回NULL。对于put操作,队列满时,如果wait = true,它就会一直等待到队列不满;如果wait = false,那么它就不会顾及capacity,而直接向队列中存储,导致size > capacity。
  3. 为了防止播放结束时发生死锁,设置两个接口来解除所有的get和put操作的等待。这一点考虑到解码器解码完毕后,播放器却一直等待。
  4. 以上所有情况都是是对于有用的数据。而对于回收数据队列,所有的put和get操作只保证线程安全,而不会等待。它没有最大容量,所有的put操作都会在得到锁之后立刻执行。所有的get操作也会在得到线程锁之后立刻执行,如果没有回收数据,立刻返回NULL。
  5. 通过discardAll(void (*discardCallback)(T))方法可以将所有的有用数据一次性放到回收数据中,并且还可以传递一个函数指针,对所有的有用数据进行回收处理,之后再放入回收队列。这是为了seek操作考虑的,因为seek时要放弃所有已经解码好的数据。
template <class T>
class BlockRecyclerQueue {
     
public:
    // if size == -1, then we don't limit the size of data queue, and all the put option will not wait.
    BlockRecyclerQueue(int capacity = -1);
    ~BlockRecyclerQueue();
    int getCapacity();

    int getSize();

    // put a element, if wait = true, put option will wait until the length of data queue is less than specified size.
    void put(T t, bool wait = true);

    // get a element, if wait = true, it will wait until the data queue is not empty. If wait = false, it will return NULL if the data queue is empty.
    // It will still return NULL even wait = true, in this case, it must be someone call notifyWaitGet() but the data queue is still empty.
    T get(bool wait = true);

    void putToUsed(T t);

    T getUsed();

    void discardAll(void (*discardCallback)(T));

    // notify all the put option to not wait. This will cause put option succeed immediately
    void notifyWaitPut();

    // notify all the get option to return immediately. if data queue is still empty, get option will return a NULL.
    void notifyWaitGet();





private:
    int capacity = 0;
    mutex queueMu;
    mutex usedQueueMu;
    condition_variable notFullSignal;
    condition_variable notEmptySignal;
    list<T> queue;
    list<T> usedQueue;

    bool allowNotifyPut = false;
    bool allowNotifyGet = false;


};

2. 解码器实现

解码部分还是使用FFmpeg。解码过程和解码音频过程大同小异。

首先,我们肯定需要两个线程来分别解码音频和视频。

其次,还需要一个线程来读取文件,之前我们在解码音频时将从文件中读取packet将packet解码为frame的过程放在同一个线程中执行,因为音频文件我们只关注音频流。现在我们要将读packet这个操作单独放在一个线程里,然后解码器要维护两个队列,来分别存放音频的AVPacket和视频的AVPacket,这两个队列就可以使用之前的BlockRecyclerQueue。这相当于,读文件线程是生产者,而音频解码线程和视频解码线程都是消费者。具体代码可以查看VideoFileDecoder.cpp

需要注意的是,seek操作也是放在解码器中进行的,因为seek需要对媒体文件进行操作。在seek时,同样要将之前所有已经读出的AVPacket抛弃。

由于文件解码出的编码格式会不一样,因此我们需要FFmpeg的swr_convert来转码音频数据,用sws_scale转码视频数据。

3. 播放控制

我向外提供了一个播放器的统一操作接口:VideoPlayController.cpp,同时它还负责通知上层播放进度、管理音视频播放器和解码器、管理已解码好的数据等。因此它的声明如下:

class VideoPlayController: public IMediaDataReceiver, public IAudioFrameProvider, public IVideoFrameProvider

它实现了三个接口,可以接受解码器解码好的数据,并且向音视频播放器分别提供音频数据和视频数据。

4. 音视频同步

由于通常音频帧率要比视频帧率高很多,一般视频中的音频采样率多为44.1kHz或48kHz,而视频一般是25fps。

音视频同步通常有两种方式:

  1. 以音频时间基准播放视频,这是由于音频帧率更高。
  2. 以额外的时钟对音视频进行同步。

一般来说,额外时钟的方式会更好一些,一是因为它的精度高;二是这样一来,如果出现文件中只有视频或者只有音频的情况,适用性也会更高些;三是如果你的音频播放器不是主动请求音频数据的,那么你无论如何都需要一个额外时钟来向音频播放器和视频播放器定时发送数据。不过它的缺点在于多占资源。

我这里使用的是以音频时间为基准,因为OpenSLES是主动请求音频数据的。这样一来每次音频播放器请求数据时,我们可以拿到当前AudioFrame的pts,就可以得知当前的播放进度,也可以以这个播放进度来判断是否向视频播放器发送刷新指令。

自然而然,播放和暂停功能也是通过控制音频播放器的播放暂停来实现的。

音视频同步也放在VideoPlayController.cpp中。音视频同步部分的代码放在AudioFrame *VideoPlayController::getAudioFrame()方法中。

5. 总结

至此,这个播放器的关键部分就理清了。代码请上我的github上查看,链接在博客顶部。不过它仍然有很多问题:

  1. 某些情况下,退出视频播放会ANR,可能是某个线程进入了死锁或者死等待。
  2. 现在只能正常播放分辨率较低的视频,因为没有针对硬件加速做优化,导致解码视频过于耗时。测试得出解码一帧1920*1080的视频解码需要差不多70ms。

你可能感兴趣的:(FFmpeg,视频播放器,Android,ffmpeg)