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:

    android {
        defaultConfig {
            externalNativeBuild {
                cmake {
                    abiFilters 'armeabi-v7a'
                }
            }
            ndk {
                abiFilters 'armeabi-v7a'
            }
        }
    }
    
  3. 修改 CMakeLists.txt:

    # 定义源文件
    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 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 执行准备工作:

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:

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 函数:

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 通知解码器初始化完成或者发生了错误:

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

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

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

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 作为全局唯一的表示虚拟机对象的变量,它的作用域最大,可以跨越线程,需要通过固定函数获取它:

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 层:

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

	private external fun nativePrepare(dataSource: String)

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

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 初始化时要对数据源进行深拷贝:

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;
}

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

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

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

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

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

/**
 * 在子线程中做具体的准备工作,初始化解码器
 */
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 内部是无法知晓的,只能通过回调接口,将释放元素的操作交给知道具体类型的对象如何释放的外部代码。参考代码如下:

/**
 * 线程安全队列,主要用于存放 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 作为它们的父类:

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;
};

成员函数的实现如下:

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,做出相应修改:

class VideoChannel : public BaseChannel {
    ...
}

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

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,我们的解码工作就从这里开始:

	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 层处理:

	fun start() {
        nativeStart()
    }

	private external fun nativeStart()

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

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 执行第二步:

void VideoPlayer::start() {
    isPlaying = true;

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

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

读取 AVPacket

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

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 的像素数据回调给控制层进行屏幕渲染:

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
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:

	override fun onCreate(savedInstanceState: Bundle?) {
        ...
        videoPlayer.setSurfaceHolder(binding.surfaceView.holder)
        ...
    }

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

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 层的 :

// 创建窗口和渲染时需要用锁,这里采用静态初始化方式
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 的渲染线程:

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 中即可,后续的渲染工作无需我们操作:

/**
 * 渲染
 */
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 的时候进行休眠。

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/582369.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

金属冶炼及压延加工制造数字孪生可视化平台,推进行业数字化转型

金属冶炼及压延加工制造数字孪生可视化平台&#xff0c;推进行业数字化转型。随着科技的不断进步和工业的快速发展&#xff0c;金属冶炼及压延加工行业正面临着前所未有的挑战和机遇&#xff0c;数字化转型成为了行业发展的必然趋势。在这个过程中&#xff0c;数字孪生可视化平…

【前端热门框架【vue框架】】——条件渲染和列表渲染的学习的秒杀方式

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;程序员-曼亿点 &#x1f468;‍&#x1f4bb; hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍&#x1f4bb; 本文由 秩沅 原创 &#x1f468;‍&#x1f4bb; 收录于专栏&#xff1a;v…

R语言使用sjPlot包优雅绘制回归模型的交互效应图

交互作用效应(p for Interaction)在SCI文章中可以算是一个必杀技&#xff0c;几乎在高分的SCI中必出现&#xff0c;因为把人群分为亚组后再进行统计可以增强文章结果的可靠性&#xff0c;进行可视化后可以清晰的表明变量之间的关系。不仅如此&#xff0c;交互作用还可以使用来进…

实验8 顺序图、状态图

一、实验目的 通过绘制顺序图、状态图&#xff0c;掌握顺序图、状态图之间的基本原理和差异。 能对简单问题进行顺序图、状态图的分析与绘制。 二、实验项目内容&#xff08;实验题目&#xff09; 在图书信息管理系统中&#xff0c;系统管理员可以对图书信息进行管理和维护…

【C++ 容器 set】set的相关用法

博主首页&#xff1a; 有趣的中国人 专栏首页&#xff1a; C进阶 其它专栏&#xff1a; C初阶 | 初阶数据结构 | Linux 博主会持续更新 本篇文章主要讲解 C容器set的相关用法 的相关内容 文章目录 1. 关联式容器2. 树形结构的关联式容器3. set的介绍以及相关使用操作3.1 se…

Linux内核驱动开发-001字符设备开发-003独立按键杂项驱动

