FFmpeg 详解

FFmpeg 详解

整体结构

在这里插入图片描述

不同下载版本的区别

官网下载的 FFmpeg 分为3个版本:Static,Shared,Dev。介绍如下。

前两个版本可以直接在命令行中使用,他们的区别在于:

  1. Static里面只有3个应用程序:ffmpeg.exe,ffplay.exe,ffprobe.exe,每个exe的体积都很大,相关的Dll已经被编译到exe里面去了。
  2. Shared里面除了3个应用程序:ffmpeg.exe,ffplay.exe,ffprobe.exe之外,还有一些Dll,比如说avcodec-54.dll之类的。Shared里面的exe体积很小,他们在运行的时候,到相应的Dll中调用功能。
  3. Dev版本是用于开发的,里面包含了include(头文件xxx.h)和lib(库文件xxx.lib),这个版本不包含exe文件。

常用库

  • AVUtil:核心工具库,下面的许多其他模块都会依赖该库做一些基本的音视频处理操作。
  • AVFormat:文件格式和协议库,该模块是最重要的模块之一,封装了Protocol层和Demuxer、Muxer层,使得协议和格式对于开发者来说是透明的。
  • AVCodec:编解码库,封装了Codec层,但是有一些Codec是具备自己的License的,FFmpeg是不会默认添加像libx264、FDK-AAC等库的,但是FFmpeg就像一个平台一样,可以将其他的第三方的Codec以插件的方式添加进来,然后为开发者提供统一的接口。
  • AVFilter:音视频滤镜库,该模块提供了包括音频特效和视频特效的处理,在使用FFmpeg的API进行编解码的过程中,直接使用该模块为音视频数据做特效处理是非常方便同时也非常高效的一种方式。
  • AVDevice:输入输出设备库,比如,需要编译出播放声音或者视频的工具ffplay,就需要确保该模块是打开的,同时也需要SDL的预先编译,因为该设备模块播放声音与播放视频使用的都是SDL库。
  • SwResample:该模块可用于音频重采样,可以对数字音频进行声道数、数据格式、采样率等多种基本信息的转换。
  • SWScale:该模块是将图像进行格式转换的模块,比如,可以将YUV的数据转换为RGB的数据,缩放尺寸由1280×720变为800×480。
  • PostProc:该模块可用于进行后期处理,当我们使用AVFilter的时候需要打开该模块的开关,因为Filter中会使用到该模块的一些基础函数。

常用函数

初始化

  • av_register_all():注册所有组件,4.0已经弃用。
  • avdevice_register_all():对设备进行注册,比如V4L2等。
  • avformat_network_init():初始化网络库以及网络加密协议相关的库(比如openssl)。

封装格式

  • avformat_alloc_context():负责申请一个AVFormatContext结构的内存,并进行简单初始化。
  • avformat_free_context():释放该结构里的所有东西以及该结构本身。
  • avformat_close_input():关闭解复用器。关闭后就不再需要使用avformat_free_context 进行释放。
  • avformat_open_input():打开多媒体数据并且获得一些相关的信息。
  • avformat_find_stream_info():获取媒体文件中每个音视频流的详细信息,包括解码器类型、采样率、声道数、码率、关键帧等信息。
  • av_read_frame():读取码流中的音频若干帧或者视频一帧。
  • avformat_seek_file():定位文件。
  • av_seek_frame():定位帧。

流程:

在这里插入图片描述

解码器

  • avcodec_alloc_context3():分配解码器上下文。
  • avcodec_find_decoder():根据ID查找解码器。
  • avcodec_find_decoder_by_name():根据解码器名字。
  • avcodec_open2():打开编解码器。
  • avcodec_decode_video2():解码一帧视频数据。输入一个压缩编码的结构体AVPacket,输出一个解码后的结构体AVFrame。
  • avcodec_decode_audio4():解码一帧音频数据。
  • avcodec_send_packet():发送编码数据包。
  • avcodec_receive_frame():接收解码后数据。
  • avcodec_free_context():释放解码器上下文,包含了avcodec_close()。
  • avcodec_close():关闭解码器。

在这里插入图片描述

版本对比

