Android 音视频播放器 Demo(一)—— 视频解码与渲染

本篇作为 Android 音视频实战系列的第二篇文章,主要介绍视频解码与渲染过程。本系列文章目录如下:

Android 音视频基础知识
Android 音视频播放器 Demo(一)------ 视频解码与渲染
Android 音视频播放器 Demo(二)------ 音频解码与音视频同步
RTMP 直播推流 Demo(一)------ 项目配置与视频预览
RTMP 直播推流 Demo(二)------ 音频推流与视频推流

1、项目概述

1.1 项目配置

FFmpeg 的交叉编译我们在前面介绍过,这里就不再赘述了,有需要可以去参考NDK 编译(二)------ NDK 编译与集成 FFmpeg

这里主要介绍 FFmpeg 的环境配置,分三步:

  1. FFmpeg 编译产物的静态库(6 个 .a 文件)复制到 libs/armeabi-v7a 下,include 文件夹复制到 src/main/cpp 目录下

  2. 更改 app 模块下的 build.gradle 文件,添加 abiFilter 只编译 arm-v7a:

    groovy 复制代码
    android {
        defaultConfig {
            externalNativeBuild {
                cmake {
                    abiFilters 'armeabi-v7a'
                }
            }
            ndk {
                abiFilters 'armeabi-v7a'
            }
        }
    }
  3. 修改 CMakeLists.txt:

    cmake 复制代码
    # 定义源文件
    file(GLOB sources *.cpp)
    
    # 定义 FFmpeg 路径
    set(FFMPEG ${CMAKE_SOURCE_DIR}/ffmpeg)
    
    # 导入 FFmpeg 头文件
    include_directories("${FFMPEG}/include")
    
    # 添加 FFmpeg 库文件路径到编译标记中
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${FFMPEG}/lib/${CMAKE_ANDROID_ARCH_ABI}")
    
    add_library(
            video-player
            SHARED
            ${sources})
    
    target_link_libraries(
            video-player
            # FFmpeg 源码编译出的 6 个静态库
            avcodec avfilter avformat avutil swresample swscale
            log
            z
            # 在 Native 进行视频渲染时要用到 ANativeWindow
            android
            # 在 Native 进行音频播放所需的库
            OpenSLES)

cmake 块中的 abiFilters 用于指定 CMake 构建系统编译和构建的 ABI。例如,如果在 abiFilters 中设置为 "armeabi-v7a",则 CMake 将只为 armeabi-v7a 架构编译和构建本机代码。

类似地,在 ndk 块中的 abiFilters 用于指定 NDK 构建系统编译和构建的 ABI。如果在 abiFilters 中设置为 "armeabi-v7a",则 NDK 将只为 armeabi-v7a 架构编译和构建本机代码。

1.2 Demo 结构

视频播放器 Demo 可以分为上下两层:

  1. 上层:主要是 UI 方面的,提供 SurfaceView 进行视频渲染。此外,还需要根据生命周期调用 Native 方法控制底层的播放
  2. Native 层:Native 层接收上层发来的播放指令,还需要通过 CallbackHelper 通知上层播放状态。此外,Native 层需要抽离出一个控制层,对音视频解码线程进行控制,接收解码的数据后要渲染到屏幕/麦克风上

Native 控制层示意图如下:

控制层的主要作用:

  • 初始化 FFmpeg 参数
  • 控制播放进度(播放、停止、控制播放速度等)
  • 从视频文件(视频流)中解析出 AVPacket 存入视频/音频队列

可以看到音视频各有一个保存 AVPacket 的队列,由于 AVPacket 是压缩数据,我们需要从队列中取出 AVPacket 解压为 AVFrame 再存入队列,因此 AVFrame 也是有一个队列的:

视频层作用:

  • 不断地从 Packet 队列中取出 AVPacket 解压为 AVFrame 后存入 AVFrame 队列。这是通过死循环进行的耗时操作,因此需要放入特定的解压线程中操作
  • 不断地从 AVFrame 队列中取出 AVFrame 放入播放线程的 buffer 中,最终要回到控制层将 AVFrame 渲染到屏幕上

音频层类似:

解压后的音频数据通过 OpenSLES 进行播放。

AudioTrack 底层实际上也是使用的 OpenSLES。

最后来介绍一下实现步骤:

  1. 准备阶段:
    • 实现 Native 反射调用上层的机制 JNICallbackHelper
    • 初始化 FFmpeg 解码器
  2. 视频解码:
    • 创建一个同步队列 SafeQueue 用于承载 AVPacket 和 AVFrame 数据
    • 创建专门处理视频解码工作的通道 VideoChannel,读取 AVPacket 并解码为 AVFrame
  3. 视频渲染:
    • 将上层 SurfaceView 的 Surface 传给 Native 控制层,设置好 Native 层的窗口对象 ANativeWindow
    • 将 VideoChannel 解码后的帧数据回调给 Native 控制层,渲染在 ANativeWindow 上
  4. 音频解码与渲染:
    • 创建专门处理音频解码工作的通道 AudioChannel 进行音频解码,具体方式与视频解码几乎一致
    • 将解码后的音频数据交给 OpenSLES 进行播放
    • 创建一个通道的基类 BaseChannel 用于定义视频通道 VideoChannel 和音频通道 AudioChannel 的共同操作
  5. 音视频同步
  6. 添加进度条与播放时间

2、准备阶段

准备阶段的主要工作是打开 FFmpeg 的解码器。在这个过程中,我们需要建立 Native 回调上层方法的机制 JNICallbackHelper,这样 Native 才能将播放器的准备状态、播放状态通知给上层。

2.1 代码框架