1驱动程序 /*************************************************************************> File Name: key_misc.c> Author: yas> Mail: rage_yashotmail.com> Created Time: 2024年04月22日 星期一 17时20分42秒**********************************************…

不同语言在算法使用方面的差异(Java 、C++篇)

由于我认为的会了是能得到结果了&#xff0c;所以我亲自去把题解的C代码给改成了Java的&#xff0c;尽管代码和逻辑上的高度统一。编译器还是报错了。 第三个死都过不去。而且后面的还超时了。 这使我十分怀疑是不是超时或者空间不够所导致的。但是去问讯飞星火&#xff0c;它…

PhotosCollage for Mac:优雅且实用的照片拼贴软件

PhotosCollage for Mac是一款优雅且实用的照片拼贴软件&#xff0c;为Mac用户提供了一个便捷、高效的平台&#xff0c;以创建精美、个性化的照片拼贴作品。 PhotosCollage for Mac v1.4.1激活版下载 该软件界面简洁直观&#xff0c;操作便捷。用户只需将想要拼贴的照片拖入“照…

社交媒体数据恢复:Singal

Signal 数据恢复方法 Signal 是一款主打安全的即时通信应用&#xff0c;它采用了端到端加密的聊天方式。然而&#xff0c;有时候用户可能会遇到数据丢失的问题&#xff0c;例如不小心删除了重要的聊天记录或者忘记了 PIN 码导致无法访问账户数据。以下是针对 Signal 数据恢复的…

花生壳域名收费?那就用免费的dnsexit动态域名解析保姆级图文教程,效果杠杠的

免费dnsexit动态域名解析教程 在互联网上有很多不同的域名解析服务&#xff0c;其中dnsexit是一个流行的免费动态域名解析服务&#xff0c;它允许用户动态更新其IP地址&#xff0c;确保域名始终指向正确的服务器。以下是一个dnsexit动态域名解析的图文教程&#xff0c;帮助你了…

区块链 | OpenSea 相关论文:Toward Achieving Anonymous NFT Trading(三)

&#x1f951;原文&#xff1a; Toward Achieving Anonymous NFT Trading VII 讨论&#xff1a;关于匿名性与市场平台的困境 在本文的这一部分&#xff0c;我们将讨论关于隐藏 NFT 所有者地址的困境&#xff0c;以及为什么像 OpenSea 这样的 NFT 市场平台几乎必须得到完全的信…

Python-VBA函数之旅-min函数

目录 一、min函数的常见应用场景 二、min函数使用注意事项 三、如何用好min函数&#xff1f; 1、min函数&#xff1a; 1-1、Python&#xff1a; 1-2、VBA&#xff1a; 2、推荐阅读&#xff1a; 个人主页&#xff1a;神奇夜光杯-CSDN博客 一、min函数的常见应用场景 mi…

vue跟jQuery中的事件冒泡、事件捕获、事件委托(事件代理)

1、事件捕获、事件冒泡 在JS中&#xff0c;我们管事件发生的顺序叫“事件流” 标准的事件流&#xff1a;当dom触发了事件后&#xff0c;会先通过事件传播捕获到目标元素&#xff0c;然后目标节点通过事件传播实现事件冒泡 事件传播&#xff1a;无论是捕获还是冒泡&#xff0…

百面算法工程师 | 池化相关总结

目录 14.1 什么是池化 14.2 池化层的作用 14.3 平均池化 14.4 最大池化 14.5 空间金字塔池化 14.6 ROI Pooling 14.7 最大池化与平均池化是如何进行反向传播的 14.8 卷积层与池化层的区别 欢迎大家订阅我的专栏一起学习共同进步 祝大家早日拿到offer&#xff01; lets…

位运算、状态压缩、枚举子集汇总

本文涉及知识点 证明容斥原理和证明集合枚举都用到了&#xff1a;二项式定理 【数学归纳法 组合数学】容斥原理 基础知识 位运算优先级 位运算的结合性都是从左到右。优先级低的先运算。 优先级位运算符说明7<< >>位左移/位右移10&按位与11^按位异或12按位…

织梦云端:网络信号原理的艺术解码

hello &#xff01;大家好呀&#xff01; 欢迎大家来到我的Linux高性能服务器编程系列之《织梦云端&#xff1a;网络信号原理的艺术解码》&#xff0c;在这篇文章中&#xff0c;你将会学习到网络信号原理以及应用&#xff0c;并且我会给出源码进行剖析&#xff0c;以及手绘UML图…

【树莓派】yolov5 Lite,目标检测,行人检测入侵报警,摄像头绑定

延续之前的程序&#xff1a; https://qq742971636.blog.csdn.net/article/details/138172400 文章目录 播放声音pygame不出声音怎么办&#xff08;调节音量&#xff09;树莓派上的音乐播放器&#xff08;可选&#xff09;命令行直接放歌&#xff08;尝试放mp3歌曲&#xff09; …

数据结构-二叉搜索树(BST)

目录 什么是二叉搜索树 二叉搜索树的特性 (1)顺序性 (2)局限性 二叉搜索树的应用 二叉搜索树的操作 (1)查找节点 (2)插入节点 (3)删除节点 (4)中序遍历 什么是二叉搜索树 如图所示&#xff0c;二叉搜索树&#xff08;binary search tree&#xff09;满足以下条件。…

【Vivado那些事儿】使用 Python 提取 ILA 数据

ILA应该是调试AMD-Xilinx FPGA最常用的IP。 在调试中&#xff0c;我们希望 ILA 中的波形可以提供有关设计问题的所有信息&#xff0c;但情况并非如此。对于复杂的调试&#xff0c;我们还需要将 ILA 捕获的真实数据存储到可以进一步处理的文件中。根据放置 ILA 的位置&#xff0…

C语言阶段的题目解析

前言 我们C语言已经学习的差不多了&#xff0c;但是C语言之中存在的一些问题与难点我们还不一定能够又快又好地解决&#xff0c;为了夯实我们的基础&#xff0c;我们来练习几道稍微有点难度的C语言习题吧 例题一 题目 int main(void) {unsigned char i 7;int j 0;for (; i…