Android 音视频开发:FFmpeg 播放器

前言

本篇文章属于 Android NDK 模块,需要读者有一点 NDK 相关的基础和 C/C++ 基础,不然其中的语法会有点晦涩难懂。本篇文章共分为以下五个专题,通过这五个专题的学习最终带大家制作一款属于自己的直播流播放器。

  1. 直播流信息获取
  2. 视频解码与原生绘制
  3. 音频解码与 OpenSL
  4. 音视频同步
  5. 音视频停止与释放

在学习第一个专题之前我们先掌握一些基础知识。我们知道播放在手机上的视频图像是由 RGB 三原色组成的,视频的话是各种图片的集合,由于 RGB 数据量太大我们需要进行压缩减少数据大小节省带宽和磁盘空间。为什么可以压缩呢,可以从以下几个方面进行考虑。

去除冗余信息

  • 空间冗余:图像相邻像素之间有较强的相关性
  • 时间冗余:视频序列的相邻图像之间内容相似
  • ​编码冗余:不同像素值出现的概率不同​- 视觉冗余:人的视觉系统对某些细节不敏感
  • ​知识冗余:规律性的结构可由先验知识和背景知识得到

我们需要做的是获得压缩数据解压缩展示在手机上。那具体的流程是什么样的呢?比如说我们获得了一个 MP4 文件,如何解压缩成为可以展示的 RGB 图像呢?

我们可以借助 FFmpeg 进行解封装解码的工作,FFmpeg 不仅内部实现了编解码算法还可以集成其他的编解码框架,目前抖音斗鱼等各种直播软件都使用了 FFmpeg 所以说还是很强大的。

Android 做视频只能通过 FFmpeg 吗?FFmpeg 是通过软编解码通过代码进行编解码,还有一个硬编解码,Android 中的 Media Codec 使用的就是硬编解码,由于兼容性比较差如果没有厂商的硬件支持基本上是兼容不了。OK,下面首先介绍一下我们的工程结构。

我的 build 文件,配置 CPU 兼容 和 CMakeLists:

      externalNativeBuild {
            cmake {
                cppFlags ""
                abiFilters 'armeabi-v7a'
            }
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }

我的 CMakeLists 文件

    cmake_minimum_required(VERSION 3.4.1)
    # 创建一个变量 source_file 它的值就是src/main/cpp/ 所有的.cpp文件
    file(GLOB source_file src/main/cpp/*.cpp)
    add_library(
             native-lib

             SHARED

             ${source_file} )
             include_directories(src/main/cpp/include)

             set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/cpp/libs/${ANDROID_ABI}")
             #avfilter avformat    avcodec  avutil  swresample swscale
             target_link_libraries(  native-lib
                avformat avcodec avfilter    avutil swresample swscale
                      log  z)

enter image description here

Java 层我们新建两个文件,MainActivity 播放器的主界面,MyPlayer 播放器的管理类。

enter image description here

cpp 中的 libs 存放的是我们编译出来的 FFmpeg 的 .a 库,JavaCallHelper 类实现了 C++ 反射调用 Java 代码,MyFFmpeg 中编写获取直播流解码的代码,获取到流后分为视频流和音频流,分别用 VideoChannel 和 AudioChannel 类进行处理。为了方便我编写了一个 util.h 文件来定义一些宏函数,由于直播涉及到线程我编写了一个线程安全的类 safe_queue。OK,整体项目结构就是这样,下面开始进行代码的讲解。

直播流信息获取

    public class MainActivity extends AppCompatActivity {

    private MyPlayer myPlayer;

    /**
     * 1,RTMP协议直播源
     * 香港卫视:rtmp://live.hkstv.hk.lxdns.com/live/hks
     *
     * 2,RTSP协议直播源
     * 珠海过澳门大厅摄像头监控:rtsp://218.204.223.237:554/live/1/66251FC11353191F/e7ooqwcfbqjoo80j.sdp
     * 大熊兔(点播):rtsp://184.72.239.149/vod/mp4://BigBuckBunny_175k.mov
     *
     * 3,HTTP协议直播源
     * 香港卫视:http://live.hkstv.hk.lxdns.com/live/hks/playlist.m3u8
     * CCTV1高清:http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8
     * CCTV3高清:http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8
     * CCTV5高清:http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8
     * CCTV5+高清:http://ivi.bupt.edu.cn/hls/cctv5phd.m3u8
     * CCTV6高清:http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8
     * 苹果提供的测试源(点播):http://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/gear2/prog_index.m3u8
     *
     * @param savedInstanceState
     */

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SurfaceView surfaceView = findViewById(R.id.surfaceView);
        myPlayer = new MyPlayer();
        myPlayer.setSurfaceView(surfaceView);
        myPlayer.setDataSource("rtmp://live.hkstv.hk.lxdns.com/live/hks");
        myPlayer.setOnPrepareListener(new MyPlayer.OnPrepareListener() {
            @Override
            public void onPrepare() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(MainActivity.this, "可以开始播放了", 0).show();
                    }
                });
            }
        });

    }

    public void start(View view) {
        myPlayer.prepare();
    }}