简单说一下代码结构:

  • Activity 布局主要有一个负责渲染视频的 SurfaceView 和控制播放进度的 SeekBar,对视频的控制都通过 VideoPlayer 类完成
  • VideoPlayer 是上层与 Native 交互的桥梁,定义了很多控制播放的 Native 方法,此外还有 Native 为了通知播放器状态要回调的方法
  • Native 层的入口在 native-lib,负责创建 Native 层的 VideoPlayer 并将上层的请求转交给它
  • Native 的 VideoPlayer 负责 FFmpeg 解码器的创建以及相关操作,还要把视频流和音频流交给对应的通道进行解码处理

还是先从 Activity 开始,布局如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <SurfaceView
        android:id="@+id/surfaceView"
        android:layout_width="match_parent"
        android:layout_height="200dp" />

    <!-- 进度条 -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="30dp"
        android:layout_margin="5dp">

        <TextView
            android:id="@+id/tv_time"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:gravity="center"
            android:text="@string/init_time"
            android:visibility="gone" />

        <SeekBar
            android:id="@+id/seekBar"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:max="100"
            android:visibility="gone" />
    </LinearLayout>
</LinearLayout>

代码端命令 VideoPlayer 执行准备工作:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var videoPlayer: VideoPlayer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 设置屏幕常亮
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        checkPermissionAndFile()

        videoPlayer = VideoPlayer()
        videoPlayer.setOnPreparedListener(object : VideoPlayer.OnPreparedListener {
            override fun onPrepared() {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "准备就绪", Toast.LENGTH_LONG).show()
                }
            }
        })
        videoPlayer.setOnErrorListener(object : VideoPlayer.OnErrorListener {
            override fun onError(errorMsg: String) {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, errorMsg, Toast.LENGTH_LONG).show()
                }
            }
        })
        // 准备工作
        videoPlayer.prepare(file_path)
    }
}

VideoPlayer 将准备工作转交给 Native 层,同时还为外界提供了播放器准备就绪的监听器 OnPreparedListener 和发生错误的监听器 OnErrorListener:

kotlin 复制代码
class VideoPlayer {

    private lateinit var surfaceHolder: SurfaceHolder
    private var onPreparedListener: OnPreparedListener? = null
    private var onErrorListener: OnErrorListener? = null

    fun setSurfaceHolder(surfaceHolder: SurfaceHolder) {
        this.surfaceHolder = surfaceHolder
    }

    /**
     * 准备工作,让 Native 层对解码器进行初始化
     */
    fun prepare(dataSource: String) {
        nativePrepare(dataSource)
    }

    /**
     * 供 Native 回调上层通知解码器准备就绪的方法
     */
    fun onPrepared() {
        onPreparedListener?.onPrepared()
    }

    /**
     * 供 Native 回调上层通知解码器初始化发生错误的方法
     */
    fun onError(errorCode: Int) {
        onErrorListener?.onError(getMsgFromCode(errorCode))
    }

    private fun getMsgFromCode(errorCode: Int): String = when (errorCode) {
        Constants.FFMPEG_CAN_NOT_OPEN_URL -> "打不开视频"
        Constants.FFMPEG_CAN_NOT_FIND_STREAMS -> "找不到流媒体"
        Constants.FFMPEG_FIND_DECODER_FAIL -> "找不到解码器"
        Constants.FFMPEG_ALLOC_CODEC_CONTEXT_FAIL -> "无法根据解码器创建上下文"
        Constants.FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL -> "根据流信息配置上下文参数失败"
        Constants.FFMPEG_OPEN_DECODER_FAIL -> "打开解码器失败"
        Constants.FFMPEG_NO_MEDIA -> "没有音视频"
        else -> "未知错误"
    }

    fun setOnPreparedListener(onPreparedListener: OnPreparedListener) {
        this.onPreparedListener = onPreparedListener
    }

    fun setOnErrorListener(onErrorListener: OnErrorListener) {
        this.onErrorListener = onErrorListener
    }

    private external fun nativePrepare(dataSource: String)

    interface OnPreparedListener {
        fun onPrepared()
    }

    interface OnErrorListener {
        fun onError(errorMsg: String)
    }
}

在 Native 层的入口,也是控制层 native-lib.cpp 中创建 nativePrepare() 对应的 Native 函数:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_video_player_VideoPlayer_nativePrepare(JNIEnv *env, jobject thiz, jstring data_source) {
    // 创建 Native 层的 VideoPlayer 并将准备工作交给它
}

VideoPlayer 执行准备工作时需要将结果通知给上层,因此到这里我们先来看 JNICallbackHelper 的实现。

2.2 JNICallbackHelper

JNICallbackHelper 是一个在 Native 层调用上层方法的帮助类,在进行解码器初始化时需要通过它告知上层解码器的初始化状态。

首先我们要了解,Native 如何调用上层方法。实际上跟 Java/Kotlin 反射类似:

  • 获取到上层方法所在的类对象 jclass
  • 根据上层方法的名字和签名,获取该方法的 jmethodID
  • 调用 JNI 提供的函数 JNIEnv->CallVoidMethod(jclass,jmethodID,methodArgs) 就可调用上层方法了

在上层的 VideoPlayer 中提供了 onPrepared() 和 onError() 供 Native 通知解码器初始化完成或者发生了错误:

kotlin 复制代码
class VideoPlayer {
    /**
     * 供 Native 回调上层通知解码器准备就绪的方法
     */
    fun onPrepared() {
        onPreparedListener?.onPrepared()
    }

    /**
     * 供 Native 回调上层通知解码器初始化发生错误的方法
     */
    fun onError(errorCode: Int) {
        onErrorListener?.onError(getMsgFromCode(errorCode))
    }
}

为了帮助 Native 回调 onPrepared() 和 onError(),JNICallbackHelper 可以这样实现:

cpp 复制代码
JNICallbackHelper::JNICallbackHelper(JavaVM *jvm, JNIEnv *jEnv, jobject jObj) {
    javaVM = jvm;
    jniEnv = jEnv;
    // jobject 默认作用域就在当前函数内,不能跨越线程和函数,必须声明为全局引用才可以
    jObject = jEnv->NewGlobalRef(jObj);
    // 反射获取上层方法对象需要方法所在的类对象
    jclass clazz = jEnv->GetObjectClass(jObject);
    // 获取要反射的方法 ID,实际上是拿到了方法的 ArtMethod 结构体
    onPreparedId = jEnv->GetMethodID(clazz, "onPrepared", "()V");
    onErrorId = jEnv->GetMethodID(clazz, "onError", "(I)V");
}

/**
 * 释放成员,从作用域小的开始释放
 */
JNICallbackHelper::~JNICallbackHelper() {
    if (jObject) {
        jniEnv->DeleteGlobalRef(jObject);
        jObject = nullptr;
    }

    if (jniEnv) {
        delete jniEnv;
        jniEnv = nullptr;
    }

    if (javaVM) {
        delete javaVM;
        javaVM = nullptr;
    }
}

/**
 * 回调上层的 onPrepared(),通知 Native 这边已经完成了
 * 解码器初始化
 */
void JNICallbackHelper::onPrepared(int thread_mode) {
    if (thread_mode == MAIN_THREAD) {
        // 在主线程中,可以直接使用主线程的 JNIEnv 调用上层方法
        jniEnv->CallVoidMethod(jObject, onPreparedId);
    } else {
        // 在子线程中,需要先获取子线程的 JNIEnv 再调用上层方法
        JNIEnv *childEnv;
        javaVM->AttachCurrentThread(&childEnv, nullptr);
        childEnv->CallVoidMethod(jObject, onPreparedId);
        javaVM->DetachCurrentThread();
    }
}

/**
 * 回调上侧的 onError(),通知上层在初始化解码器时发生了错误
 * @param thread_mode 运行在主线程还是子线程中
 * @param error_code 错误码,上层根据不同的错误码返回响应的提示
 */
void JNICallbackHelper::onError(int thread_mode, int error_code) {
    if (thread_mode == MAIN_THREAD) {
        // 在主线程中,可以直接使用主线程的 JNIEnv 调用上层方法
        jniEnv->CallVoidMethod(jObject, onErrorId, error_code);
    } else {
        // 在子线程中,需要先获取子线程的 JNIEnv 再调用上层方法
        JNIEnv *childEnv;
        javaVM->AttachCurrentThread(&childEnv, nullptr);
        childEnv->CallVoidMethod(jObject, onErrorId, error_code);
        javaVM->DetachCurrentThread();
    }
}

你能看到在 onPrepared() 和 onError() 会对所在线程加以区分,这是因为,初始化解码器是耗时操作要放在子线程中执行,而 JNIEnv 是与线程绑定的,不同线程的 JNIEnv 不同,因此在子线程中执行时,需要切换到子线程的 JNIEnv 再执行 CallVoidMethod()。

类似的情况还有 jobject,它不仅不能跨越线程,还不能跨越函数,因此在 JNICallbackHelper 的构造函数中,是将其声明为全局变量后才保存到成员变量中;而 JavaVM 作为全局唯一的表示虚拟机对象的变量,它的作用域最大,可以跨越线程,需要通过固定函数获取它:

cpp 复制代码
JavaVM *javaVm = nullptr;

/**
 * 获取全局的 JavaVm
 */
jint JNI_OnLoad(JavaVM *jvm, void *args) {
    javaVm = jvm;
    return JNI_VERSION_1_6;
}

有了它我们就可以在 native-lib 中创建 JNICallbackHelper 对象,然后在初始化解码器时使用它。

2.3 初始化解码器

上层的 VideoPlayer 提供 prepare() 供外界发出初始化解码器的请求,然后通过 Native 方法把这个请求转发到 Native 层:

kotlin 复制代码
	/**
     * 准备工作,让 Native 层对解码器进行初始化
     */
    fun prepare(dataSource: String) {
        nativePrepare(dataSource)
    }

	private external fun nativePrepare(dataSource: String)

native-lib 接收到请求,要创建 Native 层的 VideoPlayer 并让它来初始化解码器:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_video_player_VideoPlayer_nativePrepare(JNIEnv *env, jobject thiz, jstring data_source) {
    const char *dataSource = env->GetStringUTFChars(data_source, nullptr);
    auto jniCallbackHelper = new JNICallbackHelper(javaVm, env, thiz);
    // 当前 VideoPlayer 需要数据源以及回调帮助对象
    videoPlayer = new VideoPlayer(dataSource, jniCallbackHelper);
    videoPlayer->prepare();
    env->ReleaseStringUTFChars(data_source, dataSource);
}

VideoPlayer 初始化时要对数据源进行深拷贝:

cpp 复制代码
VideoPlayer::VideoPlayer(const char *data_source, JNICallbackHelper *helper) {
    // 由于参数传入的 data_source 指针在调用完当前构造函数后会被回收,
    // 为了避免 dataSource 成为悬空指针,需要对 data_source 进行深拷贝,
    // 声明 char 数组时不要忘记为 \0 预留出一个字节的空间
    dataSource = new char[strlen(data_source) + 1];
    strcpy(dataSource, data_source);

    jniCallbackHelper = helper;
}

由于初始化解码器是一个耗时操作,不能放在主线程中进行,因此我们开辟一个子线程进行准备工作:

cpp 复制代码
/**
 * 我们在 Activity 的主线程中开启准备工作,因此 prepare()
 * 是在主线程中运行的,该函数的任务是解析数据源,不论是本地文件
 * 还是网络地址,解析过程都是耗时操作,因此要放在子线程中进行
 */
void VideoPlayer::prepare() {
    pthread_create(&pid_prepare, nullptr, task_prepare, this);
}

线程的任务并没有直接开始初始化解码器,因为线程环境访问不到数据源,还是要在 VideoPlayer 的成员函数中进行:

cpp 复制代码
void *task_prepare(void *args) {
    // 因为我们现在是在子线程环境中,不是 VideoPlayer 的成员函数,不能
    // 直接访问 dataSource,因此绕一圈,在新的成员函数中做具体的准备工作
    auto videoPlayer = static_cast<VideoPlayer *>(args);
    videoPlayer->prepareInChildThread();
    // 线程的任务函数一定要返回 nullptr,否则运行会崩溃
    return nullptr;
}

调用 FFmpeg 的 API 去初始化解码器需要按照固定的步骤,已经在注释中用标号给出。解码器初始化完毕后,就要查找媒体流,如果找到了音视频流就创建对应的通道分开处理:

cpp 复制代码
/**
 * 在子线程中做具体的准备工作,初始化解码器
 */
void VideoPlayer::prepareInChildThread() {
    /*
     * 1.打开数据源
     */
    // 总上下文
    AVFormatContext *avFormatContext = avformat_alloc_context();
    // 字典,可以以键值对形式添加参数
    AVDictionary *avDictionary = nullptr;
    // 设置超时时间为 3 秒
    av_dict_set(&avDictionary, "timeout", "3000000", 0);
    // 打开视频数据源,成功则返回 0
    int result = avformat_open_input(&avFormatContext, dataSource, nullptr, &avDictionary);
    // 及时回收用完的变量
    av_dict_free(&avDictionary);
    // 打开失败的话要通知上层
    if (result) {
        if (jniCallbackHelper) {
            jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_CAN_NOT_OPEN_URL);
        }
        // 打开失败需要回收上下文
        avformat_close_input(&avFormatContext);
        LOGE("无法打开数据源");
        return;
    }

    /*
     * 2.查找媒体中的音视频流信息存入 AVFormatContext
     */
    result = avformat_find_stream_info(avFormatContext, nullptr);
    if (result < 0) {
        if (jniCallbackHelper) {
            jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_CAN_NOT_FIND_STREAMS);
        }
        avformat_close_input(&avFormatContext);
        // 实际上 FFmpeg 也提供了根据错误码转换成字符串的函数
        char *errorMsg = av_err2str(result);
        LOGE("%s", errorMsg);
        return;
    }

    // 获取视频的时长信息
    // avformat_find_stream_info() 会去尝试获取所有视频格式的总时长,
    // 因此在它之后使用 mAVFormatContext->duration 才更加合适,如果在
    // 它之前使用,则可以获取 mp4 格式的时长,但无法获取 flv 等格式的
    int duration = avFormatContext->duration / AV_TIME_BASE;

    /*
     * 3.打开解码器,对音视频流分别创建对应的处理通道
     */
    // 编解码器上下文
    AVCodecContext *avCodecContext = nullptr;
    for (int i = 0; i < avFormatContext->nb_streams; ++i) {
        // 3.1 根据媒体流的信息获取相应的解码器,流的类型可能是音频、视频、字幕
        AVStream *stream = avFormatContext->streams[i];
        // 获取这个流的编解码参数
        AVCodecParameters *codecParameters = stream->codecpar;
        // 根据参数获取对应的解码器
        AVCodec *codec = avcodec_find_decoder(codecParameters->codec_id);
        if (!codec) {
            if (jniCallbackHelper) {
                jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_FIND_DECODER_FAIL);
            }
            avformat_close_input(&avFormatContext);
            LOGE("获取解码器失败");
            return;
        }

        // 3.2 有了解码器才能获取解码器上下文
        avCodecContext = avcodec_alloc_context3(codec);
        if (!avCodecContext) {
            if (jniCallbackHelper) {
                jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
            }
            // 从这开始比之前多释放一个解码器上下文 AVCodecContext,它会同时帮你释放解码器 AVCodec
            avcodec_free_context(&avCodecContext);
            avformat_close_input(&avFormatContext);
            LOGE("获取解码器上下文失败");
            return;
        }

        // 3.3 根据解码器上下文参数填充解码器上下文 AVCodecContext
        result = avcodec_parameters_to_context(avCodecContext, codecParameters);
        if (result < 0) {
            if (jniCallbackHelper) {
                jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
            }
            avcodec_free_context(&avCodecContext);
            avformat_close_input(&avFormatContext);
            LOGE("设置解码器上下文失败");
            return;
        }

        // 3.4 打开解码器
        result = avcodec_open2(avCodecContext, codec, nullptr);
        if (result < 0) {
            if (jniCallbackHelper) {
                jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_OPEN_DECODER_FAIL);
            }
            avcodec_free_context(&avCodecContext);
            avformat_close_input(&avFormatContext);
            LOGD("打开解码器失败");
            return;
        }

        // 3.5 根据媒体流的类型创建对应的处理通道
        if (codecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
            // 有的视频类型只有一帧封面图片,这种情况需要跳过
            if (stream->disposition & AV_DISPOSITION_ATTACHED_PIC) {
                continue;
            }
            // 创建视频通道
            videoChannel = new VideoChannel;
        } else if (codecParameters->codec_type == AVMEDIA_TYPE_AUDIO) {
            // 创建音频通道
            audioChannel = new AudioChannel;
        } else if (codecParameters->codec_type == AVMEDIA_TYPE_SUBTITLE) {
            // 创建字幕通道...省略
        }
    }

    // 3.6 健壮性校验
    if (!videoChannel && !audioChannel) {
        if (jniCallbackHelper) {
            jniCallbackHelper->onError(CHILD_THREAD, FFMPEG_NO_MEDIA);
        }
        if (avCodecContext) {
            avcodec_free_context(&avCodecContext);
        }
        avformat_close_input(&avFormatContext);
        LOGE("媒体文件没有音视频流");
        return;
    }

    /*
     * 4.回调上层方法,通知准备就绪
     */
    if (jniCallbackHelper) {
        jniCallbackHelper->onPrepared(CHILD_THREAD);
        LOGD("准备完成");
    }
}

到这里解码器初始化就完成了。

3、视频解码