FFmpeg的版本众多,从2010年开始计算的项目的话,基本上还在使用的有ffmpeg2/3/4/5/6,最近几年版本彪的比较厉害,直接4/5/6,大版本之间接口有一些变化,特别是一些废弃接口被彻底删除了,编程时要特别注意兼容性的问题。

在这里插入图片描述

组件注册方式对比

FFmpeg 3.x 组件注册方式

我们使用FFmpeg,首先要执行av_register_all,把全局的解码器、编码器等结构体注册到各自全局的对象链表里,以便后面查找调用。

在这里插入图片描述

FFmpeg 4.x 组件注册方式

FFmpeg内部去做,不需要用户调用API去注册。

以codec编解码器为例:在configure的时候生成要注册的组件,这里会生成一个codec_list.c文件,里面只有static const AVCodec *const codec_list[]数组。在libavcodec/allcodecs.c将static const AVCodec *const codec_list[]的编解码器用链表的方式组织起来。

对于demuxer/muxer:libavformat/muxer_list.c、libavformat/demuxer_list.c这两个文件也是在configure的时候生成,直接下载源码是没有这两个文件的。在libavformat/allformats.c将demuxer_list[]和muexr_list[]以链表的方式组织。

其他组件也是类似的方式。

结构体比对

  1. PIX_FMT_YUV420P变成了AV_PIX_FMT_YUV420P。
  2. 解码器 AVStream::codec 被声明为已否决,现在去掉了stream->codec,解码器放在 stream->codecpar 中。

更多差别参见:

  1. ffmpeg新旧函数对比

函数对比

avcodec_decode_video2()

avcodec_decode_video2() 原本的解码函数被拆解为两个函数avcodec_send_packet()和avcodec_receive_frame()。具体用法如下:

old:

avcodec_decode_video2(pCodecCtx, pFrame, &got_picture, pPacket);

new:

avcodec_send_packet(pCodecCtx, pPacket);
avcodec_receive_frame(pCodecCtx, pFrame);
vcodec_encode_video2()

vcodec_encode_video2() 对应的编码函数也被拆分为两个函数avcodec_send_frame()和avcodec_receive_packet()。具体用法如下:

old:

avcodec_encode_video2(pCodecCtx, pPacket, pFrame, &got_picture);

new:

avcodec_send_frame(pCodecCtx, pFrame);
avcodec_receive_packet(pCodecCtx, pPacket);

更多函数和一些细微上的差别:

  1. ffmpeg新旧函数对比
  2. Qt/C++音视频开发50-不同ffmpeg版本之间的差异处理

数据结构

  • AVFormatContext:封装格式上下文结构体,也是统领全局的结构体,保存了视频文件封装格式相关信息。
  • AVInputFormat:解复用器对象,每种封装格式(例如FLV, MKV, MP4, AVI)对应一个该结构体。
  • AVOutputFormat:复用器对象,表示输出文件容器格式。
  • AVStream:视频文件中每个视频(音频)流对应一个该结构体。
  • AVCodecContext:编解码器上下文结构体,保存了视频(音频)编解码相关信息。
  • AVCodec:每种视频(音频)编解码器(例如H.264解码器)对应一个该结构体。
  • AVPacket:存储一帧压缩编码数据。
  • AVFrame:存储一帧解码后像素(采样)数据。

如果上下文数据保存在解码器里面?
多路解码的时候数据肯定有冲突。

它们之间的关系:

在这里插入图片描述

结构体分析

AVFormatContext

在这里插入图片描述

常用的成员:

struct AVInputFormat* iformat; // 输入数据的封装格式
AVIOContext *pb; // 输入数据的缓存
unsigned int nb_streams; // 音视频流个数
AVStream** streams; // 音视频流
int64_t duration; // 时长(us)
int bit_rate; // 比特率(bps)
AVDictionary *metadata; // 元数据
AVInputFormat

成员变量:

const char *name; // 格式名列表.也可以分配一个新名字。
const char *long_name; // 格式的描述性名称,意味着比名称更易于阅读。
int flags;
// 可用的flag有: AVFMT_NOFILE, AVFMT_NEEDNUMBER, AVFMT_SHOW_IDS,AVFMT_GENERIC_INDEX, AVFMT_TS_DISCONT, AVFMT_NOBINSEARCH,AVFMT_NOGENSEARCH, AVFMT_NO_BYTE_SEEK, AVFMT_SEEK_TO_PTS.
const char *extensions; // 文件扩展名
const AVClass *priv_class; // 一个模拟类型列表.用来在probe的时候check匹配的类型。
struct AVInputFormat *next; // 用于把所有支持的输入文件容器格式连接成链表,便于遍历查找。
int priv_data_size; // 标示具体的文件容器格式对应的Context的大小。

