FFmpeg 内容介绍 音视频解码和播放

本文详细介绍了FFmpeg在视频压缩与解码中的关键概念,如封装格式、数据压缩类型、编解码标准,以及解码过程的步骤,重点讲解了如何使用FFmpeg进行视频解码和播放,包括Sws_getContext和avcodec_decode_video2等函数应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。FFmpeg本身是跨平台的,支持多个平台。

在我们常见的音视频文件(mp3,mp4, flv, flac, mkv, avi等)都是一种压缩过的封装格式文件。

封装格式的主要作用是把视频码流和音频码流按照一定的格式存储在一个文件中。

  1. 为什么要进行视频压缩? ● 未经压缩的数字视频的数据量巨大 ● 存储困难 ○ 一G只能存储几秒钟的未压缩数字视频。 ● 传输困难 ○ 1兆的带宽传输一秒的数字电视视频需要大约4分钟。
  2. 为什么可以压缩 ● 去除冗余信息 ○ 空间冗余:图像相邻像素之间有较强的相关性 ○ 时间冗余:视频序列的相邻图像之间内容相似 ○ 编码冗余:不同像素值出现的概率不同 ○ 视觉冗余:人的视觉系统对某些细节不敏感 ○ 知识冗余:规律性的结构可由先验知识和背景知识得到
  3. 数据压缩分类 ● 无损压缩(Lossless) ○ 压缩前解压缩后图像完全一致X=X' ○ 压缩比低(2:1~3:1) ○ 例如:Winzip,JPEG-LS ● 有损压缩(Lossy) ○ 压缩前解压缩后图像不一致X≠X' ○ 压缩比高(10:1~20:1) ○ 利用人的视觉系统的特性 ○ 例如:MPEG-2,H.264/AVC,AVS 人类视觉系统HVS ● HVS特点: ○ 对高频信息不敏感 ○ 对高对比度更敏感 ○ 对亮度信息比色度信息更敏感 ○ 对运动的信息更敏感X ● RGB转化到YUV空间 亮度分量Y与三原色有如下关系:

主流的编解码标准的压缩对象都是YUV图像

解协议的作用,就是将流媒体协议的数据,解析为标准的相应的封装格式数据。视音频在网络上传播的时候,常常采用各种流媒体协议,例如HTTP,RTMP,或是MMS等等。这些协议在传输视音频数据的同时,也会传输一些信令数据。

信令数据包括对播放的控制(播放,暂停,停止),或者对网络状态的描述等。解协议的过程中会去除掉信令数据而只保留视音频数据。例如,采用RTMP协议传输的数据,经过解协议操作后,输出FLV格式的数据。

解封装的作用,就是将输入的封装格式的数据,分离成为音频流压缩编码数据和视频流压缩编码数据。封装格式种类很多,例如MP4,MKV,RMVB,TS,FLV,AVI等等,它的作用就是将已经压缩编码的视频数据和音频数据按照一定的格式放到一起。例如,FLV格式的数据,经过解封装操作后,输出H.264编码的视频码流和AAC编码的音频码流。

解码的作用,就是将视频/音频压缩编码数据,解码成为非压缩的视频/音频原始数据。音频的压缩编码标准包含AAC,MP3,AC-3等等,视频的压缩编码标准则包含H.264,MPEG2,VC-1等等。解码是整个系统中最重要也是最复杂的一个环节。通过解码,压缩编码的视频数据输出成为非压缩的颜色数据,例如YUV420P,RGB等等;压缩编码的音频数据输出成为非压缩的音频抽样数据,例如PCM数据。

视音频同步的作用,就是根据解封装模块处理过程中获取到的参数信息,同步解码出来的视频和音频数据,并将视频音频数据送至系统的显卡

由表可见,除了AVI之外,其他封装格式都支持流媒体,即可以“边下边播”。有些格式更“万能”一些,支持的视音频编码标准多一些,比如MKV。而有些格式则支持的相对比较少,比如说RMVB。

视频编码