在 1.2 节介绍 Demo 结构时我们放了一张图,就是要从视频文件中不断读取 AVPacket 然后存放到 AVPacket 队列中。解码时不断地从 AVPacket 队列中取出 AVPacket 解码为 AVFrame 再存入 AVFrame 的队列。

由于上述两步都是循环的耗时操作,因此要放在子线程中操作。由于是在多线程环境中,因此保存 AVPacket 与 AVFrame 的队列需要是一个线程安全的队列,我们首先来实现这个队列。

3.1 SafeQueue

SafeQueue 这个队列主要存放 AVPacket 和 AVFrame,因此将其设计为模板类。此外,由于释放队列元素的具体方法在 SafeQueue 内部是无法知晓的,只能通过回调接口,将释放元素的操作交给知道具体类型的对象如何释放的外部代码。参考代码如下:

cpp 复制代码
/**
 * 线程安全队列,主要用于存放 AVFrame 和 AVPacket
 * 除了线程锁之外,还有两点需要注意:
 * 1. 由于使用泛型,需要释放队列元素时不知道具体类型该如何
 * 释放,因此需要通过 ReleaseCallback 回调给外部释放
 * 2.队列通过 enable 控制是否工作。比如存入元素时,如果
 * 队列不工作,那么需要丢弃并回收该元素
 *
 * 此外,还需注意,模板类的实现需要和头文件包含在同一个文件中,
 * 以便在编译时能够正确实例化模板类的具体类型。因此实现也放在
 * 头文件中,而没有分离到 cpp 文件中
 */
template<class T>
class SafeQueue {

    // 释放 T 的回调类型,因为 SafeQueue 内部不知道 T 的具体类型,
    // 也就不知道具体的释放方式
    typedef void (*ReleaseCallback)(T *value);

private:
    std::queue<T> queue;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    bool enabled = false;
    ReleaseCallback releaseCallback;

public:
    SafeQueue() {
        pthread_mutex_init(&mutex, nullptr);
        pthread_cond_init(&cond, nullptr);
    }

    ~SafeQueue() {
        pthread_mutex_destroy(&mutex);
        pthread_cond_destroy(&cond);
    }

    void setEnable(bool enable) {
        this->enabled = enable;
    }

    /**
    * 向队列中存入元素,如果队列不在工作状态,就要丢弃该元素
    */
    void put(T value) {
        pthread_mutex_lock(&mutex);
        if (enabled) {
            queue.push(value);
            pthread_cond_signal(&cond);
        } else {
            if (releaseCallback) {
                releaseCallback(&value);
            }
        }
        pthread_mutex_unlock(&mutex);
    }

    /**
    * 获取元素,成功则返回 true。
    * 参数是一个入参出参,采用引用形式,避免了参数的复制,
    * 将元素赋给形参就会直接给到实参
    */
    bool get(T &value) {
        bool success = false;
        pthread_mutex_lock(&mutex);
        // 阻塞函数,如果队列中没有元素就等着
        while (enabled && queue.empty()) {
            pthread_cond_wait(&cond, &mutex);
        }

        if (!queue.empty()) {
            value = queue.front();
            queue.pop();
            success = true;
        }
        pthread_mutex_unlock(&mutex);
        return success;
    }

    void clear() {
        pthread_mutex_lock(&mutex);
        while (!queue.empty()) {
            T value = queue.front();
            if (releaseCallback) {
                releaseCallback(&value);
            }
            queue.pop();
        }
        pthread_mutex_unlock(&mutex);
    }

    /**
    * 因为函数指针不包含 this 指针,因此带有隐藏的 this 指针的成员函数无法直接转换
    * 为函数指针。而静态函数不依赖于特定对象也没有 this 指针,它可以直接转换为函数
    * 指针。因此,方法参数可以传静态函数,而不能传成员函数,否则会报 "Reference to
    * non-static member function must be called" 的错误
    */
    void setReleaseCallback(ReleaseCallback callback) {
        releaseCallback = callback;
    }

    bool isEmpty() {
        return queue.empty();
    }

    int size() {
        return queue.size();
    }
};

当然,这不是 SafeQueue 的最终形态,因为后续在做音视频同步需要丢包时,还要向 SafeQueue 中添加丢包的操作逻辑。

3.2 BaseChannel

由于 VideoChannel 和 AudioChannel 会有很多类似的操作以及属性,因此我们考虑抽取出 BaseChannel 作为它们的父类:

cpp 复制代码
class BaseChannel {

public:
    BaseChannel(int stream_index, AVCodecContext *codecContext);

    virtual ~BaseChannel();

    static void releaseAVPacket(AVPacket **packet);

    static void releaseAVFrame(AVFrame **frame);

    // 解码器上下文
    AVCodecContext *avCodecContext;
    // 是否在播放中
    bool isPlaying;
    // 媒体流对应的索引
    int stream_index;
    // 压缩数据 AVPacket 队列
    SafeQueue<AVPacket *> packets;
    // 解压后数据 AVFrame 队列
    SafeQueue<AVFrame *> frames;
};

成员函数的实现如下:

cpp 复制代码
BaseChannel::BaseChannel(int stream_index, AVCodecContext *avCodecContext) :
        stream_index(stream_index), avCodecContext(avCodecContext) {
    // 设置释放 AVPacket 和 AVFrame 的函数
    packets.setReleaseCallback(releaseAVPacket);
    frames.setReleaseCallback(releaseAVFrame);
}

BaseChannel::~BaseChannel() {
    packets.clear();
    frames.clear();
}

void BaseChannel::releaseAVPacket(AVPacket **packet) {
    if (*packet) {
        av_packet_free(packet);
        *packet = nullptr;
    }
}

void BaseChannel::releaseAVFrame(AVFrame **frame) {
    if (*frame) {
        av_frame_free(frame);
        *frame = nullptr;
    }
}

VideoChannel 继承 BaseChannel,做出相应修改:

cpp 复制代码
class VideoChannel : public BaseChannel {
    ...
}

源文件需要修改构造函数:

cpp 复制代码
VideoChannel::VideoChannel(int stream_index, AVCodecContext *avCodecContext)
        : BaseChannel(stream_index, avCodecContext) {

}

AudioChannel 也是类似的修改。当然,这不是 BaseChannel 的最终形态,后续还会添加功能。

是否对 BaseChannel 的 releaseAVPacket() 和 releaseAVFrame() 两个成员函数声明为 static 有所疑问?因为 SafeQueue.setReleaseCallback() 的参数是函数指针,因此参数必须是或者可以转为函数指针。由于函数指针没有 this,而成员函数是有隐藏 this 的,所以成员函数不能直接转换为函数指针。只能是静态函数、全局函数或 C++11 以上的 Lambda 表达式可以转换,我们就使用了静态函数的方案。

3.3 解码

之前我们完成了解码器的初始化,因为我们设置了 Native 对上层的回调,在准备就绪后会通知上层的 VideoPlayer,我们的解码工作就从这里开始:

kotlin 复制代码
	override fun onCreate(savedInstanceState: Bundle?) {
        ...
        videoPlayer.setOnPreparedListener(object : VideoPlayer.OnPreparedListener {
            override fun onPrepared() {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "准备就绪", Toast.LENGTH_LONG).show()
                }
                // 开始解码
                videoPlayer.start()
            }
        })
        ...
    }

VideoPlayer 直接交给 Native 层处理:

kotlin 复制代码
	fun start() {
        nativeStart()
    }

	private external fun nativeStart()

native-lib 将请求转发给底层的 VideoPlayer:

cpp 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_video_player_VideoPlayer_nativeStart(JNIEnv *env, jobject thiz) {
    videoPlayer->start();
}

解码的操作包含两部分:

  1. 首先从媒体流中读取出 AVPacket,既可能是音频,也可能是视频,区分类型后存入相应通道的 AVPacket 队列中
  2. 从 AVPacket 队列中取出 AVPacket 解码为 AVFrame 存入队列

很明显,由于第一步需要区分音视频,因此它应该在 VideoPlayer 内进行,而第二步则在各自通道内进行。那么 VideoPlayer 的 start() 就需要开启子线程执行第一步,驱动 VideoChannel 执行第二步:

cpp 复制代码
void VideoPlayer::start() {
    isPlaying = true;

    if (videoChannel) {
        videoChannel->start();
    }

    pthread_create(&pid_start, nullptr, task_start, this);
}

读取 AVPacket

读取 AVPacket 是一个耗时操作,所以要放在子线程中。在 task_start() 内将具体操作交给 VideoPlayer 的 startInChildThread() 以便访问成员变量:

cpp 复制代码
void *task_start(void *args) {
    auto videoPlayer = static_cast<VideoPlayer *>(args);
    videoPlayer->startInChildThread();
    return nullptr;
}

/**
 * 解码器从媒体流中读取出 AVPacket 存入对应通道的 AVPacket 队列中
 */
void VideoPlayer::startInChildThread() {
    int result;
    while (isPlaying) {
        // 因为将 AVPacket 存入队列的速度远远快于取出 AVPacket 解码的速度,
        // 因此需要添加速度控制以防队列体积过大而撑爆内存
        if (videoChannel && videoChannel->packets.size() > 100) {
            // 休眠 10 毫秒
            av_usleep(10 * 1000);
            continue;
        }
        if (audioChannel && audioChannel->packets.size() > 100) {
            av_usleep(10 * 1000);
            continue;
        }
        // 不要想着将 packet 拿到 while 外面复用,因为在当前方法只会将其存入
        // AVPacket 队列,在 Channel 那边取出 AVPacket 使用完并释放之前就
        // 复用,会导致 Channel 那边解码失败
        AVPacket *packet = av_packet_alloc();
        // 读取一帧,AVPacket 可能是视频帧,也可能是音频帧,加以区分后存入相应的队列中
        result = av_read_frame(avFormatContext, packet);
        if (!result) {
            // 读取成功,将其加入相应通道的队列中
            if (videoChannel && videoChannel->stream_index == packet->stream_index) {
                videoChannel->packets.put(packet);
            } else if (audioChannel && audioChannel->stream_index == packet->stream_index) {
                audioChannel->packets.put(packet);
            }
        } else if (result == AVERROR_EOF) {
            // 如果读取到文件末尾了,那就等音视频通道的 AVPacket 队列都为空后再跳出循环结束播放
            if (videoChannel && videoChannel->packets.isEmpty() &&
                audioChannel && audioChannel->packets.isEmpty()) {
                break;
            }
        } else {
            // 其他情况就是读取错误,直接结束循环
            break;
        }
    }

    // 结束播放
    isPlaying = false;
    if (videoChannel) {
        videoChannel->stop();
    }

    if (audioChannel) {
        audioChannel->stop();
    }
}

整个过程的核心 API 就是先用 av_packet_alloc() 创建一个 AVPacket 对象再传入 av_read_frame() 读取出 AVPacket 的内容。

此外,需要注意的是,由于 SafeQueue 内没有进行容量限制,并且 AVPacket 的入队速度远远快于出队速度,因此需要进行速度控制以免内存爆炸。如果不添加速度控制,在播放长一点的视频时,程序会崩溃。

将 AVPacket 解码为 AVFrame

VideoChannel 的 start() 会启动两个线程,一个负责将 AVPacket 解码为 AVFrame,一个负责取出 AVFrame 的像素数据回调给控制层进行屏幕渲染:

cpp 复制代码
void VideoChannel::start() {
    // 是否在解码和渲染过程中
    isPlaying = true;
    // 开启两个队列
    packets.setEnable(true);
    frames.setEnable(true);
    // 开启解码和渲染线程
    pthread_create(&pid_decode, nullptr, task_decode, this);
    pthread_create(&pid_play, nullptr, task_play, this);
}