函数:

int (*read_probe)(AVProbeData *);//判断一个给定的文件是否有可能被解析为这种格式。 给定的buf足够大,所以你没有必要去检查它,除非你需要更多 。
int (*read_header)(struct AVFormatContext *);//读取format头并初始化AVFormatContext结构体,如果成功,返回0。创建新的流需要调用avformat_new_stream。
int (*read_header2)(struct AVFormatContext *, AVDictionary **options);//新加的函数指针,用于打开进一步嵌套输入的格式。
int (*read_packet)(struct AVFormatContext *, AVPacket *pkt);//读取一个数据包并将其放在“pkt”中。 pts和flag也被设置。
int (*read_close)(struct AVFormatContext *);//关闭流。 AVFormatContext和Streams不会被此函数释放。
int (*read_seek)(struct AVFormatContext *, int stream_index, int64_t timestamp, int flags);
int64_t (*read_timestamp)(struct AVFormatContext *s, int stream_index, int64_t *pos, int64_t pos_limit);//获取stream [stream_index] .time_base单位中的下一个时间戳。
int (*read_play)(struct AVFormatContext *);//开始/继续播放 - 仅当使用基于网络的(RTSP)格式时才有意义。
int (*read_pause)(struct AVFormatContext *);//暂停播放 - 仅当使用基于网络的(RTSP)格式时才有意义。
int (*read_seek2)(struct AVFormatContext *s, int stream_index, int64_t min_ts, int64_t ts, int64_t max_ts, int flags);//寻求时间戳ts。
int (*get_device_list)(struct AVFormatContext *s, struct AVDeviceInfoList *device_list);返回设备列表及其属性。
int (*create_device_capabilities)(struct AVFormatContext *s, struct AVDeviceCapabilitiesQuery *caps);//初始化设备能力子模块。
int (*free_device_capabilities)(struct AVFormatContext *s, struct AVDeviceCapabilitiesQuery *caps);//释放设备能力子模块。
AVStream

AVStream是存储每一个视频/音频流信息的结构体。
在这里插入图片描述

成员变量:

int index; // 标识该视频/音频流
AVCodecContext* codec; // 指向该音频/视频流的AVCodecContext(已被废弃,不推荐使用)
AVCodecParameters* codecpar; // 获得AVCodecParameters对象,用于替代AVCodecContext(推荐使用)
int64_t duration; // 视频长度
AVRational avg_frame_rate; // 视频平均帧率
// 其中,AVRational表示有理数,他有两个参数:avg_frame_rate.num是分子,avg_frame_rate.den是分母。
// 平均帧率 = avg_frame_rate.num / avg_frame_rate.den
AVCodecParameters

AVCodecParameters是FFmpeg库中的一个结构体,用于保存音视频流的基本参数信息。该结构体通常会在AVCodecContext中被填充并使用。

在这里插入图片描述

AVCodecContext

编码器上下文AVCodecContext是FFmpeg中用于描述编码器状态的结构体,包含了许多参数和配置选项,用于控制编码器的行为和性能。

常用成员变量:

enum AVMediaType codec_type; // 编解码器的类型(视频,音频...)
struct AVCodec *codec; // 采用的解码器AVCodec(H.264,MPEG2...)
int bit_rate; // 平均比特率
uint8_t *extradata;// 针对特定编码器包含的附加信息(例如对于H.264解码器来说,存储SPS,PPS等)
int extradata_size:
AVRational time_base; // 根据该参数,可以把PTS转化为实际的时间(单位为秒s)
int width, height; // 如果是视频的话,代表宽和高
int refs; // 运动估计参考帧的个数(H.264的话会有多帧,MPEG2这类的一般就没有了)
int sample_rate; // 采样率(音频)
int channels; // 声道数(音频)
enum AVSampleFormat sample_fmt; // 采样格式
AVCodec

AVCodec是存储编解码器信息的结构体。

常用成员变量:

const char *name; // 编解码器的名字,比较短
const char *long_name; // 编解码器的名字,全称,比较长
enum AVMediaType type; // 指明了类型,是视频,音频,还是字幕
enum AVCodecID id; // 编解码器ID,不重复
const AVRational *supported_framerates; // 支持的帧率(仅视频)
const enum AVPixelFormat *pix_fmts; // 支持的像素格式(仅视频)
const int *supported_samplerates; // 支持的采样率(仅音频)
const enum AVSampleFormat *sample_fmts; // 支持的采样格式(仅音频)
const uint64_t *channel_layouts; // 支持的声道数(仅音频)
int priv_data_size; // 私有数据的大小
// 初始化编解码器静态数据,从avcodec_register()调用。
void (*init_static_data)(struct AVCodec *codec);

关键函数:

// 将数据编码到AVPacket
int (*encode2)(AVCodecContext *avctx, AVPacket *avpkt, const AVFrame *frame, int *got_packet_ptr);
// 解码数据到AVPacket
int (*decode)(AVCodecContext *, void *outdata, int *outdata_size, AVPacket *avpkt);
// 关闭编解码器
int (*close)(AVCodecContext *);
// 刷新缓冲区。当seek时会被调用
void (*flush)(AVCodecContext *);
AVPacket

AVPacket是存储压缩编码数据相关信息的结构体,保存了解封装之后,解码之前的数据(仍然是压缩后的数据)和关于这些数据的一些附加信息,如显示时间戳(pts)、解码时间戳(dts)、数据时长、所在媒体流的索引等。

对于视频(Video)来说,AVPacket通常包含一个压缩的Frame,而音频(Audio)则有可能包含多个压缩的Frame。并且,一个Packet有可能是空的,不包含任何压缩数据,只含有side data(side data,容器提供的关于Packet的一些附加信息。例如,在编码结束的时候更新一些流的参数)。

在这里插入图片描述

关键成员变量:

uint8_t *data; // 指向压缩编码数据的指针
// 对于packed格式的数据(例如RGB24),会存到data[0]里面
// 对于planar格式的数据(例如YUV420P),则会分开成data[0],data[1],data[2]
int size; // data的大小
int64_t pts; // 显示时间戳
int64_t dts; // 解码时间戳
int stream_index; // 标识该AVPacket所属的视频/音频流
int flags; // packet标志位,比如是否关键帧等
int64_t pos; // 当前包在流中的位置,单位字节
int64_t duration; // 数据的时长,以所属媒体流的时间基准为单位,未知则值为默认值0
AVBufferRef *buf; // 用来管理data指针引用的数据缓存

关键函数:

函数定义解释
int av_read_frame(AVFormatContext *s, AVPacket *pkt);读取码流中的音频若干帧或者视频一帧,填充AVPacket
AVPacket *av_packet_alloc(void);分配AVPacket这个时候和buffer没有关系
void av_packet_free(AVPacket **pkt);释放AVPacket和_alloc对应
void av_init_packet(AVPacket *pkt);初始化AVPacket只是单纯初始化pkt字段
int av_new_packet(AVPacket *pkt, int size);给AVPacket的buf分配内存, 引用计数初始化为1
int av_packet_ref(AVPacket *dst, const AVPacket *src);从src复制一个AVPacket结构体到dst,增加引用计数
void av_packet_unref(AVPacket *pkt);注销一个AVPacket对象,减少引用计数,若引用计数变成0,则回收缓冲区内存
void av_packet_move_ref(AVPacket *dst, AVPacket *src);转移引用计数
AVPacket *av_packet_clone(const AVPacket *src);等于av_packet_alloc()+av_packet_ref()
AVFrame

AVFrame结构体一般用于存储原始数据(即非压缩数据,例如对视频来说是YUV,RGB,对音频来说是PCM),此外还包含了一些相关的信息。

关键成员变量:

// 解码后原始数据(对视频来说是YUV,RGB,对音频来说是PCM)
// 这个data变量是一个指针数组,对于视频,可以简单地理解为三个一维数组
uint8_t *data[AV_NUM_DATA_POINTERS]; 
// data中“一行”数据的大小。注意:未必等于图像的宽,一般大于图像的宽。
int linesize[AV_NUM_DATA_POINTERS];
int width, height; // 视频帧宽和高
int nb_samples; // 音频的一个AVFrame中可能包含多个音频帧,在此标记包含了几个
int format; // 解码后原始数据类型(YUV420,YUV422,RGB24...)
int key_frame; // 是否是关键帧
enum AVPictureType pict_type; // 帧类型(I,B,P...)
AVRational sample_aspect_ratio; // 宽高比(16:9,4:3...)
int64_t pts; // 显示时间戳
int coded_picture_number; // 编码帧序号
int display_picture_number; // 显示帧序号

关键函数:

函数定义解释
AVFrame *av_frame_alloc(void);申请AVFrame结构体空间,同时会对申请的结构体初始化
void av_frame_free(AVFrame **frame);释放AVFrame的结构体空间
int av_frame_ref(AVFrame *dst, const AVFrame *src);对已有AVFrame的引用,增加引用计数
void av_frame_unref(AVFrame *frame);对frame释放引用,减少引用计数,若引用计数变成0,则释放data的空间
void av_frame_move_ref(AVFrame *dst, AVFrame *src);转移引用计数
int av_frame_get_buffer(AVFrame *frame, int align);建立AVFrame中的data内存空间,使用这个函数之前frame结构中的format、width、height必须赋值
AVFrame *av_frame_clone(const AVFrame *src);等于av_frame_alloc()+av_frame_ref()

数据结构之间的关系

AVFormatContext和AVInputFormat之间的关系
int avformat_open_input(AVFormatContext **ps, const char *filename,
                        AVInputFormat *fmt, AVDictionary **options)

参数说明:

  1. AVFormatContext **ps:格式化的上下文。要注意,如果传入的是一个AVFormatContext*的指针,则该空间须自己手动清理,若传入的指针为空,则FFmpeg会内部自己创建。
  2. const char *filename:传入的文件地址。支持http、RTSP以及普通的本地文件。地址最终会存入到AVFormatContext结构体当中。
  3. AVInputFormat *fmt, 指定输入的封装格式。一般传NULL,由FFmpeg自行探测。
  4. AVDictionary **options, 其它参数设置。它是一个字典,用于参数传递,不传则写NULL。

在这里插入图片描述

AVCodecContext和AVCodec之间的关系

AVCodecContext:编码器上下文结构体,用于存储音视频编解码器的参数和状态信息。它包含了进行音视频编解码所需的各种设置和配置,如编码器类型、编码参数、解码参数、输入输出格式等。每个音视频流在编解码过程中都需要一个对应的AVCodecContext来描述和控制编解码器的行为。在解码过程中,AVCodecContext用于接收解码后的音视频数据。在编码过程中,AVCodecContext用于传递待编码的音视频数据。

struct AVCodec *codec;

AVCodec:音视频编解码器结构体,用于定义特定的编解码器。它包含了编解码器的类型、名称、支持的音视频格式、编解码函数等。通过AVCodec结构体,可以查询和获取系统中可用的编解码器,并与AVCodecContext关联以进行音视频编解码操作。

int (*decode)(AVCodecContext *, void *outdata, int *outdata_size, AVPacket *avpkt);
int (*encode2)(AVCodecContext *avctx, AVPacket *avpkt, const AVFrame *frame, int *got_packet_ptr);

AVCodecContext是对AVCodec的实例化使用,用于配置和管理编解码器的参数和状态,而AVCodec则定义了编解码器的具体功能和操作。两者共同协作,实现音视频的编解码过程。在使用FFmpeg进行音视频编解码时,首先需要选择合适的AVCodec,然后为每个音视频流创建对应的AVCodecContext,并将它们关联起来。AVCodecContext提供了对编解码器的参数进行设置的接口,如编码器参数、解码器参数、输入输出格式等。然后,通过调用相关的编解码函数,使用AVCodecContext进行音视频数据的编解码操作。

三个问题:

  1. AVCodecContext和AVCodec之间的关系是一对多的吗?

不,AVCodecContext和AVCodec之间的关系不是一对多的,而是一对一的关系。

每个AVCodecContext实例对应一个特定的编解码器,而每个编解码器对应一个AVCodec结构体。这意味着在一个AVCodecContext中,只能与一个特定的AVCodec相关联。