主界面主要做的工作就是用 SurfaceView 来播放音视频流,实例化 MyPlayer 将 surfaceView 传递给 MyPlayer 设置播放地址,实现一个准备播放的监听,一个开始播放的方法。

    /**
 * 提供java 进行播放 停止 等函数
 */

    public class MyPlayer implements SurfaceHolder.Callback {
    static {
        System.loadLibrary("native-lib");
    }

    private String dataSource;
    private SurfaceHolder holder;
    private OnPrepareListener listener;

    /**
     * 让使用 设置播放的文件 或者 直播地址
     */
    public void setDataSource(String dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * 设置播放显示的画布
     *
     * @param surfaceView
     */
    public void setSurfaceView(SurfaceView surfaceView) {
        holder = surfaceView.getHolder();
        holder.addCallback(this);
    }

    public void onError(int errorCode){
        System.out.println("Java接到回调:"+errorCode);
    }

    public void onPrepare(){
        if (null != listener){
            listener.onPrepare();
        }
    }

    public void setOnPrepareListener(OnPrepareListener listener){
        this.listener = listener;
    }
    public interface OnPrepareListener{
        void onPrepare();
    }

    /**
     * 准备好 要播放的视频
     */
    public void prepare() {
        native_prepare(dataSource);
    }

    /**
     * 开始播放
     */
    public void start() {

    }

    /**
     * 停止播放
     */
    public void stop() {

    }

    public void release() {
        holder.removeCallback(this);
    }

    /**
     * 画布创建好了
     *
     * @param holder
     */
    @Override
    public void surfaceCreated(SurfaceHolder holder) {

    }

    /**
     * 画布发生了变化(横竖屏切换、按了home都会回调这个函数)
     *
     * @param holder
     * @param format
     * @param width
     * @param height
     */
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

    }

    /**
     * 销毁画布 (按了home/退出应用/)
     *
     * @param holder
     */
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }

    native void native_prepare(String dataSource);}

MyPlayer 类实现了一个 SurfaceHolder.Callback 接口,我们看下源码:

      public interface Callback {
        /**
         * This is called immediately after the surface is first created.
         * Implementations of this should start up whatever rendering code
         * they desire.  Note that only one thread can ever draw into
         * a {@link Surface}, so you should not draw into the Surface here
         * if your normal rendering will be in another thread.
         *
         * @param holder The SurfaceHolder whose surface is being created.
         */
        public void surfaceCreated(SurfaceHolder holder);

        /**
         * This is called immediately after any structural changes (format or
         * size) have been made to the surface.  You should at this point update
         * the imagery in the surface.  This method is always called at least
         * once, after {@link #surfaceCreated}.
         *
         * @param holder The SurfaceHolder whose surface has changed.
         * @param format The new PixelFormat of the surface.
         * @param width The new width of the surface.
         * @param height The new height of the surface.
         */
        public void surfaceChanged(SurfaceHolder holder, int format, int width,
                int height);

        /**
         * This is called immediately before a surface is being destroyed. After
         * returning from this call, you should no longer try to access this
         * surface.  If you have a rendering thread that directly accesses
         * the surface, you must ensure that thread is no longer touching the
         * Surface before returning from this function.
         *
         * @param holder The SurfaceHolder whose surface is being destroyed.
         */
        public void surfaceDestroyed(SurfaceHolder holder);
    }