这一节我们只看解码线程。主要步骤是:

  1. 从 AVPacket 队列中不断取出 AVPacket,先通过 avcodec_send_packet() 将其发送给解码器
  2. 通过 av_frame_alloc() 创建一个 AVFrame,再通过 avcodec_receive_frame() 读取到解码后的 AVFrame
  3. 将 AVFrame 存入队列,通过 av_packet_unref() 将 AVFrame 的引用计数减 1,最后回收 AVFrame
cpp 复制代码
void *task_decode(void *args) {
    auto videoChannel = static_cast<VideoChannel *>(args);
    videoChannel->decode();
    return nullptr;
}

/**
 * 解码就是从 AVPacket 队列中的 AVPacket 解码
 * 为 AVFrame 再存入 AVFrame 队列中
 */
void VideoChannel::decode() {
    // 由于从队列中取出的 AVPacket 在使用完后直接
    // 就释放了,因此可以放在 while 外复用
    AVPacket *packet = nullptr;
    int result;
    while (isPlaying) {
        // 由于解码速度要快于音视频的渲染/播放速度,因此需要控制
        // frames 队列的入队速度,以防队列过大而撑爆内存
        if (isPlaying && frames.size() > 100) {
            av_usleep(10 * 1000);
            continue;
        }
        // 从队列中取出一个 AVPacket
        result = packets.get(packet);

        // 如果此时已经设置停止播放,则跳出循环
        if (!isPlaying) {
            break;
        }
        // 如果取 AVPacket 失败,可能是因为队列中尚未有
        // AVPacket,继续循环等待 AVPacket 被读取到队列中
        if (!result) {
            continue;
        }

        // 将 AVPacket 发送给解码器
        result = avcodec_send_packet(avCodecContext, packet);
        if (result != 0) {
            break;
        }

        // 从解码器中获取解码后的 AVFrame 存入 frames 队列中,av_frame_alloc()
        // 会在堆区开辟内存空间,使用完毕需要回收
        AVFrame *frame = av_frame_alloc();
        result = avcodec_receive_frame(avCodecContext, frame);
        LOGD("解码结果:%d", result);
        if (!result) {
            frames.put(frame);
            // 每当调用 av_read_frame() 时就会对相应的 AVPacket 引用计数加一,
            // 对 AVPacket 的 *data 指向的内存区域的引用计数减 1,减到 0 时会回收
            av_packet_unref(packet);
            // 回收 AVPacket 指针本身
            releaseAVPacket(&packet);
        } else if (result == AVERROR(EAGAIN)) {
            continue;
        } else {
            // 解码失败,但是 AVFrame 有值,需要释放
            if (frame) {
                releaseAVFrame(&frame);
            }
            break;
        }
        LOGD("解码,mFrames 中完成解码的帧数:%d", frames.size());
    }
    // 对于从 while 循环 break 出来的情况还要再回收一次 AVPacket
    av_packet_unref(packet);
    releaseAVPacket(&packet);
}

这样解码就完成了。

4、视频渲染

视频渲染要从两个方向上看:

  • 一方面,从上至下,我们要将上层的 SurfaceView 传递给 Native 层的 native-lib,因为我们要在 Native 层进行渲染
  • 另一方面,从下至上,解码后的 AVFrame 队列保存在 VideoChannel 中,而渲染屏幕的对象在 native-lib 中,需要将 AVFrame 回调给 native-lib

4.1 窗口设置

在 Activity 中将 SurfaceHolder 传递给 VideoPlayer:

kotlin 复制代码
	override fun onCreate(savedInstanceState: Bundle?) {
        ...
        videoPlayer.setSurfaceHolder(binding.surfaceView.holder)
        ...
    }

VideoPlayer 需要实现 SurfaceHolder.Callback 以便在 SurfaceView 窗口尺寸发生变化时将新的窗口传递到 Native 层:

kotlin 复制代码
class VideoPlayer : SurfaceHolder.Callback {
    private var surfaceHolder: SurfaceHolder? = null
    
    fun setSurfaceHolder(surfaceHolder: SurfaceHolder) {
        this.surfaceHolder?.removeCallback(this)
        this.surfaceHolder = surfaceHolder
        this.surfaceHolder?.addCallback(this)
    }
    
    // SurfaceHolder.Callback start
    // 只在创建时回调
    override fun surfaceCreated(holder: SurfaceHolder) {

    }

    // 创建时回调,Surface 的格式与尺寸变化时也会回调
    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
        nativeSetSurface(holder.surface)
    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {

    }
    // SurfaceHolder.Callback end
    
    private external fun nativeSetSurface(surface: Surface)
}

native-lib 接收 Surface 并创建 Native 层的 :

cpp 复制代码
// 创建窗口和渲染时需要用锁,这里采用静态初始化方式
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
ANativeWindow *window = nullptr;

extern "C"
JNIEXPORT void JNICALL
Java_com_video_player_VideoPlayer_nativeSetSurface(JNIEnv *env, jobject thiz, jobject surface) {
    pthread_mutex_lock(&mutex);
    // 先销毁之前的 ANativeWindow
    if (window) {
        ANativeWindow_release(window);
        window = nullptr;
    }
    // 再创建新的 ANativeWindow
    window = ANativeWindow_fromSurface(env, surface);
    pthread_mutex_unlock(&mutex);
}

4.2 回调绘制数据与渲染

这次我们来看 VideoChannel 的渲染线程:

cpp 复制代码
void *task_play(void *args) {
    auto videoChannel = static_cast<VideoChannel *>(args);
    videoChannel->play();
    return nullptr;
}

/**
 * 播放任务,实际上就是要将 AVFrame 内的像素数据取出,回调给负责进行
 * 渲染的 native-lib。具体操作有:
 * 1.将 AVFrame 队列中的 AVFrame 取出,将像素数据转为 RGB 格式
 * 2.将转换后的数据保存到矩阵中,回调给上一层的 VideoPlayer,后者
 * 再次回调给持有 ANativeWindow 的 native-lib 进行绘制
 */