在使用FFmpeg进行音视频编解码时,通常会为每个音视频流创建一个对应的AVCodecContext来描述和控制编解码器的行为。在这种情况下,每个AVCodecContext会与一个特定的AVCodec相关联,用于执行相应的音视频编解码操作。

请注意,虽然多个AVCodecContext可能使用相同的AVCodec结构体进行实例化,但每个AVCodecContext都有自己的状态和参数设置,因此在使用过程中它们是独立的。这意味着每个AVCodecContext都有自己的上下文和状态,不会相互影响。

  1. AVCodecContext和AVCodec之间的关系是否可以动态地改变?

在一般情况下,AVCodecContext和AVCodec之间的关系是静态的,即在创建AVCodecContext时,会指定它所使用的特定AVCodec。一旦AVCodecContext与特定的AVCodec相关联,通常情况下不能动态地改变它们之间的关系。

这是因为AVCodecContext的配置和状态是基于特定的编解码器,而不同的编解码器可能具有不同的参数和行为。因此,如果要更改AVCodecContext的编解码器,通常需要先释放旧的AVCodecContext,然后重新创建一个新的AVCodecContext并与新的AVCodec相关联。

需要注意的是,这种重新关联的操作可能涉及到重新设置和初始化AVCodecContext的参数,以适应新的编解码器。这可能包括重新配置编码参数、解码参数、输入输出格式等。

总结来说,一般情况下,AVCodecContext和AVCodec之间的关系是静态的,一旦关联,通常不能动态地改变它们之间的关系。如果需要更改编解码器,通常需要释放旧的AVCodecContext并重新创建一个新的AVCodecContext并与新的AVCodec相关联。

  1. avcodec_open2初始化的是AVCodec还是AVCodecContext?

avcodec_open2函数用于初始化和打开一个编解码器,并将其与给定的AVCodecContext相关联。因此,avcodec_open2函数初始化的是AVCodecContext。

具体来说,avcodec_open2函数会根据AVCodecContext中的配置信息找到对应的AVCodec,然后初始化该编解码器,并将其与AVCodecContext关联起来。这样,AVCodecContext就准备好进行音视频编解码操作了。

在调用avcodec_open2函数之前,需要确保AVCodecContext已经正确设置了所需的参数,例如编码器类型、输入输出格式、编解码参数等。avcodec_open2函数会根据这些参数初始化相应的编解码器,并将其与AVCodecContext相关联,以便后续的编解码操作。

需要注意的是,一旦调用了avcodec_open2函数,AVCodecContext的参数就不能再被修改,否则可能导致未定义的行为。因此,在调用该函数之前,应该确保AVCodecContext已经正确设置了所有必要的参数。

总结来说,avcodec_open2函数用于初始化和打开一个编解码器,并将其与给定的AVCodecContext相关联,以准备进行音视频编解码操作。

AVFormatContext, AVStream和AVCodecContext之间的关系

在这里插入图片描述

如何区别不同的码流?

  • AVMEDIA_TYPE_VIDEO:视频流
  • AVMEDIA_TYPE_AUDIO:音频流
// 查找最佳匹配的媒体流
video_index = av_find_best_stream(ic, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
audio_index = av_find_best_stream(ic, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);

AVPacket 里面也有一个index的字段。

内存模型

从av_read_frame读取到一个AVPacket后怎么放入解码器队列?

从avcodec_recevice_frame读取到一个AVFrame后又怎么放入解压后的帧队列?

从现有的Packet拷贝一个新Packet的时候,有两种情况:

  1. 两个Packet的buf引用的是同一数据缓存空间,这时候要注意数据缓存空间的释放问题。
  2. 两个Packet的buf引用不同的数据缓存空间,每个Packet都有数据缓存空间的copy。

在这里插入图片描述

FFmpeg 内存模型:

在这里插入图片描述

在这里插入图片描述

对于多个AVPacket共享同一个缓存空间, FFmpeg使用的引用计数的机制(reference-count) :

  1. 初始化引用计数为0,只有真正分配AVBuffer的时候,引用计数初始化为1
  2. 当有新的Packet引用共享的缓存空间时, 就将引用计数+1
  3. 当释放了引用共享空间的Packet,就将引用计数-1
  4. 引用计数为0时,就释放掉引用的缓存空间AVBuffer

AVFrame也是采用同样的机制。

  • 71
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值