可以看到这个接口有三个方法要我们实现,控制了画布创建、画布改变、画布销毁。在 setSurfaceView 中 getHolder 和 addCallback,在 release() 方法中调用 holder.removeCallback 释放掉 Callback,防止内存泄漏。由于视频的编解码操作是在 native 方法中,所以定义一个 native_prepare 方法把 dataSource 传进去。下面开始 native 方法的编写。在 Myplayer 类中编写 native void native_prepare(String dataSource); 通过快捷键 Alt+Enter 可以在 native-lib 中快速生成相对应的 native 方法。

    MyFFmpeg *ffmpeg = 0;
    extern "C"
    JNIEXPORT void JNICALL
    Java_com_my_player_MyPlayer_native_1prepare(JNIEnv *env, jobject instance,
                                                 jstring dataSource_) {
    const char *dataSource = env->GetStringUTFChars(dataSource_, 0);
    //创建播放器
    JavaCallHelper *helper =  new JavaCallHelper(javaVm, env, instance);
    ffmpeg = new MyFFmpeg(helper, dataSource);
    ffmpeg->prepare();
    env->ReleaseStringUTFChars(dataSource_, dataSource);
    }

第一个参数 JNIEnv 类型实际上代表了 Java 环境,通过这个 JNIEnv* 指针,就可以对 Java 端的代码进行操作。例如,创建 Java 类中的对象,调用 Java 对象的方法,获取 Java 对象中的属性等等。JNIEnv 的指针会被 JNI 传入到本地方法的实现函数中来对 Java 端的代码进行操作。JNIEnv 类中有很多函数可以用:

  • NewObject:创建 Java 类中的对象
  • NewString:创建 Java 类中的 String 对象
  • New<Type>Array:创建类型为 Type 的数组对象
  • Get<Type>Field:获取类型为 Type 的字段
  • Set<Type>Field:设置类型为 Type 的字段的值
  • GetStatic<Type>Field:获取类型为 Type 的 static 的字段
  • SetStatic<Type>Field:设置类型为 Type 的 static 的字段的值
  • Call<Type>Method:调用返回类型为 Type 的方法
  • CallStatic<Type>Method:调用返回值类型为 Type 的 static 方法

等许多的函数,具体的可以查看 jni.h 文件中的函数名称。

参数:jobject instance

  • 如果 native 方法不是 static 的话,这个 instance 就代表这个 native 方法的类实例。
  • 如果 native 方法是 static 的话,这个 instance 就代表这个 native 方法的类的 class 对象实例(static 方法不需要类实例的,所以就代表这个类的 class 对象)。

将所有的操作封装在 Myffmpeg 中,在 native-lib 中进行调用。

    MyFFmpeg::MyFFmpeg(JavaCallHelper *callHelper,const char *dataSource) {
    this->callHelper = callHelper;
    //防止 dataSource参数 指向的内存被释放
    this->dataSource = new char[strlen(dataSource)];
    //错误写法 this->dataSource = const_cast<char * >(dataSource);
     strcpy(this->dataSource,dataSource); }
    MyFFmpeg::~MyFFmpeg() {
    //释放
    DELETE(dataSource);
    DELETE(callHelper);}