void VideoChannel::play() {
    // 存放 RGBA 数据的指针数组
    uint8_t *dst_data[4];
    // 存放 dst_data 四个指针首地址的数组
    int dst_lineSize[4];
    // 根据图片的宽高和格式为其分配内存,并为 dst_data 和 dst_lineSize 赋值
    // 比如一张 1920*1080 使用 AV_PIX_FMT_RGBA,即 RGBA 8:8:8:8, 32bpp, RGBARGBA...
    // 的图片,其内存占用为 1920*1080*4≈8MB
    av_image_alloc(dst_data, dst_lineSize, avCodecContext->width, avCodecContext->height,
                   AV_PIX_FMT_RGBA, 1);
    // 转换上下文,将 YUV 转换为 RGB 所需的上下文
    SwsContext *swsContext = sws_getContext(
            avCodecContext->width, avCodecContext->height, avCodecContext->pix_fmt,
            avCodecContext->width, avCodecContext->height, AV_PIX_FMT_RGBA,
            SWS_BILINEAR, nullptr, nullptr, nullptr);

    AVFrame *frame = nullptr;
    int result;
    while (isPlaying) {
        result = frames.get(frame);
        if (!isPlaying) {
            break;
        }
        if (!result) {
            continue;
        }

        // 执行 YUV -> RGBA 转换,转换后的数据保存在 dst_data 和 dst_lineSize 中
        sws_scale(swsContext, frame->data, frame->linesize, 0,
                  avCodecContext->height, dst_data, dst_lineSize);

        renderCallback(dst_data[0], avCodecContext->width, avCodecContext->height, dst_lineSize[0]);
        // 释放 AVFrame
        av_frame_unref(frame);
        releaseAVFrame(&frame);
    }

    av_frame_unref(frame);
    releaseAVFrame(&frame);
    isPlaying = false;
    av_free(&dst_data[0]);
    sws_freeContext(swsContext);
}

VideoChannel 通过 renderCallback 将绘制所需数据先回调给它的直接上层 VideoPlayer,VideoPlayer 做同样的操作回调给 native-lib,渲染只需将数据拷贝到 ANativeWindow_Buffer 中即可,后续的渲染工作无需我们操作:

cpp 复制代码
/**
 * 渲染
 */
void renderFrame(uint8_t *src_data, int width, int height, int src_lineSize) {
    pthread_mutex_lock(&mutex);
    if (!window) {
        // 如果 ANativeWindow 不存在要释放锁避免死锁
        pthread_mutex_unlock(&mutex);
        return;
    }

    // 设置 ANativeWindow 的宽高以及图像格式
    ANativeWindow_setBuffersGeometry(window, width, height, WINDOW_FORMAT_RGBA_8888);
    ANativeWindow_Buffer window_buffer;

    // 渲染之前要对 ANativeWindow 上锁,如果上锁失败要结束渲染过程
    if (ANativeWindow_lock(window, &window_buffer, nullptr)) {
        ANativeWindow_release(window);
        window = nullptr;

        pthread_mutex_unlock(&mutex);
        return;
    }

    // 将像素数据填入 ANativeWindow_Buffer 就算渲染完成了
    auto *dst_data = static_cast<uint8_t *>(window_buffer.bits);
    int dst_lineSize = window_buffer.stride * 4;

    // 行遍历
    for (int i = 0; i < window_buffer.height; ++i) {
        // 从 src_data 拷贝一行数据到 dst_data 中
        memcpy(dst_data + i * dst_lineSize, src_data + i * src_lineSize, dst_lineSize);
    }

    // 数据刷新
    ANativeWindow_unlockAndPost(window);

    pthread_mutex_unlock(&mutex);
}

在底层的绘制都是通过缓冲区进行绘制的。ANativeWindow 自带一个相同大小的缓冲区,OpenCV、WebRTC、FFmpeg 都是通过这样的缓冲区进行绘制的。缓冲区实际上是一个字节数组,将像素数据赋值给字节数组,就完成了渲染。因此,底层的渲染,实际上就是一个内存的拷贝。

渲染这里要注意空间的分配与回收问题,否则长时间播放可能会耗尽内存导致应用崩溃。可能的原因是解码速度远远快于渲染速度,导致解码队列溢出了,所以我们才添加了对 VideoChannel 与 AudioChannel 内 AVPacket 和 AVFrame 队列的流量控制,队列容量大于 100 的时候进行休眠。

到这里,可以顺利播放视频了,但是由于音频解码与渲染还没做,因此当前视频无声。下一篇文章我们再介绍音频如何处理。

相关推荐
AI巨人7 小时前
“PR插件:轻松减少50%素材寻找时间,内置丰富素材,提升视频制作效率
人工智能·音视频·语音识别
伐尘7 小时前
【MySQL】MySQL 有效利用 profile 分析 SQL 语句的执行过程
android·sql·mysql
Haha_bj7 小时前
七、Kotlin——扩展(Extensions)
android·kotlin
urkay-7 小时前
Android getDrawingCache 过时废弃
android·java·开发语言·kotlin·iphone·androidx
Likeadust8 小时前
视频推流平台EasyDSS无人机推流直播技术赋能城市可视化管理
音视频·无人机
技术摆渡人8 小时前
Android 14系统深度分析
android·linux·驱动开发
AI生成未来8 小时前
港科大等提出音频驱动多人视频生成新范式 AnyTalker,解锁任意数量角色间的自然互动!
aigc·音视频·视频生成·音频驱动视频
帅得不敢出门8 小时前
Android8 Framework实现Ntp服务器多域名轮询同步时间
android·java·服务器·python·framework·github
走在路上的菜鸟8 小时前
Android学Dart学习笔记第十一节 错误处理
android·笔记·学习·flutter
EasyDSS8 小时前
全场景视频推流利器:视频推流平台EasyDSS技术解析与行业落地实践
音视频