视频编码的主要作用是将视频像素数据(RGB,YUV等)压缩成为视频码流,从而降低视频的数据量。如果视频不经过压缩编码的话,体积通常是非常大的,一部电影可能就要上百G的空间。视频编码是视音频技术中最重要的技术之一。视频码流的数据量占了视音频总数据量的绝大部分。高效率的视频编码在同等的码率下,可以获得更高的视频质量。

音频编码

音频编码的主要作用是将音频采样数据(PCM等)压缩成为音频码流,从而降低音频的数据量。音频编码也是互联网视音频技术中一个重要的技术。但是一般情况下音频的数据量要远小于视频的数据量,因而即使使用稍微落后的音频编码标准,而导致音频数据量有所增加,也不会对视音频的总数据量产生太大的影响。高效率的音频编码在同等的码率下,可以获得更高的音质。

音频编码的简单原理

  • YUV420数据格式 YUV简介 YUV定义:分为三个分量, “Y”表示明亮度(Luminance或Luma)也就是灰度值 而“U”和“V” 表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色。

YUV存储:格式其实与其采样的方式密切相关,主流的采样方式有三种,YUV4:4:4,YUV4:2:2,YUV4:2:0,

YUV特点:也是一种颜色编码方法,它将亮度信息(Y)与色彩信息(UV)分离,没有UV信息一样 可以显示完整的图像,只不过是黑白的,这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。并且,YUV不像RGB那样要求三个独立的视频信号同时传 输,所以用YUV方式传送占用极少的频宽。

在采集到RGB24数据后,需要对这个格式的数据进行第一次压缩。即将图像的颜色空间由RGB2YUV。因为,X264在进行编码的时候需要标准的YUV(4:2:0)。但是这里需要注意的是,虽然YV12也是(4:2:0),但是YV12和I420的却是不同的,在存储空间上面有些区别。如下:

YV420: 亮度(行×列) + V(行×列/4) + U(行×列/4)

以后提取每个像素的YUV分量会用到。

  1. YUV 4:4:4采样,每一个Y对应一组UV分量。
  2. YUV 4:2:2采样,每两个Y共用一组UV分量。
  3. YUV 4:2:0采样,每四个Y共用一组UV分量。

FFmpeg部分的函数和结构体介绍

  • av_register_all() 函数

源码:

void av_register_all(void);

在FFmpeg4.0之前,基于ffmpeg的应用程序中 几乎都是第一个被调用的。只有调用了该函数,才能使用复用器,编码器才能起作用,必须调用此函数。相当于用该函数来初始化各个组件

在FFmpeg4.0开始,这个api被标记为过时的api,不需要调用该函数

  • avformat_alloc_context() 函数

源码:

AVFormatContext *avformat_alloc_context(void);

用来创建AVFormatContext对象,AVFormatContext是包含码流参数较多的结构体。

使用:

AVFormatContext *pFormatContext = avformat_alloc_context();
  • avformat_open_input 函数

源码:

int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);

打开一个输入流并读取头(编码解码器没有打开),即用来打打开封装格式文件,例如:.mp4、.mov、.wmv文件等等...

使用:

int ret = avformat_open_input(&pFormatContext, src_path, NULL, NULL);
  • avformat_find_stream_info 函数

源码:

int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);

