网站管理后台文章排序/关联词有哪些小学
本篇作为 Android 音视频实战系列的第二篇文章,主要介绍视频解码与渲染过程。本系列文章目录如下:
Android 音视频基础知识
Android 音视频播放器 Demo(一)—— 视频解码与渲染
Android 音视频播放器 Demo(二)—— 音频解码与音视频同步
RTMP 直播推流 Demo(一)—— 项目配置与视频预览
RTMP 直播推流 Demo(二)—— 音频推流与视频推流
1、项目概述
1.1 项目配置
FFmpeg 的交叉编译我们在前面介绍过,这里就不再赘述了,有需要可以去参考NDK 编译(二)—— NDK 编译与集成 FFmpeg。
这里主要介绍 FFmpeg 的环境配置,分三步:
-
FFmpeg 编译产物的静态库(6 个 .a 文件)复制到 libs/armeabi-v7a 下,include 文件夹复制到 src/main/cpp 目录下
-
更改 app 模块下的 build.gradle 文件,添加 abiFilter 只编译 arm-v7a:
android {defaultConfig {externalNativeBuild {cmake {abiFilters 'armeabi-v7a'}}ndk {abiFilters 'armeabi-v7a'}} }
-
修改 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-playerSHARED${sources})target_link_libraries(video-player# FFmpeg 源码编译出的 6 个静态库avcodec avfilter avformat avutil swresample swscalelogz# 在 Native 进行视频渲染时要用到 ANativeWindowandroid# 在 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 可以分为上下两层:
- 上层:主要是 UI 方面的,提供 SurfaceView 进行视频渲染。此外,还需要根据生命周期调用 Native 方法控制底层的播放
- Native 层:Native 层接收上层发来的播放指令,还需要通过 CallbackHelper 通知上层播放状态。此外,Native 层需要抽离出一个控制层,对音视频解码线程进行控制,接收解码的数据后要渲染到屏幕/麦克风上
Native 控制层示意图如下:
控制层的主要作用:
- 初始化 FFmpeg 参数
- 控制播放进度(播放、停止、控制播放速度等)
- 从视频文件(视频流)中解析出 AVPacket 存入视频/音频队列
可以看到音视频各有一个保存 AVPacket 的队列,由于 AVPacket 是压缩数据,我们需要从队列中取出 AVPacket 解压为 AVFrame 再存入队列,因此 AVFrame 也是有一个队列的:
视频层作用:
- 不断地从 Packet 队列中取出 AVPacket 解压为 AVFrame 后存入 AVFrame 队列。这是通过死循环进行的耗时操作,因此需要放入特定的解压线程中操作
- 不断地从 AVFrame 队列中取出 AVFrame 放入播放线程的 buffer 中,最终要回到控制层将 AVFrame 渲染到屏幕上
音频层类似:
解压后的音频数据通过 OpenSLES 进行播放。
AudioTrack 底层实际上也是使用的 OpenSLES。
最后来介绍一下实现步骤:
- 准备阶段:
- 实现 Native 反射调用上层的机制 JNICallbackHelper
- 初始化 FFmpeg 解码器
- 视频解码:
- 创建一个同步队列 SafeQueue 用于承载 AVPacket 和 AVFrame 数据
- 创建专门处理视频解码工作的通道 VideoChannel,读取 AVPacket 并解码为 AVFrame
- 视频渲染:
- 将上层 SurfaceView 的 Surface 传给 Native 控制层,设置好 Native 层的窗口对象 ANativeWindow
- 将 VideoChannel 解码后的帧数据回调给 Native 控制层,渲染在 ANativeWindow 上
- 音频解码与渲染:
- 创建专门处理音频解码工作的通道 AudioChannel 进行音频解码,具体方式与视频解码几乎一致
- 将解码后的音频数据交给 OpenSLES 进行播放
- 创建一个通道的基类 BaseChannel 用于定义视频通道 VideoChannel 和音频通道 AudioChannel 的共同操作
- 音视频同步
- 添加进度条与播放时间
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"><SurfaceViewandroid:id="@+id/surfaceView"android:layout_width="match_parent"android:layout_height="200dp" /><!-- 进度条 --><LinearLayoutandroid:layout_width="match_parent"android:layout_height="30dp"android:layout_margin="5dp"><TextViewandroid: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" /><SeekBarandroid: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: ActivityMainBindingprivate lateinit var videoPlayer: VideoPlayeroverride 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: SurfaceHolderprivate var onPreparedListener: OnPreparedListener? = nullprivate var onErrorListener: OnErrorListener? = nullfun 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);// 打开视频数据源,成功则返回 0int 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,它会同时帮你释放解码器 AVCodecavcodec_free_context(&avCodecContext);avformat_close_input(&avFormatContext);LOGE("获取解码器上下文失败");return;}// 3.3 根据解码器上下文参数填充解码器上下文 AVCodecContextresult = 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();
}
解码的操作包含两部分:
- 首先从媒体流中读取出 AVPacket,既可能是音频,也可能是视频,区分类型后存入相应通道的 AVPacket 队列中
- 从 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);
}
这一节我们只看解码线程。主要步骤是:
- 从 AVPacket 队列中不断取出 AVPacket,先通过 avcodec_send_packet() 将其发送给解码器
- 通过 av_frame_alloc() 创建一个 AVFrame,再通过 avcodec_receive_frame() 读取到解码后的 AVFrame
- 将 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;}// 从队列中取出一个 AVPacketresult = 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 出来的情况还要再回收一次 AVPacketav_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? = nullfun setSurfaceHolder(surfaceHolder: SurfaceHolder) {this.surfaceHolder?.removeCallback(this)this.surfaceHolder = surfaceHolderthis.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 endprivate 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);// 先销毁之前的 ANativeWindowif (window) {ANativeWindow_release(window);window = nullptr;}// 再创建新的 ANativeWindowwindow = 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≈8MBav_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]);// 释放 AVFrameav_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 的时候进行休眠。
到这里,可以顺利播放视频了,但是由于音频解码与渲染还没做,因此当前视频无声。下一篇文章我们再介绍音频如何处理。
相关文章:

Android 音视频播放器 Demo(一)—— 视频解码与渲染
本篇作为 Android 音视频实战系列的第二篇文章,主要介绍视频解码与渲染过程。本系列文章目录如下: Android 音视频基础知识 Android 音视频播放器 Demo(一)—— 视频解码与渲染 Android 音视频播放器 Demo(二ÿ…...

Flutter笔记:Widgets Easier组件库(3)使用按钮组件
Flutter笔记 Widgets Easier组件库(3):使用按钮组件 - 文章信息 - Author: 李俊才 (jcLee95) Visit me at CSDN: https://jclee95.blog.csdn.netMy WebSite:http://thispage.tech/Email: 291148484163.com. Shenzhen ChinaAddre…...

荷香堪筑梦,鸳鸯和月寻。(变相BFS搜索)
本题链接:登录—专业IT笔试面试备考平台_牛客网 题目: 样例: 输入 3 4 2 .... ***. ..a. 输出 yes 思路: 根据题意,这里 1 s 可以移动多次,我们将每次可以移动避开雪的的位置存储起来,判断当…...

智慧公厕建设,打造智慧城市基础设施新亮点
公共厕所是城市基础设施的重要组成部分,而智慧公厕的建设则是现代城市管理的创新之举。为了实现公厕的精细化管理和提供更便捷的服务,推进智慧公厕建设必须要实现技术融合、业务融合、数据融合的目标,跨越层级、地域、系统、部门和业务的限制…...

Kubernetes 教程:在 Containerd 容器中使用 GPU
原文链接:Kubernetes 教程:在 Containerd 容器中使用 GPU 云原生实验室本文介绍了如何在使用 Containerd 作为运行时的 Kubernetes 集群中使用 GPU 资源。https://fuckcloudnative.io/posts/add-nvidia-gpu-support-to-k8s-with-containerd/ 前两天闹得沸沸扬扬的事件不知道…...

LNMP部署wordpress
1.环境准备 总体架构介绍 序号类型名称外网地址内网地址软件02负载均衡服务器lb0110.0.0.5192.168.88.5nginx keepalived03负载均衡服务器lb0210.0.0.6192.168.88.6nginx keepalived04web服务器web0110.0.0.7192.168.88.7nginx05web服务器web0210.0.0.8192.168.88.8nginx06we…...

本地部署大模型ollama+docker+open WebUI/Lobe Chat
文章目录 大模型工具Ollama下载安装运行Spring Ai 代码测试加依赖配置写代码 ollama的web&Desktop搭建部署Open WebUI有两种方式Docker DesktopDocker部署Open WebUIDocker部署Lobe Chat可以配置OpenAI的key也可以配置ollama 大模型的选择 本篇基于windows环境下配置 大模型…...

qt学习篇---界面按键关联(信号和槽)
目录 1.qt基础 2.做一个界面 创建project UI界面设计 信号和槽 1.控件改名字 2.什么是信号和槽 3.怎么关联信号和槽 自动关联 手动关联 1.qt基础 qt可移植性强,不久会用到MCU。很有意义学习 2.做一个界面 创建project 不要中文路径 选择QWidget .pro文件…...

python Django 的内置权限系统或自定义模型来存储更复杂的角色和权限关系
在 Django 中,管理用户权限和角色通常涉及到使用 Django 的内置权限系统或自定义模型来存储更复杂的角色和权限关系。下面是一个基本的指南,说明如何在 Django 中为后台管理系统分配权限并将其保存在数据库中,同时结合 Vue.js 和 Element UI 作为前端框架。 后端(Django)…...

不上班,我靠这5份赚钱副业养活了自己
在这个快节奏的社会里,很多人都在为生活奔波忙碌。今天,就让我来跟大家分享一下我的“躺平”秘籍吧! 这一个月来,我没有上班,但好在有副业养活自己。有时候,我真的觉得有一份自己喜欢的自媒体副业挺好的。…...

强一致性的皇冠:分布式事务模型的至高法则揭秘
关注微信公众号 “程序员小胖” 每日技术干货,第一时间送达! 引言 分布式事务模型是分布式系统设计的核心,关键在于保证数据一致性和事务完整性,尤其强调强一致性。诸如2PC、3PC、Saga、TCC等模型与协议,应运而生以解…...

mac/windows下安装docker,minikube
1、安装docker Get Started | Docker 下载安装docker 就行 启动后,就可以正常操作docker了 使用docker -v 验证是否成功就行 2、安装minikube,是基于docker-desktop的 2.1、点击设置 2.2、选中安装,这个可能需要一点时间 这样安装后&…...

【爬虫】fake_useragent的使用、BeautifulSoup(find()和find_all())
1 fake_useragent 2 BeautifulSoup 3 Beautiful Soup库的find()和find_all() 1 fake_useragent fake_useragent是一个Python库,用于生成随机的用户代理字符串。 用户代理是在HTTP请求中发送给服务器的一种标识,它告诉服务器发送请求的客户端的类型、版本…...

ComfyUI中图像亮度/对比度/饱和度处理
用上面这个节点可以同时设置图片的亮度、对比度和饱和度。 【保姆级教程】一口气分享在ComfyUI中常用的30多种基本图像处理方式 更多好玩且实用AIGC工作流和节点 星球号:32767063 本期资料链接 往期学习资料 整理AI学习资料库...

基于FPGA的DDS波形发生器VHDL代码Quartus仿真
名称:基于FPGA的DDS波形发生器VHDL代码Quartus仿真(文末获取) 软件:Quartus 语言:VHDL 代码功能: DDS波形发生器VHDL 1、可以输出正弦波、方波、三角波 2、可以控制输出波形的频率 DDS波形发生器原理…...

C++语法|可调用对象与function类型
文章目录 引入function的使用function类型的典型应用function类型的原理实现代码优化可变参的函数对象 引入 还记得C语言中的函数指针数组吗? 我们通过函数指针数组实现一个,图书管理系统的界面: #include <stdio.h> void doShowAllB…...

Linux学习之路 -- 文件 -- 文件描述符
前面介绍了与文件相关的各种操作,其中的各个接口都离不开一个整数,那就是文件描述符,本文将介绍文件描述符的一些相关知识。 目录 <1>现象 <2>原理 文件fd的分配规则和利用规则实现重定向 <1>现象 我们可以先通过prin…...

JDK动态代理和Cglib动态代理区别
1.如果目标类实现了接口,将会使用JDK动态代理,否则会使用Cglib动态代理; 2.JDK代理使用自己的字节码生成工具生成代理对象,而Cglib会使用ASM字节码生成工具去生成; 3.JDK动态代理是通过反射的方式去实现代理对象的所有方法,通过…...

牛客 | 字符金字塔
请打印输出一个字符金字塔,字符金字塔的特征请参考样例 #include <stdio.h> #include <string.h> using namespace std; int main() {char c;scanf("%c", &c);for (int i 1; i < (c - 64); i)//第一个循环决定了有多少行{//c:67 第三…...

【计算机科学速成课】笔记三——操作系统
文章目录 18.操作系统问题引出——批处理设备驱动程序多任务处理虚拟内存内存保护Unix 18.操作系统 问题引出—— Computers in the 1940s and early 50s ran one program at a time. 1940,1950 年代的电脑,每次只能运行一个程序 A programmer would write one at…...

用js代码实现贪吃蛇小游戏
js已经学了大部分了,现在就利用我所学的js知识试试做贪吃蛇小游戏吧 以下部分相关图片以及思路笔记均出自渡一陈老师的视频 首先制作简单的静态页面,添加贪吃蛇移动的背景和相关图片,比如开始游戏等等 将各个功能均封装在函数中࿰…...

微信小程序+esp8266温湿度读取
本文主要使用微信小程序显示ESP8266读取的温湿度并通过微信小程序控制LED灯。小程序界面如下图所示 原理讲解 esp8266 通过mqtt发布消息,微信小程序通过mqtt 订阅消息,小程序订阅后,就可以实时收到esp8266 传输来的消息。 个人可免费注册五个微信小程序账号,在微信小程序官…...

软考中级-软件设计师(十)网络与信息安全基础知识
一、网络概述 1.1计算机网络的概念 计算机网络的发展:具有通信功能的单机系统->具有通信功能的多机系统->以共享资源为目的的计算机网络->以局域网及因特网为支撑环境的分布式计算机系统 计算机网络的功能:数据通信、资源共享、负载均衡、高…...

推荐一个好用的命令行工具ShellGPT
ShellGPT 配置安装常用功能聊天写命令并执行 高级功能函数调用角色管理 总结 这两天突然想到,现有的很多工具都在被大模型重构,比如诞生了像perplexity.ai 这种新交互形式的搜索引擎,就连wps也推出了AI服务,甚至都可以直接生成ppt…...

Prompt提示词教程 | 提示工程指南 | 提示词示例 入门篇
在上一节中,我们介绍并给出了如何赋能大语言模型的基本示例。如果还没看而且是刚入门的同学建议看下,有个基本概念。 Prompt提示词教程 | 提示工程指南 | 提示工程简介https://blog.csdn.net/HRG520JN/article/details/138523705在本节中,我…...

uniapp + uView动态表单校验
项目需求:动态循环表单,并实现动态表单校验 页面: <u--form label-position"top" :model"tmForm" ref"tmForm" label-width"0px" :rulesrules><div v-for"(element, index) in tmForm…...

【Linux】HTTPS
欢迎来到Cefler的博客😁 🕌博客主页:折纸花满衣 🏠个人专栏:Linux 目录 👉🏻HTTPS协议概念👉🏻加密为什么要进行加密 👉🏻常见的加密方式对称加密…...

语音识别--使用YAMNet识别环境音
⚠申明: 未经许可,禁止以任何形式转载,若要引用,请标注链接地址。 全文共计3077字,阅读大概需要3分钟 🌈更多学习内容, 欢迎👏关注👀【文末】我的个人微信公众号…...

前端JS必用工具【js-tool-big-box】,邮箱,手机,身份证号,ip地址等正则验证方法学习
这一小节,我们针对前端npm包 js-tool-big-box 的使用做一些讲解,主要是针对项目中,邮箱,手机号,身份证号,ip地址,url格式,邮政编码等验证的方法使用。 目录 1 安装和引入 2 邮箱验…...

notepad++安装 hex-editor插件
打开notepad 点击插件 搜索 hex-editor,点击右侧 安装install 安装成功后,在已安装插件中就有显示了...