• ijkplayer项目


    ijkplayer项目

    环境配置

    NDK全称:Native Development Kit。

    1、NDK是一系列工具的集合。NDK提供了一系列的工具,帮助开发者快速开发C(或C++)的动态库,并能自动将so和java应用一起打包成apk。这些工具对开发者的帮助是巨大的。NDK集成了交叉编译器,并提供了相应的mk文件隔离平台、CPU、API等差异,开发人员只需要简单修改mk文件(指出“哪些文件需要编译”、“编译特性要求”等),就可以创建出so。NDK可以自动地将so和Java应用一起打包,极大地减轻了开发人员的打包工作。

    2、NDK提供了一份稳定、功能有限的API头文件声明。Google明确声明该API是稳定的,在后续所有版本中都稳定支持当前发布的API。从该版本的NDK中看出,这些API支持的功能非常有限,包含有:C标准库(libc)、标准数学库(libm)、压缩库(libz)、Log库(liblog)。

    SDK:(software development kit)软件。

    Gradle是一个基于JVM的构建工具,是一款通用灵活的构建工具,支持maven, Ivy仓库,支持传递性依赖管理,而不需要远程仓库或者是pom.xml和ivy.xml配置文件,基于Groovy,build脚本使用Groovy编写。

    JNI是Java Native Interface的缩写,通过使用 Java本地接口书写程序,可以确保代码在不同的平台上方便移植。从Java1.1开始,JNI标准成为java平台的一部分,它允许Java代码和其他语言写的代码进行交互。JNI一开始是为了本地已编译语言,尤其是C和C++而设计的,但是它并不妨碍你使用其他编程语言,只要调用约定受支持就可以了。使用java与本地已编译的代码交互,通常会丧失平台可移植性。但是,有些情况下这样做是可以接受的,甚至是必须的。例如,使用一些旧的库,与硬件、操作系统进行交互,或者为了提高程序的性能。JNI标准至少要保证本地代码能工作在任何Java 虚拟机环境。

    文件结构

    ijkplayer在底层重写了ffplay.c文件,主要是去除ffplay中使用sdl音视频库播放音视频的部分;并且增加了对移动端硬件解码部分,视频渲染部分,以及音频播放部分的实现,这些部分在android和ios下有不同的实现,具体如下:

    Platform硬件解码视频渲染音频播放
    IOSVideoToolBoxOpenGL ESAudioQueue
    AndroidMediaCodecOpenGL ES、ANativeWindowOpenSL ES、AudioTrack

    从上面可以看出ijkplayer是暂时不支持音频硬件解码的,只支持软解。

    主要目录结构:

    目录解释
    androidandroid平台上的上层接口封装以及平台相关方法
    config存放编译ijkplayer所需的依赖源文件, 如ffmpeg、openssl等
    ijkmedia核心代码
    ijkj4aandroid平台下使用,用来实现c代码调用java层代码。这个文件夹是通过bilibili的另一个开源项目jni4android自动生成的。
    ijkplayer播放器数据下载及解码相关
    ijksdl音视频数据渲染相关
    iosiOS平台上的上层接口封装以及平台相关方法
    tool初始化项目工程脚本

    源码解析

    v4oWoq.png

    v4ooSU.png

    初始化

    结构体:

    • SDL_Vout表示一个显示上下文,或者理解为一块画布,ANativeWindow,控制如何显示overlay。
    • SDL_VoutOverlay表示显示层,或者理解为一块图像数据,表达如何显示。

    初始化:

    1. 创建IJKMediaPlayer对象, 通过ffp_create方法创建了FFPlayer对象,并设置消息处理函数;
    2. 创建图像渲染对象SDL_Vout;
    3. 创建平台相关的IJKFF_Pipeline对象,包括视频解码以及音频输出部分;

    简单来说,就是创建播放器对象,完成音视频解码、渲染的准备工作。

    当外部调用prepareToPlay启动播放后,ijkplayer内部最终会调用到ffplay.c中的方法int ffp_prepare_async_l(FFPlayer *ffp, const char *file_name),该方法是启动播放器的入口函数,在此会设置player选项,打开audio output,最重要的是调用stream_open方法。

    static VideoState *stream_open(FFPlayer *ffp, const char *filename, AVInputFormat *iformat)
    {  
        ......           
        /* start video display */
        if (frame_queue_init(&is->pictq, &is->videoq, ffp->pictq_size, 1) < 0)
            goto fail;
        if (frame_queue_init(&is->sampq, &is->audioq, SAMPLE_QUEUE_SIZE, 1) < 0)
            goto fail;
     
        if (packet_queue_init(&is->videoq) < 0 ||
            packet_queue_init(&is->audioq) < 0 )
            goto fail;
     
        ......
        
        is->video_refresh_tid = SDL_CreateThreadEx(&is->_video_refresh_tid, video_refresh_thread, ffp, "ff_vout");
        
        ......
        
        is->read_tid = SDL_CreateThreadEx(&is->_read_tid, read_thread, ffp, "ff_read");
        
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    从代码中可以看出,stream_open主要做了以下几件事情:

    1. 创建存放video/audio解码前数据的videoq/audioq;
    2. 创建存放video/audio解码后数据的pictq/sampq;
    3. 创建读数据线程read_thread;
    4. 创建视频渲染线程video_refresh_thread;

    说明:subtitle是与video、audio平行的一个stream,ffplay中也支持对它的处理,即创建存放解码前后数据的两个queue,并且当文件中存在subtitle时,还会启动subtitle的解码线程。

    数据读取

    数据读取的整个过程都是由ffmpeg内部完成的,接收到网络过来的数据后,ffmpeg根据其封装格式,完成了解复用的动作,得到音视频分离开的解码前的数据,步骤如下:

    1. 创建上下文结构体,这个结构体是最上层的结构体,表示输入上下文
    ic = avformat_alloc_context();
    
    • 1
    1. 设置中断函数,如果出错或者退出,就可以立刻退出
    ic->interrupt_callback.callback = decode_interrupt_cb;
    ic->interrupt_callback.opaque = is;
    
    • 1
    • 2
    1. 打开文件,主要是探测协议类型,如果是网络文件则创建网络链接等
    err = avformat_open_input(&ic, is->filename, is->iformat, &ffp->format_opts);
    
    • 1
    1. 探测媒体类型,可得到当前文件的封装格式,音视频编码参数等信息
    err = avformat_find_stream_info(ic, opts);
    
    • 1
    1. 打开视频、音频解码器。在此会打开相应解码器,并创建相应的解码线程。
    stream_component_open(ffp, st_index[AVMEDIA_TYPE_AUDIO]);
    
    • 1
    1. 读取媒体数据,得到的是音视频分离的解码前数据
    ret = av_read_frame(ic, pkt);
    
    • 1
    1. 将音视频数据分别送入相应的queue中
    if (pkt->stream_index == is->audio_stream && pkt_in_play_range) {
        packet_queue_put(&is->audioq, pkt);
    } else if (pkt->stream_index == is->video_stream && pkt_in_play_range && !(is->video_st && (is->video_st->disposition & AV_DISPOSITION_ATTACHED_PIC))) {
        packet_queue_put(&is->videoq, pkt);
        ......
    } else {
        av_packet_unref(pkt);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    重复6、7两步,即可不断获取待播放的数据。

    音视频解码

    ijkplayer在视频解码上支持软解和硬解两种方式,可在起播前配置优先使用的解码方式,播放过程中不可切换。iOS平台上硬解使用VideoToolbox,Android平台上使用MediaCodec。ijkplayer中的音频解码只支持软解,暂不支持硬解。

    • 硬解,用自带播放器播放,android中的VideoView;
    • 软解,使用音视频解码库,比如FFmpeg;

    视频解码方式选择

    在打开解码器的方法中:

    static int stream_component_open(FFPlayer *ffp, int stream_index)
    {
        ......
        codec = avcodec_find_decoder(avctx->codec_id);
        ......
        if ((ret = avcodec_open2(avctx, codec, &opts)) < 0) {
            goto fail;
        }
        ......  
        case AVMEDIA_TYPE_VIDEO:
            ......
            decoder_init(&is->viddec, avctx, &is->videoq, is->continue_read_thread);
            ffp->node_vdec = ffpipeline_open_video_decoder(ffp->pipeline, ffp);
            if (!ffp->node_vdec)
                goto fail;
            if ((ret = decoder_start(&is->viddec, video_thread, ffp, "ff_video_dec")) < 0)
                goto out;       
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    首先会打开ffmpeg的解码器,然后通过ffpipeline_open_video_decoder创建IJKFF_Pipenode。在创建IJKMediaPlayer对象时,通过ffpipeline_create_from_android创建了pipeline。该函数实现如下:

    IJKFF_Pipenode* ffpipeline_open_video_decoder(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        return pipeline->func_open_video_decoder(pipeline, ffp);
    }
    
    • 1
    • 2
    • 3
    • 4

    func_open_video_decoder函数指针最后指向的是ffpipeline_android.c中的func_open_video_decoder,其定义如下:

    static IJKFF_Pipenode *func_open_video_decoder(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        IJKFF_Pipeline_Opaque *opaque = pipeline->opaque;
        IJKFF_Pipenode        *node = NULL;
        if (ffp->mediacodec_all_videos || ffp->mediacodec_avc || ffp->mediacodec_hevc || ffp->mediacodec_mpeg2)
            node = ffpipenode_create_video_decoder_from_android_mediacodec(ffp, pipeline, opaque->weak_vout);
        if (!node) {
            node = ffpipenode_create_video_decoder_from_ffplay(ffp);
        }
        return node;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    首先通过ffp->mediacodec_all_videos || ffp->mediacodec_avc || ffp->mediacodec_hevc || ffp->mediacodec_mpeg2判断是否支持硬件解码,如果支持会优先去尝试打开硬件解码器,如果打开失败会自动切换使用ffmpeg软解码。

    关于ffp->mediacodec_all_videos 、ffp->mediacodec_avc 、ffp->mediacodec_hevc 、ffp->mediacodec_mpeg2它们的值需要在起播前通过如下方法配置:

    ijkmp_set_option_int(_mediaPlayer, IJKMP_OPT_CATEGORY_PLAYER,   "xxxxx", 1);
    
    • 1

    视频解码

    video的解码线程为video_thread,audio的解码线程为audio_thread。

    • 视频解码线程
    static int video_thread(void *arg)
    {
        FFPlayer *ffp = (FFPlayer *)arg;
        int       ret = 0;
     
        if (ffp->node_vdec) {
            ret = ffpipenode_run_sync(ffp->node_vdec);
        }
        return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    ffpipenode_run_sync 中调用的是IJKFF_Pipenode对象中的 func_run_sync

    int ffpipenode_run_sync(IJKFF_Pipenode *node)
    {
        return node->func_run_sync(node);
    }
    
    • 1
    • 2
    • 3
    • 4

    func_run_sync 取决于播放前配置的软硬解,假设为硬解func_run_sync函数指针最后指向的是ffpipenode_android_mediacodec_vdec.c中的func_run_sync,其定义如下:

    static int func_run_sync(IJKFF_Pipenode *node)
    {
        .......
        opaque->enqueue_thread = SDL_CreateThreadEx(&opaque->_enqueue_thread, enqueue_thread_func, node, "amediacodec_input_thread");
        if (!opaque->enqueue_thread) {
            ALOGE("%s: SDL_CreateThreadEx failed\n", __func__);
            ret = -1;
            goto fail;
        }
        while (!q->abort_request) {
            int64_t timeUs = opaque->acodec_first_dequeue_output_request ? 0 : AMC_OUTPUT_TIMEOUT_US;
            got_frame = 0;
            ret = drain_output_buffer(env, node, timeUs, &dequeue_count, frame, &got_frame);
            .......
            if (got_frame) {
                duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
                pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb);
                ret = ffp_queue_picture(ffp, frame, pts, duration, av_frame_get_pkt_pos(frame), is->viddec.pkt_serial);
                ......
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    1. 首先该函数启动一个输入线程,线程的执行函数为enqueue_thread_func,函定义如下:
    static int enqueue_thread_func(void *arg)
    {
        ......
        while (!q->abort_request) {
            ret = feed_input_buffer(env, node, AMC_INPUT_TIMEOUT_US, &dequeue_count);
            if (ret != 0) {
                goto fail;
            }
        }
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    该函数在循环中通过feed_iput_buffer调用ffp_packet_queue_get_or_buffering一直不停的取数据,并将取得的数据交给硬件解码器。

    1. 创建完输入线程后,直接进入while循环,循环中调用drain_output_buffer去获取硬件解码后的数据,该函数最后一个参数用来标记是否接收到完整的一帧数据。

    2. got_frame为true时,将接收的帧通过ffp_queue_picture送入pictq队列里。

    若为软解func_run_sync函数指针最后指向的是ffpipenode_ffplay_vdec.c中的func_run_sync,其定义如下:

    static int func_run_sync(IJKFF_Pipenode *node)
    {
        IJKFF_Pipenode_Opaque *opaque = node->opaque;
        return ffp_video_thread(opaque->ffp);
    }
    
    static int ffplay_video_thread(void *arg) {
        AVFrame *frame = av_frame_alloc();
        for(;;){
            ret = get_video_frame(ffp, frame); // avcodec_receive_frame软解码获取一帧
            queue_picture(ffp, frame, pts, duration, frame->pkt_pos, is->viddec.pkt_serial);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    音频解码

    ijkplayer的音频解码线程的入口函数是ff_ffplayer.c中的audio_thread()

    static int audio_thread(void *arg)
    {
    .....
        do {
            ffp_audio_statistic_l(ffp);
            if ((got_frame = decoder_decode_frame(ffp, &is->auddec, frame, NULL)) < 0)
                goto the_end;
                ......
                while ((ret = av_buffersink_get_frame_flags(is->out_audio_filter, frame, 0)) >= 0) {
                  	.....
                    if (!(af = frame_queue_peek_writable(&is->sampq)))
                        goto the_end;
    								.....
                    av_frame_move_ref(af->frame, frame);
                    frame_queue_push(&is->sampq);
                    .....
            		}
        		} while (ret >= 0 || ret == AVERROR(EAGAIN) || ret == AVERROR_EOF);
     the_end:
    		......
        av_frame_free(&frame);
        return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    1. 一开始就进入循环,然后调用decoder_decode_frame()进行解码,调用传进去的codec的codec->decode()方法解码,解码后的帧存放到frame中;

    2. 然后调用frame_queue_peek_writable()判断是否能把刚刚解码的frame写入is->sampq中。会判断sampq队列是否满了,如果已满,会调用pthread_cond_wait()方法阻塞队列;如果未满,就会返回frame应该放置的位置的地址。is->sampq是音频解码帧列表,播放线程从这里面读取数据,然后播放出来;

    3. 最后 av_frame_move_ref(af->frame, frame);把frame放入到sampq相应位置。由于前面af = frame_queue_peek_writable(&is->sampq),af为指向这一帧frame存放位置的指针,所以直接把值赋值给它的结构体里面的frame就行了。

    4. frame_queue_push(&is->sampq);里面是一个唤醒线程的操作,如果音频播放线程因为sampq队列为空而阻塞,这里可以唤醒它。

    音视频渲染和同步

    音频输出

    ijkplayer中Android平台使用OpenSL ES或AudioTrack输出音频,iOS平台使用AudioQueue输出音频。

    audio output节点,在ffp_prepare_async_l方法中被创建:

    ffp->aout = ffpipeline_open_audio_output(ffp->pipeline, ffp);
    
    • 1

    ffpipeline_open_audio_output方法实际上调用的是IJKFF_Pipeline对象的函数指针func_open_audio_utput,该函数指针在初始化中的ijkmp_android_create方法中被赋值,最后指向的是ffpipeline_android.c中的函数func_open_audio_output

    static SDL_Aout *func_open_audio_output(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        SDL_Aout *aout = NULL;
        if (ffp->opensles) {
            aout = SDL_AoutAndroid_CreateForOpenSLES();
        } else {
            aout = SDL_AoutAndroid_CreateForAudioTrack();
        }
        if (aout)
            SDL_AoutSetStereoVolume(aout, pipeline->opaque->left_volume, pipeline->opaque->right_volume);
        return aout;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    该函数会根据ffp->opensles来决定是否使用openSLES来进行音频播放,后面的分析是基于openSLES方式处理音频的。

    SDL_AoutAndroid_CreateForOpenSLES定义如下,主要完成的是创建SDL_Aout对象:

    SDL_Aout *SDL_AoutAndroid_CreateForOpenSLES()
    {
        SDLTRACE("%s\n", __func__);
        SDL_Aout *aout = SDL_Aout_CreateInternal(sizeof(SDL_Aout_Opaque));
        if (!aout)
            return NULL;
        SDL_Aout_Opaque *opaque = aout->opaque;
        opaque->wakeup_cond = SDL_CreateCond();
        opaque->wakeup_mutex = SDL_CreateMutex();
    
        int ret = 0;
     
        SLObjectItf slObject = NULL;
        ret = slCreateEngine(&slObject, 0, NULL, 0, NULL, NULL);
        CHECK_OPENSL_ERROR(ret, "%s: slCreateEngine() failed", __func__);
        opaque->slObject = slObject;
     
        ret = (*slObject)->Realize(slObject, SL_BOOLEAN_FALSE);
        CHECK_OPENSL_ERROR(ret, "%s: slObject->Realize() failed", __func__);
     
        SLEngineItf slEngine = NULL;
        ret = (*slObject)->GetInterface(slObject, SL_IID_ENGINE, &slEngine);
        CHECK_OPENSL_ERROR(ret, "%s: slObject->GetInterface() failed", __func__);
        opaque->slEngine = slEngine;
     
        SLObjectItf slOutputMixObject = NULL;
        const SLInterfaceID ids1[] = {SL_IID_VOLUME};
        const SLboolean req1[] = {SL_BOOLEAN_FALSE};
        ret = (*slEngine)->CreateOutputMix(slEngine, &slOutputMixObject, 1, ids1, req1);
        CHECK_OPENSL_ERROR(ret, "%s: slEngine->CreateOutputMix() failed", __func__);
        opaque->slOutputMixObject = slOutputMixObject;
     
        ret = (*slOutputMixObject)->Realize(slOutputMixObject, SL_BOOLEAN_FALSE);
        CHECK_OPENSL_ERROR(ret, "%s: slOutputMixObject->Realize() failed", __func__);
     
        aout->free_l       = aout_free_l;
        aout->opaque_class = &g_opensles_class;
        aout->open_audio   = aout_open_audio;
        aout->pause_audio  = aout_pause_audio;
        aout->flush_audio  = aout_flush_audio;
        aout->close_audio  = aout_close_audio;
        aout->set_volume   = aout_set_volume;
        aout->func_get_latency_seconds = aout_get_latency_seconds;
     
        return aout;
    fail:
        aout_free_l(aout);
        return NULL;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    回到ffplay.c中,如果发现待播放的文件中含有音频,那么在调用 stream_component_open 打开解码器时,该方法里面也调用 audio_open 打开了audio output设备。

    static int audio_open(FFPlayer *opaque, int64_t wanted_channel_layout, int wanted_nb_channels, int wanted_sample_rate, struct AudioParams *audio_hw_params)
    {
        FFPlayer *ffp = opaque;
        VideoState *is = ffp->is;
        SDL_AudioSpec wanted_spec, spec;
        ......
        wanted_nb_channels = av_get_channel_layout_nb_channels(wanted_channel_layout);
        wanted_spec.channels = wanted_nb_channels;
        wanted_spec.freq = wanted_sample_rate;
        wanted_spec.format = AUDIO_S16SYS;
        wanted_spec.silence = 0;
        wanted_spec.samples = FFMAX(SDL_AUDIO_MIN_BUFFER_SIZE, 2 << av_log2(wanted_spec.freq / SDL_AoutGetAudioPerSecondCallBacks(ffp->aout)));
        wanted_spec.callback = sdl_audio_callback;
        wanted_spec.userdata = opaque;
        while (SDL_AoutOpenAudio(ffp->aout, &wanted_spec, &spec) < 0) {
            .....
        }
        ......
        return spec.size;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    在 audio_open中配置了音频输出的相关参数 SDL_AudioSpec ,并通过

    int SDL_AoutOpenAudio(SDL_Aout *aout, const SDL_AudioSpec *desired, SDL_AudioSpec *obtained)
    {
        if (aout && desired && aout->open_audio)
            return aout->open_audio(aout, desired, obtained);
        return -1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    设置给了Audio Output,android平台上即为OpenSLES。OpenSLES模块在工作过程中,通过不断的callback来获取pcm数据进行播放。

    视频渲染

    若Android平台上采用OpenGL渲染解码后的YUV图像,渲染线程为video_refresh_thread,最后渲染图像的方法为video_image_display2,定义如下:

    static void video_image_display2(FFPlayer *ffp)
    {
        VideoState *is = ffp->is;
        Frame *vp;
        Frame *sp = NULL;
     
        vp = frame_queue_peek_last(&is->pictq);
        ......
        
        SDL_VoutDisplayYUVOverlay(ffp->vout, vp->bmp);
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    从代码实现上可以看出,该线程的主要工作为:

    1. 调用frame_queue_peek_last从pictq中读取当前需要显示视频帧;
    2. 调用SDL_VoutDisplayYUVOverlay进行绘制;

    display_overlay函数指针在函数SDL_VoutAndroid_CreateForANativeWindow()中被赋值为vout_display_overlay,该方法就是调用OpengGL绘制图像。

    音视频同步

    ijkplayer在默认情况下也是使用音频作为参考时钟源,处理同步的过程主要在视频渲染video_refresh_thread的线程中:

    static int video_refresh_thread(void *arg)
    {
        FFPlayer *ffp = arg;
        VideoState *is = ffp->is;
        double remaining_time = 0.0;
        while (!is->abort_request) {
            if (remaining_time > 0.0)
                av_usleep((int)(int64_t)(remaining_time * 1000000.0));
            remaining_time = REFRESH_RATE;
            if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
                video_refresh(ffp, &remaining_time);
        }
     
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    从上述实现可以看出,该方法中主要循环做两件事情:

    1. 休眠等待,remaining_time的计算在video_refresh中;
    2. 调用video_refresh方法,刷新视频帧;

    可见同步的重点是在video_refresh中,下面着重分析该方法:

    lastvp = frame_queue_peek_last(&is->pictq);
    vp = frame_queue_peek(&is->pictq);
    ......
      /* compute nominal last_duration */
    last_duration = vp_duration(is, lastvp, vp);
    delay = compute_target_delay(ffp, last_duration, is);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    lastvp是上一帧,vp是当前帧,last_duration则是根据当前帧和上一帧的pts,计算出来上一帧的显示时间,经过 compute_target_delay 方法,计算出显示当前帧需要等待的时间。

    static double compute_target_delay(FFPlayer *ffp, double delay, VideoState *is)
    {
        double sync_threshold, diff = 0;
     
        /* update delay to follow master synchronisation source */
        if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {
            /* if video is slave, we try to correct big delays by
               duplicating or deleting a frame */
            diff = get_clock(&is->vidclk) - get_master_clock(is);
     
            /* skip or repeat frame. We take into account the
               delay to compute the threshold. I still don't know
               if it is the best guess */
            sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
            /* -- by bbcallen: replace is->max_frame_duration with AV_NOSYNC_THRESHOLD */
            if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD) {
                if (diff <= -sync_threshold)
                    delay = FFMAX(0, delay + diff);
                else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
                    delay = delay + diff;
                else if (diff >= sync_threshold)
                    delay = 2 * delay;
            }
        }
     
        .....
     
        return delay;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 如果当前视频帧落后于主时钟源,则需要减小下一帧画面的等待时间;
    • 如果视频帧超前,并且该帧的显示时间大于显示更新门槛,则显示下一帧的时间为超前的时间差加上上一帧的显示时间;
    • 如果视频帧超前,并且上一帧的显示时间小于显示更新门槛,则采取加倍延时的策略。

    回到video_refresh中:

    time= av_gettime_relative()/1000000.0;
    if (isnan(is->frame_timer) || time < is->frame_timer)
      is->frame_timer = time;
    if (time < is->frame_timer + delay) {
      *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
      goto display;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    frame_timer实际上就是上一帧的播放时间,而frame_timer + delay实际上就是当前这一帧的播放时间,如果系统时间还没有到当前这一帧的播放时间,直接跳转至display,而此时is->force_refresh变量为0,不显示当前帧,进入video_refresh_thread中下一次循环,并睡眠等待。

    is->frame_timer += delay;
      if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
          is->frame_timer = time;
     
      SDL_LockMutex(is->pictq.mutex);
      if (!isnan(vp->pts))
             update_video_pts(is, vp->pts, vp->pos, vp->serial);
      SDL_UnlockMutex(is->pictq.mutex);
     
      if (frame_queue_nb_remaining(&is->pictq) > 1) {
           Frame *nextvp = frame_queue_peek_next(&is->pictq);
           duration = vp_duration(is, vp, nextvp);
           if(!is->step && (ffp->framedrop > 0 || (ffp->framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration) {
               frame_queue_next(&is->pictq);
               goto retry;
           }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    如果当前这一帧的播放时间已经过了,并且其和当前系统时间的差值超过了AV_SYNC_THRESHOLD_MAX,则将当前这一帧的播放时间改为系统时间,并在后续判断是否需要丢帧,其目的是为后面帧的播放时间重新调整frame_timer,如果缓冲区中有更多的数据,并且当前的时间已经大于当前帧的持续显示时间,则丢弃当前帧,尝试显示下一帧。

    {
       frame_queue_next(&is->pictq);
       is->force_refresh = 1;
     
       SDL_LockMutex(ffp->is->play_mutex);
       
        ......
        
    display:
        /* display picture */
        if (!ffp->display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
            video_display2(ffp);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    否则进入正常显示当前帧的流程,调用 video_display2 开始渲染。

    事件处理

    在播放过程中,某些行为的完成或者变化,如prepare完成,开始渲染等,需要以事件形式通知到外部,以便上层作出具体的业务处理。ijkplayer支持的事件比较多,具体定义在ijkplayer/ijkmedia/ijkplayer/ff_ffmsg.h中

    #define FFP_MSG_FLUSH                       0
    #define FFP_MSG_ERROR                       100     /* arg1 = error */
    #define FFP_MSG_PREPARED                    200
    #define FFP_MSG_COMPLETED                   300
    #define FFP_MSG_VIDEO_SIZE_CHANGED          400     /* arg1 = width, arg2 = height */
    #define FFP_MSG_SAR_CHANGED                 401     /* arg1 = sar.num, arg2 = sar.den */
    #define FFP_MSG_VIDEO_RENDERING_START       402
    #define FFP_MSG_AUDIO_RENDERING_START       403
    #define FFP_MSG_VIDEO_ROTATION_CHANGED      404     /* arg1 = degree */
    #define FFP_MSG_BUFFERING_START             500
    #define FFP_MSG_BUFFERING_END               501
    #define FFP_MSG_BUFFERING_UPDATE            502     /* arg1 = buffering head position in time, arg2 = minimum percent in time or bytes */
    #define FFP_MSG_BUFFERING_BYTES_UPDATE      503     /* arg1 = cached data in bytes,            arg2 = high water mark */
    #define FFP_MSG_BUFFERING_TIME_UPDATE       504     /* arg1 = cached duration in milliseconds, arg2 = high water mark */
    #define FFP_MSG_SEEK_COMPLETE               600     /* arg1 = seek position,                   arg2 = error */
    #define FFP_MSG_PLAYBACK_STATE_CHANGED      700
    #define FFP_MSG_TIMED_TEXT                  800
    #define FFP_MSG_VIDEO_DECODER_OPEN          10001
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    消息上报初始化

    在IJKMediaPlayer的初始化方法中:

    static void
    IjkMediaPlayer_native_setup(JNIEnv *env, jobject thiz, jobject weak_this)
    {
        MPTRACE("%s\n", __func__);
        IjkMediaPlayer *mp = ijkmp_android_create(message_loop);
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    可以看到在创建播放器时, message_loop 函数地址作为参数传入了 ijkmp_android_create ,继续跟踪代码,可以发现,该函数地址最终被赋值给了IjkMediaPlayer中的 msg_loop 函数指针:

    IjkMediaPlayer *ijkmp_create(int (*msg_loop)(void*))
    {
        ......
        mp->msg_loop = msg_loop;
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    开始播放时,会启动一个消息线程:

    static int ijkmp_prepare_async_l(IjkMediaPlayer *mp)
    {
        ......
        mp->msg_thread = SDL_CreateThreadEx(&mp->_msg_thread, ijkmp_msg_loop, mp, "ff_msg_loop");
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    ijkmp_msg_loop 方法中调用的即是 mp->msg_loop

    消息上报处理

    播放器底层上报事件时,实际上就是将待发送的消息放入消息队列,另外有一个线程会不断从队列中取出消息,上报给外部,其代码流程大致如下图所示:

    vIMfoD.png

    框架分析

    ijkplayer(android)融合了mediacodec,实现了硬解支持。由于MediaCodec的使用与一般的解码API有所不同,其在应用中的使用层级更偏向于播放器层面。所以ijkplayer并没有将MediaCodec作为“解码器”扩展到ffmpeg中,而是另外定义了一个封装层,同时也统一了软解的接口,这个封装层即ffpipeline

    ijkplayer中的音频是走的软解,后续提到的解码无特别说明都是指视频解码。

    基础概念

    解码封装层中有两个重要的结构体,ffpipelineffpipenode。ffpipeline表示解码器和音频输出的提供者,ffpipenode表示解码器。

    ffpipeline定义如下:

    struct IJKFF_Pipeline {
        SDL_Class             *opaque_class;
        IJKFF_Pipeline_Opaque *opaque;
        void            (*func_destroy)             (IJKFF_Pipeline *pipeline);
        IJKFF_Pipenode *(*func_open_video_decoder)  (IJKFF_Pipeline *pipeline, FFPlayer *ffp);
        SDL_Aout       *(*func_open_audio_output)   (IJKFF_Pipeline *pipeline, FFPlayer *ffp);
        IJKFF_Pipenode *(*func_init_video_decoder)  (IJKFF_Pipeline *pipeline, FFPlayer *ffp);
        int           (*func_config_video_decoder)  (IJKFF_Pipeline *pipeline, FFPlayer *ffp);
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    IJKFF_Pipeline主要定义了3组函数:

    • 获取音频输出:func_open_audio_output;
    • 同步方式获取视频解码器:func_open_video_decoder;
    • 异步方式获取视频解码器:func_init_video_decoder、func_config_video_decoder;

    这里以IJKFF_Pipenode表示一个视频解码器,以SDL_Aout表示音频输出。

    ijkplayer中的音频是软解码的,所以不需要像视频一样去作封装,而是直接基于ffplay修改的。

    异步创建视频解码器的用意不是很明白,实际使用中也没需求需要用到,后面分析,先只关注同步创建解码器的部分,即func_open_video_decoder的使用。

    ffpipenode定义如下:

    struct IJKFF_Pipenode {
        SDL_mutex *mutex;
        void *opaque;
        void (*func_destroy) (IJKFF_Pipenode *node);
        int  (*func_run_sync)(IJKFF_Pipenode *node);
        int  (*func_flush)   (IJKFF_Pipenode *node); // optional
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    IJKFF_Pipenode中的主要函数是:

    • func_run_sync:表示解码主循环,从这个函数返回代表解码结束了;
    • func_flush:清空解码器,一般在seek时用到;

    目录结构

    解码封装层的源码文件及目录结构如下:

    // +表示目录,目录下文件递进4个空格,-表示文件
    +ijkmedia/ijkplayer
        -ff_ffpipenode.c/h                          //pipeline定义与封装
        -ff_ffpipeline.c/h                          //pipenode定义与封装
        +pipeline
            -ffpipeline_ffplay.c/h                  //ffplay的pipeline定义(未使用)
            -ffpipenode_ffplay_vdec.c/h             //ffplay的pipenode定义
        +android/pipeline
            -ffpipeline_android.c/h                 //android的ffpipeline定义
            -ffpipenode_android_mediacodec_vdec.c/h //android的(mediacodec)ffpipenode定义
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    上面提到的pipeline和pipenode的实现由两种(android上):

    • ffplay软解封装:在ijkmedia/ijkplayer/pipeline目录下,其中ffpipeline_ffplay并未用到;
    • mediacodec硬解封装:在ijkmedia/ijkplayer/android/pipeline目录下;

    流程分析

    解码器在播放器使用过程中的主要功能可以分为:创建、解码、帧入队、seek处理、销毁。解码器的调用主要在ff_ffplay。

    创建

    首先需要创建pipeline,pipeline的创建流程和SDL_Vout一样:

    new IjkMediaPlayer() 
        -> initPlayer() 
            -> native_setup() 
                -> IjkMediaPlayer_native_setup() 
                    -> ijkmp_android_create() 
                        -> SDL_VoutAndroid_CreateForAndroidSurface() //SDL_Vout在这里创建
                        -> ffpipeline_create_from_android() //android pipeline在这里创建
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    接着在ff_ffplay中创建解码器(pipenode):

    static int stream_component_open(FFPlayer *ffp, int stream_index)
    {
        //……
        switch (avctx->codec_type) {
            case AVMEDIA_TYPE_VIDEO:
                if (ffp->async_init_decoder) {
                    //这里是异步创建解码器的代码
                }
                else{
                    decoder_init(&is->viddec, avctx, &is->videoq, is->continue_read_thread);
                  	// 默认情况,调用android pipeline的func_open_video_decoder,
                  	// 其内部会根据所配置的选项和实际支持的编码类型,自动回退到软解,即创建ffplay pipenode
                    ffp->node_vdec = ffpipeline_open_video_decoder(ffp->pipeline, ffp);
                    if (!ffp->node_vdec)
                        goto fail;
                }
                if ((ret = decoder_start(&is->viddec, video_thread, ffp, "ff_video_dec")) < 0)
                goto out;
                break;
                //……
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    解码器的创建时在stream_component_open里调用ffpipeline_open_video_decoder完成的,创建好后的decoder保存在ffp->node_vdec中。接着调用decoder_start启动了video_thread

    video_thread的实现很简单:

    static int video_thread(void *arg)
    {
        FFPlayer *ffp = (FFPlayer *)arg;
        int       ret = 0;
    
        if (ffp->node_vdec) {
            ret = ffpipenode_run_sync(ffp->node_vdec);
        }
        return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    直接调用循环的主体ffpipenode_run_sync,即pipenode的func_run_sync函数。

    解码

    解码工作是在video_thread中完成的,也就是由具体pipenode的实现决定。在ijkplayer中,这分为两种情况,一种是硬解,一种是软解。软解的实现基本是ffplay的改造,硬解的实现是调用的MediaCodec。

    帧入队

    在解码的过程中,需要将已经解码好的帧放入帧队列FrameQueue中。该工作由ff_ffplay中的ffp_queue_picture/queue_picture完成。

    int ffp_queue_picture(FFPlayer *ffp, AVFrame *src_frame, double pts, double duration, int64_t pos, int serial)
    {
        return queue_picture(ffp, src_frame, pts, duration, pos, serial);
    }
    
    static int queue_picture(FFPlayer *ffp, AVFrame *src_frame, double pts, double duration, int64_t pos, int serial)
    {
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    ffp_queue_picture只是简单调用的queue_picture,这是为了改变它的可见性(去掉static),方便在其他文件调用该函数。

    queue_picture的主要功能和结构与ffplay的类似,可以参考ffplay video显示线程分析。所不同的是ijk的显示流程和解码流程与ffplay不同,所以在queue_picture的时候调用SDL_VoutOverlayfunc_fill_frame把帧画面“绘制”到最终的显示图层上。

    seek处理

    seek时需要调用解码器的flush:

    static int read_thread(void *arg)
    {
        //……
        ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);
        if (ret < 0) {
            //……
        }
        else {
            //……
            if (is->video_stream >= 0) {
                if (ffp->node_vdec) {
                  	// 这里调用解码器的flush,即pipenode的func_flush函数
                    ffpipenode_flush(ffp->node_vdec);
                }
                packet_queue_flush(&is->videoq);
                packet_queue_put(&is->videoq, &flush_pkt);
            }
            //……
        }
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    销毁

    按预期pipenode的销毁应该出现在其创建函数stream_component_open对应的stream_component_close中,然而并没有。根据IJKFF_Pipenode的定义,其销毁应调用func_destroy,或者其封装函数ffpipenode_free/ffpipenode_free_p

    正常流程只在整个播放器销毁时有调用到:

    void ffp_destroy(FFPlayer *ffp)
    {
        //……
        ffpipenode_free_p(&ffp->node_vdec);
        ffpipeline_free_p(&ffp->pipeline);
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这就意味着,按代码分析的情况看,除第一次外,每次选择视频轨道都会造成一次内存泄漏!不过毕竟还未验证,待验证后再给结论。

    音视频解码

    硬解

    在android中的ijkplayer是通过封装MediaCodec实现的硬解,以下将从解码器的创建、解码、帧入队三个方面介绍。

    创建

    硬解pipenode的创建是在stream_component_open中调用ffpipeline_open_video_decoder创建的。ffpipeline_open_video_decoder是pipeline的封装,在Android上调用的是ffpipeline_andriod.c中的func_open_video_decoder

    static IJKFF_Pipenode *func_open_video_decoder(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        IJKFF_Pipeline_Opaque *opaque = pipeline->opaque;
        IJKFF_Pipenode        *node = NULL;
        if (ffp->mediacodec_all_videos || ffp->mediacodec_avc || ffp->mediacodec_hevc || ffp->mediacodec_mpeg2)
            node = ffpipenode_create_video_decoder_from_android_mediacodec(ffp, pipeline, opaque->weak_vout);
        if (!node) {
            node = ffpipenode_create_video_decoder_from_ffplay(ffp);
        }
        return node;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    这里启用了硬解会调用ffpipenode_create_video_decoder_from_android_mediacodec

    IJKFF_Pipenode *ffpipenode_create_video_decoder_from_android_mediacodec(FFPlayer *ffp, IJKFF_Pipeline *pipeline, SDL_Vout *vout)
    {
        //……
        //1. 初始化node
        IJKFF_Pipenode *node = ffpipenode_alloc(sizeof(IJKFF_Pipenode_Opaque));
        node->func_destroy  = func_destroy;
        if (ffp->mediacodec_sync) {
            node->func_run_sync = func_run_sync_loop;
        } else {
            node->func_run_sync = func_run_sync;
        }
        node->func_flush    = func_flush;
    
        //2. 硬解选项检查
        switch (opaque->codecpar->codec_id) {
        case AV_CODEC_ID_H264:
            if (!ffp->mediacodec_avc && !ffp->mediacodec_all_videos) {
                ALOGE("%s: MediaCodec: AVC/H264 is disabled. codec_id:%d \n", __func__, opaque->codecpar->codec_id);
                goto fail;
            }
            opaque->mcc.profile = opaque->codecpar->profile;
            opaque->mcc.level   = opaque->codecpar->level;
        //……
        }
    
        //3. 创建MediaFormat
        ret = recreate_format_l(env, node);
    
        //4. 选择codec(选择最佳codec name)
        if (!ffpipeline_select_mediacodec_l(pipeline, &opaque->mcc) || !opaque->mcc.codec_name[0]) {
            ALOGE("amc: no suitable codec\n");
            goto fail;
        }
    
        //5. 配置codec(创建MediaCodec)
        ret = reconfigure_codec_l(env, node, jsurface);
    
        //一些特殊的解码器需要在MediaCodec解码后,增加一级帧队列,队列按pts排序,然后再送出到FrameQueue,源码分析中不考虑该特殊情况。
        if (opaque->n_buf_out) {
            int i;
    
            opaque->amc_buf_out = calloc(opaque->n_buf_out, sizeof(*opaque->amc_buf_out));
            assert(opaque->amc_buf_out != NULL);
            for (i = 0; i < opaque->n_buf_out; i++)
                opaque->amc_buf_out[i].pts = AV_NOPTS_VALUE;
        }
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    在pipenode的创建中,经历以下步骤:

    1. 初始化node;
    2. 硬解选项检查;
    3. 创建MediaFormat。在函数recreate_format_l中实现,主要设置mime type、width、height和csd-0;
    4. 选择codec(选择最佳codec name)。主要是调用ffpipeline_select_mediacodec_l函数进行选择,ffpipeline_select_mediacodec_l会回调到IjkMediaPlayeronSelectCodec。在onSelectCodec中会根据自己的一套规则取选择合适的codec name(与MediaCodecList.findDecoderForFormat的工作类似,不过更灵活);
    5. 配置codec(创建MediaCodec)。在函数reconfigure_codec_l中实现。

    接下来看下reconfigure_codec_l

    static int reconfigure_codec_l(JNIEnv *env, IJKFF_Pipenode *node, jobject new_surface)
    {
        //……
        //acodec = new MediaCodec
        if (!opaque->acodec) {
            opaque->acodec = create_codec_l(env, node);
        }
    
        //MediaCodec.setSurface
        amc_ret = SDL_AMediaCodec_configure_surface(env, opaque->acodec, opaque->input_aformat, opaque->jsurface, NULL, 0);
    
        //MediaCodec.start
        amc_ret = SDL_AMediaCodec_start(opaque->acodec);
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    reconfigure的主要流程与java api的使用差不多。典型的new -> setSurface -> start。到这里,MediaCodec就创建好,准备接收数据了。

    解码

    解码调用过程:stream_component_open -> decoder_start -> video_thread -> fun_run_sync

    在ffpipenode_android_mediacodec_vdec中有两个fun_run_sync的实现,可以通过mediacodec_sync选项进行切换:

    //ffpipenode_create_video_decoder_from_android_mediacodec
        if (ffp->mediacodec_sync) {
            node->func_run_sync = func_run_sync_loop;
        } else {
            node->func_run_sync = func_run_sync;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    默认使用的是func_run_sync:

    static int func_run_sync(IJKFF_Pipenode *node)
    {
        //……
        //A. 创建enqueue_thread,喂原始数据
        opaque->enqueue_thread = SDL_CreateThreadEx(&opaque->_enqueue_thread, enqueue_thread_func, node, "amediacodec_input_thread");
    
        //B. 循环拉取解码数据
        while (!q->abort_request) {
            got_frame = 0;
            //1. drain_output_buffer获取frame
            ret = drain_output_buffer(env, node, timeUs, &dequeue_count, frame, &got_frame);
            //……
            if (ret != 0) {
                //拉取出错,release buffer false通知MediaCodec丢弃这一帧
            }
            if (got_frame) {
                duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
                pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb);
                //2. 解码速度过慢,丢帧
                if (ffp->framedrop > 0 || (ffp->framedrop && ffp_get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) {
                    //逻辑与软解丢帧类似,可以参考ffplay解码线程分析
                }
                //3. 帧入队
                ret = ffp_queue_picture(ffp, frame, pts, duration, av_frame_get_pkt_pos(frame), is->viddec.pkt_serial);
                if (ret) {
                    //入队出错,release buffer false通知MediaCodec丢弃这一帧
                }
                av_frame_unref(frame);
        }
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    func_run_sync的函数比较长,上述代码仅抽取了主干代码。主干代码分为两个部分:

    • 创建enqueue_thread,喂原始数据;
    • 循环拉取解码数据;

    也就是ijkplayer中把MediaCodec的queueInputBufferdequeueOutputBuffer两个过程分离到了两个线程:func_run_sync负责dequeue,dequeue的主要实现在drain_output_buffer

    drain_output_buffer调用后会取到一帧填充好的AVFrame,之后调用ffp_queue_picture将这一帧放入到FrameQueue中。

    在分析drain_output_buffer前先看下enqueue_thread_func

    static int enqueue_thread_func(void *arg)
    {
        while (!q->abort_request && !opaque->abort) {
            ret = feed_input_buffer(env, node, AMC_INPUT_TIMEOUT_US, &dequeue_count);
            if (ret != 0) {
                goto fail;
            }
        }
        ret = 0;
    fail:
        SDL_AMediaCodecFake_abort(opaque->acodec);
        ALOGI("MediaCodec: %s: exit: %d", __func__, ret);
        return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    enqueue_thread_func的实现比较简单,即循环调用feed_input_buffer

    因此,在分析了上述两个线程后,我们找到的3个主要函数是:

    • feed_input_buffer:主要调用MediaCodec.queueInputBuffer给MediaCodec喂原始数据;
    • drain_output_buffer:主要调用MediaCodec.dequeueOutputBuffer从MediaCodec拉解码数据;
    • ffp_queue_picture:将解码后的AVFrame送入FrameQueue;

    feed_input_buffer

    static int feed_input_buffer(JNIEnv *env, IJKFF_Pipenode *node, int64_t timeUs, int *enqueue_count)
    {
        //……
        //1. 读Packet,及不连续Packet的处理
        if (!d->packet_pending || d->queue->serial != d->pkt_serial) {
            do {
                ffp_packet_queue_get_or_buffering(ffp, d->queue, &pkt, &d->pkt_serial, &d->finished)if (ffp_is_flush_packet(&pkt) || opaque->acodec_flush_request) {
                    SDL_AMediaCodec_flush()}
            }while (ffp_is_flush_packet(&pkt) || d->queue->serial != d->pkt_serial);
            av_packet_unref(&d->pkt);
            d->pkt_temp = d->pkt = pkt;
            d->packet_pending = 1;
        }
    
        //2. 喂数据
        if (d->pkt_temp.data) {
            //如果需要重新配置MediaCodec,则重新创建一个
            if (ffpipeline_is_surface_need_reconfigure_l(pipeline)) {
                ret = reconfigure_codec_l(env, node, new_surface);
            }
            input_buffer_index = SDL_AMediaCodec_dequeueInputBuffer(opaque->acodec, timeUs);
            copy_size = SDL_AMediaCodec_writeInputData(opaque->acodec, input_buffer_index, d->pkt_temp.data, d->pkt_temp.size);
            amc_ret = SDL_AMediaCodec_queueInputBuffer(opaque->acodec, input_buffer_index, 0, copy_size, time_stamp, queue_flags);
        }
    
        //3. pkt_temp更新
        if (copy_size < 0) {//无需分包
            d->packet_pending = 0;
        } else {
            d->pkt_temp.dts =
            d->pkt_temp.pts = AV_NOPTS_VALUE;
            if (d->pkt_temp.data) {//分包更新
                d->pkt_temp.data += copy_size;
                d->pkt_temp.size -= copy_size;
                if (d->pkt_temp.size <= 0)
                    d->packet_pending = 0;
            } else {//解码结束
                d->packet_pending = 0;
                d->finished = d->pkt_serial;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    这段代码有3个步骤:

    1. 读Packet,及不连续Packet的处理。主要是通过ffp_packet_queue_get_or_buffering读一个Packet,如果不连续,就丢Packet和Flush MediaCodec;
    2. 喂数据。MediaCodec的典型步骤:dequeueInputBuffer -> writeInputData -> queueInputBuffer
    3. pkt_temp更新;

    导致这段代码不好理解的一个地方是分包发送的处理。因为在调用SDL_AMediaCodec_writeInputData发送Packet数据的时候,不一定能恰好完整发送,所以需要分多次发送。

    分包发送代码中pkt_temp表示要发送的pkt,packet_pending表示pkt_temp中有未发送完的数据。每次发送后,根据已发送大小copy_size更新pkt_temp,直到pkt_temp.size小于0,置packet_pending为0。在读Packet前会先判断packet_pending是否为1,如果为1,则不拉取新的Packet,而是先消耗pkt_temp。这样就达到循环发送Packet的目的了。

    上述代码是经过大量省略的,被省略的代码还有几个功能点:

    • reconfig的具体实现,以及如何与fun_run_sync线程同步
    • mediacodec_handle_resolution_change处理
    • H264/H265特殊处理
    • fake frame处理

    drain_output_buffer

    //drain_output_buffer = lock(opaque->acodec_mutex) + drain_output_buffer_l + unlock(opaque->acodec_mutex)
    static int drain_output_buffer_l(JNIEnv *env, IJKFF_Pipenode *node, int64_t timeUs, int *dequeue_count, AVFrame *frame, int *got_frame)
    {
        output_buffer_index = SDL_AMediaCodecFake_dequeueOutputBuffer(opaque->acodec, &bufferInfo, timeUs);
        if (output_buffer_index == AMEDIACODEC__INFO_OUTPUT_BUFFERS_CHANGED) {
            ALOGI("AMEDIACODEC__INFO_OUTPUT_BUFFERS_CHANGED\n");
        }
        else if (output_buffer_index == AMEDIACODEC__INFO_OUTPUT_FORMAT_CHANGED) {
            ALOGI("AMEDIACODEC__INFO_OUTPUT_FORMAT_CHANGED\n");
        }
        else if (output_buffer_index == AMEDIACODEC__INFO_TRY_AGAIN_LATER) {
            AMCTRACE("AMEDIACODEC__INFO_TRY_AGAIN_LATER\n");
        }
        else if (output_buffer_index < 0) {
            goto done;
        }
        else if (output_buffer_index >= 0) {
            if (opaque->n_buf_out) {
                // 如果开启了缓冲区,则对缓冲区内的帧进行pts排序后输出。
              	// 看代码,目前只有codec是OMX.TI.DUCATI1.才启用。也就是默认MediaCodec的输出都是pts排序好的
            }else {
                ret = amc_fill_frame(node, frame, got_frame, output_buffer_index, SDL_AMediaCodec_getSerial(opaque->acodec), &bufferInfo);
            }
        }
    
    done:
        if (opaque->decoder->queue->abort_request)
            ret = -1;
        else
            ret = 0;
    fail:
        return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    drain_output_buffer相比feed_input_buffer来的简单,主要是调用SDL_AMediaCodecFake_dequeueOutputBuffer(即MediaCodec.dequeueOutputBuffer),根据返回值打印调试信息。如果是output_buffer_index >= 0,则调用amc_fill_frame把bufferinfo填充到AVFrame中。

    amc_fill_frame主要是填充Frame的宽、高、pts等信息,把bufferinfo填入到Opaque中,并没有填充真正的图像数据。Frame显示的时候再利用这些信息通过MediaCodec的releasseOutputBuffer进行显示。

    帧入队

    ijkplayer 解码实现分析——软解篇中我们分析了queue_picture(ffp_queue_picture封装的是queue_picture)的逻辑。与ffplay的queue_picture的差异在于增加了一些“绘图操作”,这些绘图操作是通过SDL_Vout_CreateOverlay -> SDL_VoutFillFrameYUVOverlay完成。

    SDL_Vout_CreateOverlay会调用具体vout->create_overlaySDL_VoutFillFrameYUVOverlay会调用overlay->func_fill_frame

    对于MediaCodec而言,vout->create_overlay会调用到ijksdl_vout_overlay_android_mediacodec.c中的SDL_VoutAMediaCodec_CreateOverlay,这个函数中关键的几行是:

    SDL_VoutOverlay_Opaque *opaque = overlay->opaque;
    opaque->buffer_proxy  = NULL;
    overlay->opaque_class = &g_vout_overlay_amediacodec_class;
    overlay->format       = SDL_FCC__AMC;
    
    • 1
    • 2
    • 3
    • 4

    即:mediacodec的overlay的format指定为SDL_FCC__AMC,并且在opauqe中有一个buffer_proxy用于保存mediacodec解码后的buffer_index和bufferinfo。

    SDL_FCC__AMC主要用于在显示函数(ijksdl_vout_android_nativewindow.c)func_display_overlay_l中判断是否应该调用SDL_VoutOverlayAMediaCodec_releaseFrame_l来显示。分析见ijkplayer video显示分析

    对于overlay->func_fill_frame会调用到ijksdl_vout_overlay_android_mediacodec.c中的func_fill_frame,这个函数中关键的几行是:

    opaque->buffer_proxy = (SDL_AMediaCodecBufferProxy *)frame->opaque;
    overlay->opaque_class = &g_vout_overlay_amediacodec_class;
    overlay->format     = SDL_FCC__AMC;
    overlay->w = (int)frame->width;
    overlay->h = (int)frame->height;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    drain_output_buffer中分析过,amc_fill_frame会把bufferinfo填入到AVFrame的Opaque中,这里只是再复制到了overlay->opaque中,方便在显示时访问该变量。

    软解

    软解的pipenode定义在ffpipenode_ffplay_vdec.h/c中。通过函数ffpipenode_create_video_decoder_from_ffplay来创建一个软解码器。不过ijkplayer中ffplay pipenode并不是由ffplay pipeline创建,而是由android pipeline创建:

    //ffpipeline_android.c
    static IJKFF_Pipenode *func_open_video_decoder(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        IJKFF_Pipeline_Opaque *opaque = pipeline->opaque;
        IJKFF_Pipenode        *node = NULL;
    
        //如果有启用任何一种硬解选项,则创建mediacodec video decoder
        if (ffp->mediacodec_all_videos || ffp->mediacodec_avc || ffp->mediacodec_hevc || ffp->mediacodec_mpeg2)
            node = ffpipenode_create_video_decoder_from_android_mediacodec(ffp, pipeline, opaque->weak_vout);
    
        //如果没有启用硬解,或者创建失败,则返回ffplay video decoder
        if (!node) {
            node = ffpipenode_create_video_decoder_from_ffplay(ffp);
        }
        return node;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    ffpipenode_create_video_decoder_from_ffplay定义如下:

    IJKFF_Pipenode *ffpipenode_create_video_decoder_from_ffplay(FFPlayer *ffp)
    {
        IJKFF_Pipenode *node = ffpipenode_alloc(sizeof(IJKFF_Pipenode_Opaque));
        if (!node)
            return node;
    
        IJKFF_Pipenode_Opaque *opaque = node->opaque;
        opaque->ffp         = ffp;
    
        node->func_destroy  = func_destroy;
        node->func_run_sync = func_run_sync;
    
        ffp_set_video_codec_info(ffp, AVCODEC_MODULE_NAME, avcodec_get_name(ffp->is->viddec.avctx->codec_id));
        ffp->stat.vdec_type = FFP_PROPV_DECODER_AVCODEC;
        return node;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    软解的关键实现在func_run_sync:

    static int func_run_sync(IJKFF_Pipenode *node)
    {
        IJKFF_Pipenode_Opaque *opaque = node->opaque;
        return ffp_video_thread(opaque->ffp);
    }
    
    int ffp_video_thread(FFPlayer *ffp)
    {
        return ffplay_video_thread(ffp);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里的ffplay_video_thread实现基本与ffplay的video_thread是一样的,其分析参考ffplay解码线程分析

    queue_picture

    软解与ffplay有所不同的地方在于queue_picture(将解码帧放入FrameQueue中):

    static int queue_picture(FFPlayer *ffp, AVFrame *src_frame, double pts, double duration, int64_t pos, int serial)
    {
        //……
        if (!(vp = frame_queue_peek_writable(&is->pictq)))
            return -1;
    
        //创建overlay
        if (!vp->bmp || !vp->allocated ||
            vp->width  != src_frame->width ||
            vp->height != src_frame->height ||
            vp->format != src_frame->format) {
    
            if (vp->width != src_frame->width || vp->height != src_frame->height)
                ffp_notify_msg3(ffp, FFP_MSG_VIDEO_SIZE_CHANGED, src_frame->width, src_frame->height);
    
            vp->allocated = 0;
            vp->width = src_frame->width;
            vp->height = src_frame->height;
            vp->format = src_frame->format;
    
            alloc_picture(ffp, src_frame->format);
    
            if (is->videoq.abort_request)
                return -1;
        }
    
        //填充overlay
        if (vp->bmp) {
            SDL_VoutLockYUVOverlay(vp->bmp);
            if (SDL_VoutFillFrameYUVOverlay(vp->bmp, src_frame) < 0) {
                av_log(NULL, AV_LOG_FATAL, "Cannot initialize the conversion context\n");
                exit(1);
            }
            SDL_VoutUnlockYUVOverlay(vp->bmp);
    
            //和ffplay类似,保存frame信息。不同的是,不保存frame数据
            vp->pts = pts;
            vp->duration = duration;
            vp->pos = pos;
            vp->serial = serial;
            vp->sar = src_frame->sample_aspect_ratio;
            vp->bmp->sar_num = vp->sar.num;
            vp->bmp->sar_den = vp->sar.den;
    
            frame_queue_push(&is->pictq);
            if (!is->viddec.first_frame_decoded) {
                ALOGD("Video: first frame decoded\n");
                ffp_notify_msg1(ffp, FFP_MSG_VIDEO_DECODED_START);
                is->viddec.first_frame_decoded_time = SDL_GetTickHR();
                is->viddec.first_frame_decoded = 1;
            }
        }
    
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    可以看到queue_picture中做了一些“绘图”相关的操作,即把AVFrame的数据绘制到vout overlay上。(这样就可以在显示的时候不关心具体的解码类型了)

    创建overlay

    正常情况下vp->bmp创建一次后可重复使用,不需要重新创建。只有格式变化后,才需要调用alloc_picture重新创建。

    static void alloc_picture(FFPlayer *ffp, int frame_format)
    {
        //……
        vp = &is->pictq.queue[is->pictq.windex];	//取当前要写入的帧,即peek_writabe的帧
        free_picture(vp);													//SDL_VoutFreeYUVOverlay(vp->bmp);
    
        SDL_VoutSetOverlayFormat(ffp->vout, ffp->overlay_format);
    
        vp->bmp = SDL_Vout_CreateOverlay(vp->width, vp->height,
                                       frame_format,
                                       ffp->vout);
        //……
        SDL_LockMutex(is->pictq.mutex);
        vp->allocated = 1;
        SDL_CondSignal(is->pictq.cond);
        SDL_UnlockMutex(is->pictq.mutex);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    alloc_picture主要是通过调用SDL_Vout的接口,根据frame_format来创建一个Overlay。在ijkplayer video显示分析中分析过对于android默认通过SDL_VoutAndroid_CreateForAndroidSurface创建vout。该vout实现对应的overlay创建函数是:

    //SDL_LockMutex(vout->mutex);
    static SDL_VoutOverlay *func_create_overlay_l(int width, int height, int frame_format, SDL_Vout *vout)
    {
        switch (frame_format) {
        case IJK_AV_PIX_FMT__ANDROID_MEDIACODEC:
            return SDL_VoutAMediaCodec_CreateOverlay(width, height, vout);
        default:
            return SDL_VoutFFmpeg_CreateOverlay(width, height, frame_format, vout);
        }
    }
    //SDL_UnlockMutex(vout->mutex);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    如果配置的是硬解,这里的frame_format将是IJK_AV_PIX_FMT__ANDROID_MEDIACODEC,会创建mediacodec“显示”所需的overlay,否则,创建的ffmpeg overlay。

    填充overlay

    overlay的填充是调用的SDL_VoutFillFrameYUVOverlay,即overlay->func_fill_frame。对于软解调用的是ijksdl_vout_overlay_ffmpeg中的func_fill_frame

    static int func_fill_frame(SDL_VoutOverlay *overlay, const AVFrame *frame)
    {
        //……
        //1. 根据format决定后面的填充方法
        switch (overlay->format) {
            case SDL_FCC_YV12:
                need_swap_uv = 1;
                // no break;
            case SDL_FCC_I420:
                if (frame->format == AV_PIX_FMT_YUV420P || frame->format == AV_PIX_FMT_YUVJ420P) {
                    // ALOGE("direct draw frame");
                    use_linked_frame = 1;
                    dst_format = frame->format;
                } else {
                    // ALOGE("copy draw frame");
                    dst_format = AV_PIX_FMT_YUV420P;
                }
                break;
            //……
        }
    
        //2. 准备内部frame用于填充;如果是use_linked_frame,则引用输入参数frame;否则开辟内存,存放到managed_frame
        if (use_linked_frame) {
            // linked frame
            av_frame_ref(opaque->linked_frame, frame);
    
            overlay_fill(overlay, opaque->linked_frame, opaque->planes);
    
            if (need_swap_uv)
                FFSWAP(Uint8*, overlay->pixels[1], overlay->pixels[2]);
        } else {
            // managed frame
            AVFrame* managed_frame = opaque_obtain_managed_frame_buffer(opaque);
            if (!managed_frame) {
                ALOGE("OOM in opaque_obtain_managed_frame_buffer");
                return -1;
            }
    
            overlay_fill(overlay, opaque->managed_frame, opaque->planes);
    
            // setup frame managed
            for (int i = 0; i < overlay->planes; ++i) {
                swscale_dst_pic.data[i] = overlay->pixels[i];
                swscale_dst_pic.linesize[i] = overlay->pitches[i];
            }
    
            if (need_swap_uv)
                FFSWAP(Uint8*, swscale_dst_pic.data[1], swscale_dst_pic.data[2]);
        }
    
        //3. 执行填充动作;对于use_linked_frame引用frame后即已填充好;否则需要用sws_scale转化到目标格式(可能是为了方便opengl绘图)
        if (use_linked_frame) {
            // do nothing
        } else if (ijk_image_convert(frame->width, frame->height,
                                     dst_format, swscale_dst_pic.data, swscale_dst_pic.linesize,
                                     frame->format, (const uint8_t**) frame->data, frame->linesize)) {
            opaque->img_convert_ctx = sws_getCachedContext(opaque->img_convert_ctx,
                                                           frame->width, frame->height, frame->format, frame->width, frame->height,
                                                           dst_format, opaque->sws_flags, NULL, NULL, NULL);
            if (opaque->img_convert_ctx == NULL) {
                ALOGE("sws_getCachedContext failed");
                return -1;
            }
    
            sws_scale(opaque->img_convert_ctx, (const uint8_t**) frame->data, frame->linesize,
                      0, frame->height, swscale_dst_pic.data, swscale_dst_pic.linesize);
    
            if (!opaque->no_neon_warned) {
                opaque->no_neon_warned = 1;
                ALOGE("non-neon image convert %s -> %s", av_get_pix_fmt_name(frame->format), av_get_pix_fmt_name(dst_format));
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73

    在queue_picture中将frame填充到opaque后,就可以调用SDL_VoutDisplayYUVOverlay去显示了。显示的逻辑可以参考ffplay video显示线程分析ijkplayer video显示分析

    视频显示

    ffplay基于sdl显示图像,ijkplayer在显示上摒弃了sdl,而是另辟蹊径封装了一套自己的显示接口。

    基础概念

    还是从显示函数开始看起(ff_ffplay.c):

    stream_open -> SDL_CreateThreadEx video_refresh_thread
        ->video_refresh
            ->video_display2
                ->video_image_display2
                    ->SDL_VoutDisplayYUVOverlay
    
    • 1
    • 2
    • 3
    • 4
    • 5

    整个调用链和ffplay保持一致,只是显示线程从主线程改变了,到了一个独立线程中。最后在显示一帧图像的时候调用的是SDL_VoutDisplayYUVOverlay

    int SDL_VoutDisplayYUVOverlay(SDL_Vout *vout, SDL_VoutOverlay *overlay)
    {
        if (vout && overlay && vout->display_overlay)
            return vout->display_overlay(vout, overlay);
    
        return -1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    SDL_VoutDisplayYUVOverlay的函数里,我们看到了两个新的概念:SDL_VoutSDL_VoutOverlay

    这两个是ijk中才有的概念,是为了封装“显示上下文”和“显示层”准备的。

    SDL_Vout

    ijk中使用SDL_Vout表示一个显示上下文,或者理解为一块画布,比较接近于SDL中的Render。SDL_VoutOverlay表示显示层,或者理解为一块图像数据,比较接近于SDL中的Texture。

    SDL_Vout的定义如下:

    struct SDL_Vout {
        SDL_mutex *mutex;
        SDL_Class       *opaque_class;
        SDL_Vout_Opaque *opaque;
        SDL_VoutOverlay *(*create_overlay)(int width, int height, int frame_format, SDL_Vout *vout);
        void (*free_l)(SDL_Vout *vout);
        int (*display_overlay)(SDL_Vout *vout, SDL_VoutOverlay *overlay);
    
        Uint32 overlay_format;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    定义了一个“类/接口”,支持的方法有create_overlayfree_ldisplay_overlay。其中,最重要的是display_overlay方法,即如何去呈现一个overlay。

    既然是一个接口,就有对应的实现类:

    • dummy: 这是一个空实现,定义在ijk_sdl_vout_dummy.c;
    • android surface vout: 这是基于Android的surface实现的,定义在ijk_sdl_android_surface.c,ijk_vout_android_nativewindow.c;

    SDL_VoutOverlay

    SDL_VoutOverlay的定义如下:

    struct SDL_VoutOverlay {
        int w; /**< Read-only */
        int h; /**< Read-only */
        Uint32 format; /**< Read-only */
        int planes; /**< Read-only */
        Uint16 *pitches; /**< in bytes, Read-only */
        Uint8 **pixels; /**< Read-write */
    
        int is_private;
    
        int sar_num;
        int sar_den;
    
        SDL_Class               *opaque_class;
        SDL_VoutOverlay_Opaque  *opaque;
    
        void    (*free_l)(SDL_VoutOverlay *overlay);
        int     (*lock)(SDL_VoutOverlay *overlay);
        int     (*unlock)(SDL_VoutOverlay *overlay);
        void    (*unref)(SDL_VoutOverlay *overlay);
    
        int     (*func_fill_frame)(SDL_VoutOverlay *overlay, const AVFrame *frame);
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    定义的方法有free_l/lock/unlock/unref/func_fill_frame,其中最重要的是func_fill_frame,也就是把AVFrame的图像“画”到overlay上。

    它也有对应的几种实现:

    • ffmpeg overlay:用于软解绘图,主要是内存图像数据的格式转换。定义在ijksdl_vout_ffmpeg_overlay.c;
    • mediacodec overlay:用于mediacodec硬解绘图,主要用于MediaCodec的buffer index wrap和管理。定义在ijksdl_vout_overlay_android_mediacodec.c。(mediacodec可以直接绑定Surface解码,以提升效率,这种方式的解码是拿不到图像数据的,所以这里要wrap index);

    目录结构

    上面提到的一些结构体和函数,都在目录ijkmedia/ijksdl/下:

    + ijkmedia/ijksdl
        - ijk_sdl.h                             //包含其他sdl头文件
        - ijksdl_vout.h/c                       //封装层,提供SDL_VoutXXX的函数调用vout和overlay
        - ijksdl_vout_internal.h                //ijksdl目录内部使用的一些util函数
        + dummy                                 //dummy vout实现
        + ffmpeg
            - ijksdl_vout_overlay_ffmpeg.h/c    //ffmpeg overlay实现
        + android
            - ijksdl_vout_android_nativewindow.c//android vout的主要实现
            - ijksdl_vout_android_surface.h/c   //android vout与Java层Surface连接层
            - ijksdl_vout_overlay_android_mediacodec.h/c //mediacodec overlay实现
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    目录里还包括的timer/mutex/thread等的封装,另外音频相关的sdl封装也在这个目录里。

    流程分析

    Android上的SDL_Vout是通过ijksdl_vout_android_surface.c中的SDL_VoutAndroid_CreateForAndroidSurface函数创建的,对应的SDL_Vout的实现在ijksdl_vout_android_nativewindow.c。

    SDL_VoutAndroid_CreateForAndroidSurface调用流程如下:

    new IjkMediaPlayer() 
        -> initPlayer() 
            -> native_setup() 
                -> IjkMediaPlayer_native_setup() 
                    -> ijkmp_android_create() 
                        -> SDL_VoutAndroid_CreateForAndroidSurface()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    SDL_Vout创建后就可以用来显示SDL_VoutOverlay了,overlay的创建和填充是在解码线程中完成

    前面分析了overlay的显示是在video_display2中调用SDL_VoutDisplayYUVOverlay显示的。SDL_VoutDisplayYUVOverlay只是封装了具体SDL_Vout实现类的display_overlay方法。对于Android,对应的是ijksdl_vout_android_nativewindow.c中的func_display_overlay

    static int func_display_overlay(SDL_Vout *vout, SDL_VoutOverlay *overlay)
    {
        SDL_LockMutex(vout->mutex);
        int retval = func_display_overlay_l(vout, overlay);
        SDL_UnlockMutex(vout->mutex);
        return retval;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    加锁调用func_display_overlay_l(精简了代码,直接看正常流程代码):

    static int func_display_overlay_l(SDL_Vout *vout, SDL_VoutOverlay *overlay)
    {
        switch(overlay->format) {
        case SDL_FCC__AMC: {
            // only ANativeWindow support
            IJK_EGL_terminate(opaque->egl);
            return SDL_VoutOverlayAMediaCodec_releaseFrame_l(overlay, NULL, true);
        }
        case SDL_FCC_RV24:
        case SDL_FCC_I420:
        case SDL_FCC_I444P10LE: {
            // only GLES support
            if (opaque->egl)
                return IJK_EGL_display(opaque->egl, native_window, overlay);
            break;
        }
        case SDL_FCC_YV12:
        case SDL_FCC_RV16:
        case SDL_FCC_RV32: {
            // both GLES & ANativeWindow support
            if (vout->overlay_format == SDL_FCC__GLES2 && opaque->egl)
                return IJK_EGL_display(opaque->egl, native_window, overlay);
            break;
        }
        }
    
        // fallback to ANativeWindow
        IJK_EGL_terminate(opaque->egl);
        return SDL_Android_NativeWindow_display_l(native_window, overlay); 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    这里主要是根据传入overlay的format选择具体的显示方法。这里有3种显示方法:

    • 如果是SDL_FCC__AMC,则是MediaCodec的特定format,用SDL_VoutOverlayAMediaCodec_releaseFrame_l显示;
    • 如果是其他EGL支持的格式,则用IJK_EGL_display显示;
    • 最后,如果都无法显示,就Fallback到直接用ndk nativewindow的api显示;

    其中第一种是针对MediaCodec的硬解显示方式,后两种都是软解的显示方式。

    硬解显示

    MediaCodec是Android硬解的统一API,方便了不同芯片厂商接入。MediaCodec解码时设置一个Surface以减少显示时的数据拷贝,可以提高效率。此时解码后拿到的是一个index,并非解码后的图像数据,ijk中将其封装为SDL_AMediaCodecBufferProxy,定义在jksdl_vout_android_nativewindow.c中:

    struct SDL_AMediaCodecBufferProxy
    {
        int buffer_id;
        int buffer_index;
        int acodec_serial;
        SDL_AMediaCodecBufferInfo buffer_info;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    SDL_AMediaCodecBufferProxy的实例在android overlay的SDL_VoutOverlay_Opaque中定义:

    typedef struct SDL_VoutOverlay_Opaque {
        SDL_mutex *mutex;
    
        SDL_Vout                   *vout;
        SDL_AMediaCodec            *acodec;
    		
      	//这个是dequeueOutputBuffer的封装
        SDL_AMediaCodecBufferProxy *buffer_proxy;
    
        Uint16 pitches[AV_NUM_DATA_POINTERS];
        Uint8 *pixels[AV_NUM_DATA_POINTERS];
    } SDL_VoutOverlay_Opaque;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    MediaCodec要显示一帧,是通过调用releaseOutputBuffer通知MediaCodec该显示哪一帧的。这在ijk中是封装为SDL_AMediaCodec_releaseOutputBuffer

    回到刚才的思路,看下SDL_VoutOverlayAMediaCodec_releaseFrame_l函数:

    int  SDL_VoutOverlayAMediaCodec_releaseFrame_l(SDL_VoutOverlay *overlay, SDL_AMediaCodec *acodec, bool render)
    {
        if (!check_object(overlay, __func__))
            return -1;
    
        SDL_VoutOverlay_Opaque *opaque = overlay->opaque;
        return SDL_VoutAndroid_releaseBufferProxyP_l(opaque->vout, &opaque->buffer_proxy, render);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    SDL_VoutAndroid_releaseBufferProxyP_l调用了SDL_VoutAndroid_releaseBufferProxy_l

    //这里省略了打印调试信息的代码
    static int SDL_VoutAndroid_releaseBufferProxy_l(SDL_Vout *vout, SDL_AMediaCodecBufferProxy *proxy, bool render)
    {
        SDL_Vout_Opaque *opaque = vout->opaque;
    
        //归还到SDL_AMediaCodecBufferProxy对象池
        ISDL_Array__push_back(&opaque->overlay_pool, proxy);
    
        //如果serial已经变化,说明该帧已经无效(比如是seek前的帧),不显示,丢弃
        if (!SDL_AMediaCodec_isSameSerial(opaque->acodec, proxy->acodec_serial)) {
            return 0;
        }
    
        //buffer_index即dequeueOutputBuffer的返回值,小于0说明不是一个图像帧(具体参考官方API),无需显示
        if (proxy->buffer_index < 0) {
            return 0;
        } 
        //FAKE_FRAME是一个特殊标志,表示当前帧只是占位,不应该显示(具体分析将在ijkplay硬解一文分析)
        else if (proxy->buffer_info.flags & AMEDIACODEC__BUFFER_FLAG_FAKE_FRAME) {
            proxy->buffer_index = -1;
            return 0;
        }
    
        //到这里,就可以显示了
        sdl_amedia_status_t amc_ret = SDL_AMediaCodec_releaseOutputBuffer(opaque->acodec, proxy->buffer_index, render);    
        if (amc_ret != SDL_AMEDIA_OK) {//显示失败,返回-1
            proxy->buffer_index = -1;
            return -1;
        }
    
        proxy->buffer_index = -1;
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    上面代码,主要做了3件事情:

    1. 归还proxy到SDL_AMediaCodecBufferProxy对象池。因为解码的调用很频繁,如果重复分配释放proxy对象,内存压力会比较大,所以这里引入了一个对象池进行优化;
    2. 做一些合法性检查。比如检查serial变化、检查index值、检查占位符等;
    3. 调用SDL_AMediaCodec_releaseOutputBuffer(也就是MediaCodec.releaseOutputBuffer)显示;

    音频输出

    ijkplayer在Android上的的音频输出支持opensles和AudioTrack。

    概念

    音频输出被抽象为SDL_Aout:

    struct SDL_Aout {
    //……
        SDL_Class       *opaque_class;
        SDL_Aout_Opaque *opaque;
        void (*free_l)(SDL_Aout *vout);
        int (*open_audio)(SDL_Aout *aout, const SDL_AudioSpec *desired, SDL_AudioSpec *obtained);
        void (*pause_audio)(SDL_Aout *aout, int pause_on);
        void (*flush_audio)(SDL_Aout *aout);
        void (*set_volume)(SDL_Aout *aout, float left, float right);
        void (*close_audio)(SDL_Aout *aout);
    //……
    };
    // SDL_Aout由pipeline创建:
    struct IJKFF_Pipeline {
        //……
        SDL_Aout       *(*func_open_audio_output)   (IJKFF_Pipeline *pipeline, FFPlayer *ffp);
        //……
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    android上ffpipeline_android.c根据选项opensles创建具体的SDL_Aout:

    static SDL_Aout *func_open_audio_output(IJKFF_Pipeline *pipeline, FFPlayer *ffp)
    {
        SDL_Aout *aout = NULL;
        if (ffp->opensles) {
            aout = SDL_AoutAndroid_CreateForOpenSLES();
        } else {
            aout = SDL_AoutAndroid_CreateForAudioTrack();
        }
        if (aout)
            SDL_AoutSetStereoVolume(aout, pipeline->opaque->left_volume, pipeline->opaque->right_volume);
        return aout;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    和SDL_Aout相关的目录结构如下:

    +ijkmedia/ijkplayer
        -ff_ffpipeline.h/c              //pipeline实现
        +android/pipeline
            -ffpipeline_android.h/c     //android pipeline实现
    
    +ijksdl
        -ijksdl_aout.h/c                //SDL_Aout定义、封装
        +android
            -ijksdl_aout_android_audiotrack.h/c     //SDL_Aout的AudioTrack实现
            -ijksdl_aout_android_opensles.h/c       //SDL_Aout的opensles实现
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    对于SDL_Aout的使用流程基本和ffplay一样,不再展开分析。

    AudioTrack实现分析

    AudioTrack输出实现的SDL_Aout在文件ijksdl_aout_android_audiotrack.h/c中。AudioTrack aout的主要实现是一个循环线程aout_thread_n,该线程在aout_open_audio中创建。其他操作都是通过变量的改变来通知循环线程生效的,比如flush:

    static void aout_flush_audio(SDL_Aout *aout)
    {
        SDL_Aout_Opaque *opaque = aout->opaque;
        SDL_LockMutex(opaque->wakeup_mutex);
        SDLTRACE("aout_flush_audio()");
        opaque->need_flush = 1;
        SDL_CondSignal(opaque->wakeup_cond);
        SDL_UnlockMutex(opaque->wakeup_mutex);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    先是置了一个need_flush标志,然后通过条件变量通知线程处理。

    接下来就看下aout_thread_n的实现:

    static int aout_thread_n(JNIEnv *env, SDL_Aout *aout)
    {
        SDL_AudioCallback audio_cblk = opaque->spec.callback;//这就是ff_ffplay的sdl_audio_callback
        int copy_size = 256;//每次要求ff_ffplay给256个字节的音频数据
    
        SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH);//音频输出线程设置为高优先级,保证音频流畅输出
    
        if (!opaque->abort_request && !opaque->pause_on)
            SDL_Android_AudioTrack_play(env, atrack);//开始播放
    
        while (!opaque->abort_request) {
            SDL_LockMutex(opaque->wakeup_mutex);
            //如果有暂停请求,处理暂停
            if (!opaque->abort_request && opaque->pause_on) {
                SDL_Android_AudioTrack_pause(env, atrack);//AudioTrack.pause
                while (!opaque->abort_request && opaque->pause_on) {//循环超时等待信号
                    SDL_CondWaitTimeout(opaque->wakeup_cond, opaque->wakeup_mutex, 1000);
                }
                //恢复播放
                if (!opaque->abort_request && !opaque->pause_on) {
                    if (opaque->need_flush) {//如果有flush请求,处理flush
                        opaque->need_flush = 0;
                        SDL_Android_AudioTrack_flush(env, atrack);//AudioTrack.flush
                    }
                    SDL_Android_AudioTrack_play(env, atrack);//AudioTrack.play
                }
            }
            //如果有设置音量请求,设置音量
            if (opaque->need_set_volume) {
                opaque->need_set_volume = 0;
                SDL_Android_AudioTrack_set_volume(env, atrack, opaque->left_volume, opaque->right_volume);//AudioTrack.setVolume
            }
            //如果有变速请求,处理变速
            if (opaque->speed_changed) {
                opaque->speed_changed = 0;
                SDL_Android_AudioTrack_setSpeed(env, atrack, opaque->speed);//AudioTrack.setPlaybackRate
            }
            SDL_UnlockMutex(opaque->wakeup_mutex);
    
            //找ff_ffplay要解码后音频数据,一次256字节
            audio_cblk(userdata, buffer, copy_size);
    
            //AudioTrack.write写出
            int written = SDL_Android_AudioTrack_write(env, atrack, buffer, copy_size);
            if (written != copy_size) {
                ALOGW("AudioTrack: not all data copied %d/%d", (int)written, (int)copy_size);
            }
        }
    
        SDL_Android_AudioTrack_free(env, atrack);//AudioTrack.release释放
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    源码中有不只一次处理flush请求,并调用flush。然而根据官方文档说明flush函数会“No-op if not stopped or paused”,即不在暂停或停止状态调用flush是无效的,所以为了理解方便,上面只保留了“有效”的一处flush。

    aout_thread_n的代码逻辑比较清晰整体分3个部分:

    • 在加锁区,判断是否有未处理的请求,如果是,则处理该请求。这些请求是aout_flush_audio等SDL_Aout的函数发起的;
    • 调用audio_cblk(即sdl_audio_callback)要解码后的音频数据;
    • 通过AudioTrack.write写出;

    MediaCodec

    vHcOqs.png
    • dequeueinputBuffer(从input缓冲队列申请empty buffer,左边的上虚线);
    • inputbuffer(拷贝mp4文件的一帧到empty buffer);
    • queueInputBuffe(将inputbuffer放回codec);
    • dequeueOutputBuffer(从output缓冲区队列申请编解码后的buffer);
    • 编码后的数据渲染;
    • releaseOutputBuffer(放回到output缓冲区队列);

    packetQueue和frameQueue

    packetQueue采用两条链表,一个是保存数据的链表,一个是复用节点链表,保存没有数据的那些节点。数据链表从first_pktlast_pkt,插入数据接到last_pkt的后面,取数据从first_pkt拿。复用链表的开头是recycle_pkt,取完数据后的空节点,放到空链表recycle_pkt的头部,然后这个空节点成为新的recycle_pkt。存数据时,也从recycle_pkt复用一个节点。

    链表的节点像是包装盒,装载数据的时候放到数据链表,数据取出后回归到复用链表。

    frameQueue:数据使用一个简单的数组保存,可以把这个数据看成是环形的,然后也是其中一段有数据,另一段没有数据。rindex表示数据开头的index,也是读取数据的index,即read index;windex表示空数据开头的index,是写入数据的index,即write index。也是不断循环重用,然后size表示当前数据大小,max_size表示最大的槽位数,写入的时候如果size满了,就会阻塞等待;读取的时候size为空,也会阻塞等待。

    读取的时候不是读的rindex位置的数据,而是rindex+rindex_shown。rindex_shown 表示 rindex 指向的节点是否已经显示,如果已经显示则为1,否则为0。

    rindex 表示上一次播放的帧 lastvp,本次调用 video_refresh() 则 lastvp 会被删除,rindex 会加 1,即当调用 frame_queue_next 删除的是 lastvp,而不是当前的 vp,当前的 vp 转为 lastvp;rindex + rindex_shown 表示本次待播放的帧 vp,本次调用 video_refresh() 中,vp 会被读出播放;rindex + rindex_shown + 1 表示下一帧 nextvp。

    注意,在启用 keep_last 机制后,rindex_shown 值总是为 1,rindex_shown 确保了最后播放的一帧总保留在队列中。

    ijkplayerItem

    AVPlayerItem:管理资源对象,提供播放数据源。

    item_read_thread:

    static int item_read_thread(void * context)
    {
    		......
    		avf_inner = avformat_alloc_context();
    		// 初始化
      	ret = avformat_open_input(&avf_inner, item->url, NULL, &format_opts);
      	avf = avformat_alloc_context();
        for (i = 0; i < avf_inner->nb_streams; i++) {
            AVStream *st ;
            st = avformat_new_stream(avf, NULL);
            ret = copy_stream_props(st, avf_inner->streams[i]);
            if (st->codecpar && st->codecpar->codec_type == AVMEDIA_TYPE_AUDIO)
                has_audio = 1;
        }
    		ret = av_read_frame(avf_inner, pkt);
    		item_packet_queue_put(&item->queue, pkt);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    参考

    参考资料

  • 相关阅读:
    51单片机DS1302时钟
    说说XXLJob分片任务实现原理?
    NFT交易平台开发 创建NFT数字藏品平台
    持续交付(二)PipeLine基本使用
    网速Full Power!这款4G网关信号达360度无死角
    软件测试碎碎念:进大厂容易,做自己却很难
    第一章 C语言知识点(程序)
    一端强制一端自协商会有问题吗
    Ovalbumin-PEG-NTA/TPP 鸡卵白蛋白-聚乙二醇-次氮基三乙酸/磷酸三苯酯
    如何撰写shopee产品的标题—扬帆凌远
  • 原文地址:https://blog.csdn.net/weixin_42461320/article/details/127777167