用来查找视频流,如果是视频解码,那么查找视频流,如果是音频解码,那么就查找音频流

  • 简单的描述下AVFormatContext 结构体
    typedef struct AVFormatContext {
        ......
        //AVFormatContext.streams的元素数量
        unsigned int nb_streams;
        //文件中所有流的列表
        AVStream **streams;
        ......
    } AVFormatContext;

    用来查找视频流,如果是视频解码,那么查找视频流,如果是音频解码,那么就查找音频流

  • 简单的描述下AVFormatContext 结构体
    typedef struct AVFormatContext {
        ......
        //AVFormatContext.streams的元素数量
        unsigned int nb_streams;
        //文件中所有流的列表
        AVStream **streams;
        ......
    } AVFormatContext;

    AVStream 结构体

    typedef struct AVStream {
        ......
        //@deprecated use the codecpar struct instead
        attribute_deprecated
        AVCodecContext *codec;
        //与此流关联的编解码器参数
        AVCodecParameters *codecpar;
        ......
    } AVStream;

    AVCodecParameters 结构体

    typedef struct AVCodecParameters {
    
         //编码数据的一般类型
        enum AVMediaType codec_type;
       //编码数据的特定类型(使用的编解码器)主要用来查找对类型使用的解码器
        enum AVCodecID   codec_id;
        ......
    
        //仅限视频。视频帧的尺寸(以像素为单位)
        int width;
        int height;
        ......
    
    } AVCodecParameters;

    AVMediaType 枚举

    enum AVMediaType {
    
        AVMEDIA_TYPE_UNKNOWN = -1,  // 通常被视为AVMEDIA_TYPE_DATA
        AVMEDIA_TYPE_VIDEO,        // 视频数据信息
        AVMEDIA_TYPE_AUDIO,        // 音频数据信息
        AVMEDIA_TYPE_DATA,          //通常是连续的不透明的数据信息
        AVMEDIA_TYPE_SUBTITLE,      // 子标题数据信息
        AVMEDIA_TYPE_ATTACHMENT,    //通常是稀疏的不透明的数据信息 (字幕等)
        AVMEDIA_TYPE_NB
    
    };

    用来存放与某个AVStream流关联的编解码器参数, 主要是用来确认解码器的类型

    该结构体是在FFmpeg4.0以后新增的, 在avFormatContext->streamsi->codecpar中,即在AVStream中,取代了4.0之前的avFormatContext->streamsi->codec

  • avcodec_find_decoder 函数
    //查找具有匹配编解码器ID的已注册解码器
    AVCodec *avcodec_find_decoder(enum AVCodecID id);

    该函数主要是用来根据AVCodecID 来获取相应的解码器AVCodec

  • avcodec_alloc_context3 函数
    AVCodecContext *avcodec_alloc_context3(const AVCodec *codec);

    该函数用来创建AVCodecContext结构体的对象分配内存并且将其字段设置为默认值,与之对应的avcodec_free_context() 函数释放生成的结构

  • avcodec_parameters_to_context 函数
    int avcodec_parameters_to_context(AVCodecContext *codec, const AVCodecParameters *par)

    该函数的作用:将AVCodecParameters中的值填充编解码器上下文

  • avcodec_open2 函数
    int avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options)

    该函数作用:初始化avcodecontext以使用给定的AVCodec,即打开解码器。

  • AVPacket 结构体
  • 该结构体是用来存放音视频流等压缩数据
  • AVFrame 结构体
  • typedef struct AVFrame {
    
         //指向图片/频道平面的指针
        uint8_t *data[AV_NUM_DATA_POINTERS];
    
        //对于视频,每行图片的大小以字节为单位。
        //对于音频,每个平面的大小(以字节为单位)。
        int linesize[AV_NUM_DATA_POINTERS];
        .....
    
        //此帧描述的音频采样数(每个通道)
        int nb_samples;
    
        // 音频数据的采样率
        int sample_rate;
        .....
    } AVFrame;

    Sws_getContext 函数

    struct SwsContext *sws_getContext(int srcW, int srcH, enum AVPixelFormat srcFormat,
                                      int dstW, int dstH, enum AVPixelFormat dstFormat,
                                      int flags, SwsFilter *srcFilter,
                                      SwsFilter *dstFilter, const double *param);

    该函数:创建SwsContext对象,用来初始化sws_scale库,以及相关的参数。

  • av_image_get_buffer_size 函数
    int av_image_get_buffer_size(enum AVPixelFormat pix_fmt, int width, int height, int align);

    返回使用给定参数存储图像所需的数据量的大小(以字节为单位)。

    在FFmpeg4.0之后 用这个函数取代int avpicture_get_size(enum AVPixelFormat pix_fmt, int width, int height);

  • av_image_fill_arrays 函数
    int av_image_fill_arrays(uint8_t *dst_data[4], int dst\_linesize[4],
                             const uint8_t *src,
                             enum AVPixelFormat pix_fmt, int width, int height, int align);

    在一次调用中分配缓冲区并填充dst_data和dst_linesize 这两个字段数据,向AVFrame->填充数据

    在FFmpeg4.0之后 用这个函数取代int avpicture_fill(AVPicture *picture, const uint8_t *ptr,enum AVPixelFormat pix_fmt, int width, int height);

  • av_read_frame 函数
    int av_read_frame(AVFormatContext *s, AVPacket *pkt);

    返回流的下一帧

  • avcodec_send_packet 函数
    int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);

    将AVPacket的数据传入到解码器中。

  • avcodec_receive_frame 函数
    int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);

    将解码好的数据返回放置到AVFrame中。

  • avcodec_decode_video2 函数
    attribute_deprecated
    int avcodec_decode_video2(AVCodecContext *avctx, AVFrame *picture,
                             int *got_picture_ptr,
                             const AVPacket *avpkt);

    这个函数也是解码函数,将AVPacket的压缩数据解码到AVFrame中,这个Api在FFmpeg4.0.2之后废弃了,改成avcodec_send_packet() 和 avcodec_receive_frame() 两个方法替代。

  • sws_scale 函数
    int sws_scale(struct SwsContext *c, const uint8_t *const srcSlice[],
                  const int srcStride[], int srcSliceY, int srcSliceH,
                  uint8\_t *const dst[], const int dstStride[]);

    该函数实现了三个功能:1.图像色彩空间转换;2.分辨率缩放;3.前后图像滤波处理。

    将srcSlice数据进行缩放图像切片,并将生成的缩放切片放入dst中的图像中。根据sws_getContext一开始设置要转换类型, 比如AV_PIX_FMT_YUV420P,AV_PIX_FMT_RGBA等。

  • swr_convert 函数
    int swr_convert(struct SwrContext *s, uint8_t **out, int out_count, const uint8_t **in , int in_count);

    该函数主要是实现音频文件的编码转换

    谈谈视频文件的解码过程

    这边绘制了一下FFmpeg的解码的大致过程,区分了4.0版本前后的区别

  • FFmpeg视频解码流程.jpg
  • 这边以FFmpeg4.0.2的版本为准,采用的是4.0后的新API, 将视频解码成YUV420的编码文件
  • Java_com_jason_ndk_ffmpeg_decode_MainActivity_parseVideo(JNIEnv *env, jobject thiz,
                                                             jstring src_video_path,
                                                             jstring dst_video_path) {
    
        const char *src_path = env->GetStringUTFChars(src_video_path, NULL);
        const char *dst_path = env->GetStringUTFChars(dst_video_path, NULL);
    
        //打开封装格式的文件
        AVFormatContext *pFormatContext = avformat_alloc_context();
        int ret = avformat_open_input(&pFormatContext, src_path, NULL, NULL);
        if (ret != 0) {
            LOGE("open input file failed ");
            return;
        }
        av_dump_format(pFormatContext, 0, src_path, 0); //输出视频信息
        // 查找视频流
        // 如果是视频解码,那么查找视频流,如果是音频解码,那么就查找音频流
        ret = avformat_find_stream_info(pFormatContext, NULL);
        if (ret < 0) {
            LOGE("find video stream failed ");
        }
    
        //1. 查找视频流索引位置, 一个视频文件有分为视频流,音频流,等等
        //2. 查找视频解码器
        int streamIndex = 0, i;
        for (i = 0; i < pFormatContext->nb_streams; i++) { 
            // 判断流的类型
            // 旧的接口 formatContext->streams[i]->codec->codec_type
            // 4.0.0以后新加入的类型用codecpar于替代codec
            enum AVMediaType mediaType = pFormatContext->streams[i]->codecpar->codec_type;
            if (mediaType == AVMEDIA_TYPE_VIDEO)  //视频流
            {
                streamIndex = i;
                break;
            }
        }
    
        // 根据视频流索引,获取解码器参数信息
        AVCodecParameters *avcodecParameters = pFormatContext->streams[streamIndex]->codecpar;
        //拿到解码器的ID
        enum AVCodecID codecId = avcodecParameters->codec_id;
    
        //根据解码器参数,获得解码器ID,然后查找解码器
        AVCodec *codec = avcodec_find_decoder(codecId);
        
        // 创建一个解码器的上下文
        AVCodecContext *avCodecContext = avcodec_alloc_context3(NULL);
        if (avCodecContext == NULL) {
            //创建解码器上下文失败
            LOGE("create condectext failed ");
            return;
        }
       
        // 将新的API中的 codecpar 转成 AVCodecContext,相当于绑定解码器的参数内容到创建的解码器上文对象中
        avcodec_parameters_to_context(avCodecContext, avcodecParameters);
        //打开解码器
        ret = avcodec_open2(avCodecContext, codec, NULL);
        if (ret < 0) {
            LOGE("open decoder failed ");
            return;
        }
        LOGE("decodec name: %s", codec->name);
        //创建压缩的数据包对象
        AVPacket *avPacket = (AVPacket *) av_mallocz(sizeof(AVPacket));
        //创建一个用于存放解码之后的像素数据
        AVFrame *avFrameIn = av_frame_alloc();
        //创建SwsContext对象,用来初始化sws_scale库,以及相关的参数
        struct SwsContext *swsContext = sws_getContext(avcodecParameters->width,
                                                       avcodecParameters->height,
                                                       avCodecContext->pix_fmt,
                                                       avcodecParameters->width,
                                                       avcodecParameters->height,
                                                       AV_PIX_FMT_YUV420P,
                                                       SWS_BITEXACT, NULL, NULL, NULL);
        //创建一个用于触发转换成yuv420编码的像素数据
        AVFrame *pAVFrameYUV420P = av_frame_alloc();
    
        //给缓冲区设置类型->yuv420类型
        //得到YUV420P缓冲区大小
        int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P,
                                                  avCodecContext->width,
                                                  avCodecContext->height,
                                                  1);
    
    
        LOGE("size: %d", bufferSize);
        //开辟一块内存空间
        uint8_t *outBuffer = (uint8_t *) av_malloc(bufferSize);
        
        //向pAVFrameYUV420P->填充数据, 初始化
        av_image_fill_arrays(pAVFrameYUV420P->data,
                             pAVFrameYUV420P->linesize,
                             outBuffer,
                             AV_PIX_FMT_YUV420P,
                             avCodecContext->width,
                             avCodecContext->height,
                             1);
        
        //创建一个生产的yuv420编码的文件,用来存放转换后的内容
        FILE *fileYUV420P = fopen(dst_path, "wb+");
        int currentIndex = 0, ySize, uSize, vSize;
    
    
        while (av_read_frame(pFormatContext, avPacket) >= 0) {
            //判断是不是视频
            if (avPacket->stream_index == streamIndex) {
                 //读取每一帧数据,立马解码一帧数据
                avcodec_send_packet(avCodecContext, avPacket);
    
                //解码
                ret = avcodec_receive_frame(avCodecContext, avFrameIn);
                if (ret == 0)  //解码成功
                {
                   
                    //将解码出来的这一帧数据,统一转类型为YUV
                    sws_scale(swsContext, (const uint8_t *const *) avFrameIn->data,
                              avFrameIn->linesize,
                              0,
                              avCodecContext->height,
                              pAVFrameYUV420P->data,
                              pAVFrameYUV420P->linesize);
                    //写入yuv文件格式
                    //YUV420P格式规范一:Y结构表示一个像素(一个像素对应一个Y)
                    //YUV420P格式规范二:4个像素点对应一个(U和V: 4Y = U = V)
                    ySize = avCodecContext->width * avCodecContext->height;
                    uSize = ySize / 4;
                    vSize = ySize / 4;
                    fwrite(pAVFrameYUV420P->data[0], 1, ySize, fileYUV420P);
                    fwrite(pAVFrameYUV420P->data[1], 1, uSize, fileYUV420P);
                    fwrite(pAVFrameYUV420P->data[2], 1, vSize, fileYUV420P);
                    currentIndex++;
                    LOGE("当前解码 %d 帧", currentIndex);
    
                }
            }
        }
        /*
         * 关闭解码器
         *
        */
        av_packet_free(&avPacket);
        fclose(fileYUV420P);
        av_frame_free(&avFrameIn);
        av_frame_free(&pAVFrameYUV420P);
        free(outBuffer);
        avcodec_close(avCodecContext);
        avformat_free_context(pFormatContext);
    
        env->ReleaseStringUTFChars(src_video_path, src_path);
        env->ReleaseStringUTFChars(dst_video_path, dst_path);
    }

    这样就完成了视频的解码功能。

    利用FFmpeg进行视频播放

    思路:前面的套路都是一样的,查找视频流,解码视频文件,然后通过ANativeWindow将视频一帧一帧的画面绘制到surface对象中

    Java_com_jason_ndk_ffmpeg_decode_widget_VideoView_render(JNIEnv *env, jobject thiz,
                                                             jstring video_path, jobject surface) {
        ..... //前面的代码都是一样的
    
       //这边要将视频播放出来,设置的编码是AV_PIX_FMT_RGBA类型,
       struct SwsContext *swsContext = sws_getContext(avCodecParameters->width,
                                                       avCodecParameters->height,
                                                       avCodecContext->pix_fmt,
                                                       avCodecParameters->width,
                                                       avCodecParameters->height,
                                                       AV_PIX_FMT_RGBA,
                                                       SWS_BICUBIC, NULL, NULL, NULL);
    
        //yuv的缓冲区改成rgb的缓冲区
        AVFrame *pAVFrameRGB = av_frame_alloc();
        int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA,
                                                  avCodecParameters->width,
                                                  avCodecParameters->height,
                                                  1);
        LOGE("size: %d", bufferSize);
        //开辟一块内存空间
        uint8_t *outBuffer = (uint8_t *) av_malloc(bufferSize);
    
        av_image_fill_arrays(pAVFrameRGB->data,
                             pAVFrameRGB->linesize,
                             outBuffer,
                             AV_PIX_FMT_RGBA,
                             avCodecContext->width,
                             avCodecContext->height,
                             1);
    
       //这边的surface对象,就是Java层的surface
        ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
        // 创建视频缓冲区
        ANativeWindow_Buffer windowBuffer;
    
        int currentIndex;
        while (av_read_frame(avFormatContext, avPacket) >= 0 ) {
    
            if (avPacket->stream_index == streamIndex) {
                avcodec_send_packet(avCodecContext, avPacket);
    
                //解码
                ret = avcodec_receive_frame(avCodecContext, avFrameIn);
                if (ret == 0)  //解码成功
                {
                    //绘制之前   配置一些信息  比如宽高   格式
                    ANativeWindow_setBuffersGeometry(nativeWindow, avCodecContext->width,
                                                     avCodecContext->height,
                                                     WINDOW_FORMAT_RGBA_8888);
                    //绘制
                    ANativeWindow_lock(nativeWindow, &windowBuffer, NULL);
    
                    //转为指定的RGB
                    sws_scale(swsContext, (const uint8_t *const *) avFrameIn->data, avFrameIn->linesize,
                              0, avCodecContext->height, pAVFrameRGB->data,
                              pAVFrameRGB->linesize);
                    //rgb_frame是有画面数据
                    uint8_t *dst = (uint8_t *) windowBuffer.bits;
                    //拿到一行有多少个字节 RGBA
                    int destStride = windowBuffer.stride * 4;
                    //像素数据的首地址
                    uint8_t *src = (uint8_t *) pAVFrameRGB->data[0];
                    //实际内存一行数量
                    int srcStride = pAVFrameRGB->linesize[0];
                    for (int i = 0; i < avCodecContext->height; ++i) {
                        memcpy(dst + i * destStride, src + i * srcStride, srcStride);
                    }
                    //将数据存储到缓冲区
                    ANativeWindow_unlockAndPost(nativeWindow);
                    //16毫秒休眠
                    usleep(1000 * 16);
                    LOGE("当前解码 %d 帧", currentIndex);
                }
            }
        }
        .....   //省略了释放的代码
    }

    这样视频就可以在SurfaceView上,渲染播放出来了。但是会发现没有声音,这是因为我们只做了视频流的解码播放,并没有处理音频流的内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值