构造方法中的错误写法是因为 MyFFmpeg 的成员直接指向参数 dataSource 的话有可能这个 dataSource 在其他地方被释放了导致 MyFFmpeg 中的 dataSource 变成一个悬空指针。strcpy 指的是字符串拷贝。在析构方法中释放 dataSource 和 callHelper。到此我们的 MyFFmpeg 已经拿到了播放的地址,接下来要对这个地址进行解析。

解码流程

enter image description here

播放直播需要连接网络所以我们要加上网络权限,而且联网的操作肯定不能在主线程进行,所以我们要开辟一个子线程在子线程中操作。

    void* task_prepare(void *args){
    MyFFmpeg *ffmpeg = static_cast<MyFFmpeg *>(args);
    ffmpeg->_prepare();
    return 0;}

    void MyFFmpeg::prepare(){
    //创建一个线程
    pthread_create(&pid,0,task_prepare,this);
    }

    导入头文件 pthread.h、libavformat/avformat.h

    通过查看pthread.h 头文件int pthread_create(pthread_t* __pthread_ptr, pthread_attr_t const* __attr, void* (*__start_routine)(void*), void*);

  • 第一个参数为指向线程标识符的指针
  • 第二个参数用来设置线程属性
  • 第三个参数是线程运行函数的起始地址
  • 最后一个参数是运行函数的参数
    void MyFFmpeg::_prepare(){
    // 初始化网络 让ffmpeg能够使用网络
    avformat_network_init();
    //1、打开媒体地址(文件地址、直播地址)
    // AVFormatContext  包含了 视频的 信息(宽、高等)
    formatContext = 0;
    //文件路径不对 手机没网
    int ret = avformat_open_input(&formatContext,dataSource,0,0);
    //ret不为0表示 打开媒体失败
    if(ret != 0){
        LOGE("打开媒体失败:%s",av_err2str(ret));
        callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL);
        return;
    }
    //2、查找媒体中的 音视频流 (给 contxt里的 streams等成员赋)
    ret =  avformat_find_stream_info(formatContext,0);
    // 小于0 则失败
    if (ret < 0){
        LOGE("查找流失败:%s",av_err2str(ret));
        callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_FIND_STREAMS);
        return;
    }
    //nb_streams :几个流(几段视频/音频)
    for (int i = 0; i < formatContext->nb_streams; ++i) {
        //可能代表是一个视频 也可能代表是一个音频
        AVStream *stream = formatContext->streams[i];
        //包含了 解码 这段流 的各种参数信息(宽、高、码率、帧率)
        AVCodecParameters  *codecpar =  stream->codecpar;

        //无论视频还是音频都需要干的一些事情(获得解码器)
        // 1、通过 当前流 使用的 编码方式,查找解码器
        AVCodec *dec = avcodec_find_decoder(codecpar->codec_id);
        if(dec == NULL){
            LOGE("查找解码器失败:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_FIND_DECODER_FAIL);
            return;
        }
        //2、获得解码器上下文
        AVCodecContext *context = avcodec_alloc_context3(dec);
        if(context == NULL){
            LOGE("创建解码上下文失败:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
            return;
        }
        ret = avcodec_parameters_to_context(context,codecpar);
        //失败
        if(ret < 0){
            LOGE("设置解码上下文参数失败:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
            return;
        }
        // 4、打开解码器
        ret = avcodec_open2(context,dec,0);
        if (ret != 0){
            LOGE("打开解码器失败:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_OPEN_DECODER_FAIL);
            return;
        } 
        //音频
        if (codecpar->codec_type == AVMEDIA_TYPE_AUDIO){
            audioChannel = new AudioChannel;
        } else if(codecpar->codec_type == AVMEDIA_TYPE_VIDEO){
            videoChannel = new VideoChannel;
        }
        //没有音视频 
    if(!audioChannel && !videoChannel){
        LOGE("没有音视频");
        callHelper->onError(THREAD_CHILD,FFMPEG_NOMEDIA);
        return;
    }
    // 准备完了 通知java 你随时可以开始播放
    callHelper->onPrepare(THREAD_CHILD);

以上代码包含了获取音视频信息(宽高等)、查找解码器、获得解码器上下文、设置上下文参数、打开解码器,下面就几个重要的方法进行讲解。

avformat_open_input 打开头文件可以看到 int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);,里面传递了 AVFormatContext 点进去看,里面包含和音视频的信息(宽高等),第二个参数传递了地址,后面两个参数分别代表文件容器格式、最大延时,超时时间,以及支持的协议的白名单等。

可以看到 avformat_open_input 方法是有返回值的,返回 0 是成功,不为 0 失败。可以通过我之前编写的 JavaCallHelper 返回给 Java 进行处理,在这里我们需要注意的是,我们是在子线程中反射调用 Java,而 JNIEnv 不能跨线程调用这里就涉及到跨线程问题。这里我们需要一个 JavaVM 来获得对应线程的 JNIEnv。

    class JavaCallHelper {
    public:
    JavaCallHelper(JavaVM *vm,JNIEnv* env,jobject instace);
    ~JavaCallHelper();
    //回调java
    void  onError(int thread,int errorCode);
    void onPrepare(int thread);
    private:
    JavaVM *vm;
    JNIEnv *env;
    jobject  instance;
    jmethodID onErrorId;
    jmethodID onPrepareId;

下面介绍下我的 JavaCallHelper 类,其中包含了所有的回调 Java 的方法。

    JavaCallHelper::JavaCallHelper(JavaVM *vm, JNIEnv *env, jobject instace) {
    this->vm = vm;
    //如果在主线程 回调
    this->env = env;
    // 一旦涉及到jobject 跨方法 跨线程 就需要创建全局引用
    this->instance = env->NewGlobalRef(instace);

    jclass  clazz = env->GetObjectClass(instace);
    onErrorId = env->GetMethodID(clazz,"onError","(I)V");
    onPrepareId = env->GetMethodID(clazz,"onPrepare","()V");}
    JavaCallHelper::~JavaCallHelper() {
    env->DeleteGlobalRef(instance);}

    void JavaCallHelper::onError(int thread,int error){
    //主线程
    if (thread == THREAD_MAIN){
       env->CallVoidMethod(instance,onErrorId,error);
    } else{
        //子线程
        JNIEnv *env;
        //获得属于我这一个线程的jnienv
        vm->AttachCurrentThread(&env,0);
        env->CallVoidMethod(instance,onErrorId,error);
        vm->DetachCurrentThread();
    }}

    void JavaCallHelper::onPrepare(int thread) {
    if (thread == THREAD_MAIN){
        env->CallVoidMethod(instance,onPrepareId);
    } else{
        //子线程
        JNIEnv *env;
        //获得属于我这一个线程的jnienv
        vm->AttachCurrentThread(&env,0);
        env->CallVoidMethod(instance,onPrepareId);
        vm->DetachCurrentThread();
    }}

onError 方法中处理我们的错误信息反射给 Java,如果是主线程 THREAD_MAIN 直接传递 Env,如果是子线程就通过 vm->AttachCurrentThread(&env,0); 获得属于我这一个线程的 JNIEnv,调用完毕后 DetachCurrentThread。然后在 MyFFmpeg 中 callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL); 第一个参数代表子线程,第二个参数代表错误信息。

回到我们的 _prepare 方法中来,第二个方法 avformat_find_stream_info,查看头文件 int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options); 这个方法表示查找媒体中的音视频流(给 contxt 里的 streams 等成员赋值)。返回小于 0 失败,大于等于 0 成功。失败的话就回调给 Java ,调用这个方法后,formatContext 就有值了。这里我们看一下 AVFormatContext 这个结构体:

还有 69% 的精彩内容
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
支付 ¥12.99 继续阅读
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335