您当前的位置:首页 > 电脑百科 > 程序开发 > 框架

超详细解析FFplay之数据读取线程

时间:2020-09-15 11:11:11  来源:  作者:

超详细解析FFplay之数据读取线程

本文还是着重分析数据读取线程。还是先上ffplay"藏宝图"。

超详细解析FFplay之数据读取线程

 

从ffplay框架分析我们可以看到,ffplay有专⻔的线程read_thread()读取数据,且在调⽤av_read_frame读取数据包之前需要做例如打开⽂件,查找配置解码器,初始化⾳视频输出等准备阶段,主要包括三⼤步骤:

(1)初始化⼯作

avformat_alloc_context:创建上下⽂。

ic->interrupt_callback.callback = decode_interrupt_cb; 设置超时时间回调。

avformat_open_input:打开媒体文件,包括网络流,文件,内存流。

avformat_find_stream_info:读取媒体⽂件的包获取更多的stream信息。

avformat_seek_file:检测是否指定播放起始时间,如果指定时间则seek到指定位置。

查找查找AVStream,将对应的index值记录到st_index[AVMEDIA_TYPE_NB],有如下2种方案。

a.avformat_match_stream_specifier:根据⽤户指定来查找流。

b.av_find_best_stream:使用这个接口查找流。

从待处理流中获取相关参数,设置显示窗⼝的宽度⾼度及宽⾼⽐。

stream_component_open:打开⾳频、视频、字幕解码器,并创建相应的解码线程以及进⾏对应输出参数的初始化

(2)For循环读取数据

检测是否退出。

检测是否暂停/继续。

检测是否需要seek。

检测video是否为attached_pic。

检测队列是否已经有⾜够数据。

检测码流是否已经播放结束。

是否循环播放。

是否⾃动退出。

使⽤av_read_frame读取数据包。

检测数据是否读取完毕。

检测是否在播放范围内。

到这步才将数据插⼊对应的队列。

(3)退出线程处理

如果解复⽤器有打开则关闭avformat_close_input。

调⽤SDL_PushEvent发送退出事件FF_QUIT_EVENT。

释放互斥量wait_mutex。

具体准备工作,调用了如下函数:

调⽤avformat_alloc_context创建解复⽤器上下⽂。

// 1. 创建上下⽂结构体,这个结构体是最上层的结构体,表示输⼊上下⽂
ic = avformat_alloc_context();

最终该ic 赋值给VideoState的ic变量。

// videoState的ic指向分配的ic
is->ic = ic; 

 

ic->interrupt_callback

/* .设置中断回调函数,如果出错或者退出,就根据⽬前程序设置的状态选择继续check或者直接退出。
当执⾏耗时操作时(⼀般是在执⾏while或者for循环的数据读取时),会调⽤interrupt_ callback.callback.
回调函数中返回1则代表ffmpeg结束耗时操作退出当前函数的调⽤。
回调函数中返回0则代表ffmpeg内部继续执⾏耗时操作,直到完成既定的任务(⽐如读取到既定 的数据包)。
*/
ic->interrupt_callback.callback = decode_interrupt_cb;ic->interrupt_callback.opaque = is;

interrupt_callback⽤于ffmpeg内部在执⾏耗时操作时,检查调⽤者是否有退出请求,避免⽤户退出请求,而没有及时响应。

可以在ubuntu环境下,通过gdb ./ffplay_g来播放视频,然后在decode_interrupt_cb打断点

从调用栈关系看,这个回调函数的触发是在avformat_open_input这个节点,正真的触发是在retry_transfer_wrApper函数ff_check_interrupt。

调用栈如下,顺序应该是从下到上:

decode_interrupt_cb。

ff_check_interrupt。

retry_transfer_wrapper

ffurl_read。

read_packet_wrapper。

fill_buffer。

avio_read。

av_probe_input_buffer2。

init_input。

avformat_open_input。

 #0 decode_interrupt_cb (ctx=0x7ffff7e36040) at fftools/ffplay.c:271 5 
 #1 0x00000000007d99b7 in ff_check_interrupt (cb=0x7fffd00014b0) at libavformat/avio.c:667 
#2 retry_transfer_wrapper (transfer_func=0x7dd950 <file_read>, size _min=1, 5 size=32768, buf=0x7fffd0001700 "", h=0x7fffd0001480) at libavformat/avio.c:374 
#3 ffurl_read (h=0x7fffd0001480, buf=0x7fffd0001700 "", size=32768)  at libavformat/avio.c:411
#4 0x000000000068cd9c in read_packet_wrapper (size=<optimized out>, 10 buf=<optimized out>, s=0x7fffd00011c0) at libavformat/aviobuf.c: 535
#5 fill_buffer (s=0x7fffd00011c0) at libavformat/aviobuf.c:584
#6 avio_read (s=s@entry=0x7fffd00011c0, buf=0x7fffd0009710 "",  size=size@entry=2048) at libavformat/aviobuf.c:677
#7 0x00000000006b7780 in av_probe_input_buffer2 (pb=0x7fffd00011c0, 15 fmt=0x7fffd0000948,filename=filename@entry=0x31d50e0 "source.200kbps.768x320.flv",
#logctx=logctx@entry=0x7fffd0000940, offset=offset@entry=0, 18 max_probe_size=1048576) at libavformat/format.c:262
#8 0x00000000007b631d in init_input (options=0x7fffdd9bcb50, 20 filename=0x31d50e0 "source.200kbps.768x320.flv", s=0x7fffd000094 0)
#at libavformat/utils.c:443
#9 avformat_open_input (ps=ps@entry=0x7fffdd9bcbf8, 23 filename=0x31d50e0 "source.200kbps.768x320.flv", fmt=<optimized out>,

 

可以看到是在libavformat/avio.c:374⾏有正真触发到。

超详细解析FFplay之数据读取线程

 

avformat_find_stream_info的触发。

read_thread

avformat_find_stream_info

decode_interrupt_cb

详细步骤如下:

#0 decode_interrupt_cb (ctx=0x7ffff7e36040) at fftools/ffplay.c:2715
#1 0x00000000007b25bc in avformat_find_stream_info (ic=0x7fffd000094 0,options=0x0) at libavformat/utils.c:3693
#2 0x00000000004a6ea9 in read_thread (arg=0x7ffff7e36040)

从该调⽤栈可以看出来 avformat_find_stream_info也会触发ic->interrupt_callback的调⽤,具体可以看代码(libavformat/utils.c:3693⾏)。

在avformat_find_stream_info函数里。

超详细解析FFplay之数据读取线程

 


超详细解析FFplay之数据读取线程

 

触发的点。

av_read_frame的触发耗时回调函数decode_interrupt_cb。基本顺序流程如下:

read_thread

av_read_frame

read_frame_internal

ff_read_packet

flv_read_packet

av_get_packet

append_packet_chunked

avio_read

fill_buffer

read_packet_wrapper

ffurl_read

retry_transfer_wrapper

ff_check_interrupt

decode_interrupt_cb

详细步骤如下:

#0 decode_interrupt_cb (ctx=0x7ffff7e36040) at fftools/ffplay.c:271 5
#1 0x00000000007d99b7 in ff_check_interrupt (cb=0x7fffd00014b0)  at libavformat/avio.c:667
#2 retry_transfer_wrapper (transfer_func=0x7dd950 <file_read>, size _min=1,  size=32768, buf=0x7fffd0009710 "FLV0105", h=0x7fffd0001480) at libavformat/avio.c:374
#3 ffurl_read (h=0x7fffd0001480, buf=0x7fffd0009710 "FLV0105", size=32768)  at libavformat/avio.c:411
#4 0x000000000068cd9c in read_packet_wrapper (size=<optimized out>,  buf=<optimized out>, s=0x7fffd00011c0) at libavformat/aviobuf.c: 535
#5 fill_buffer (s=0x7fffd00011c0) at libavformat/aviobuf.c:584
#6 avio_read (s=s@entry=0x7fffd00011c0, buf=0x7fffd00dbf6d "177", size=45,  size@entry=90) at libavformat/aviobuf.c:677
 #7 0x00000000007a99d5 in append_packet_chunked (s=0x7fffd00011c0,  pkt=pkt@entry=0x7fffdd9bca00, size=size@entry=90)  at libavformat/utils.c:293
#8 0x00000000007aa969 in av_get_packet (s=<optimized out>,  pkt=pkt@entry=0x7fffdd9bca00, size=size@entry=90)  at libavformat/utils.c:317
#9 0x00000000006b350a in flv_read_packet (s=0x7fffd0000940,  pkt=0x7fffdd9bca00) at libavformat/flvdec.c:1295
#10 0x00000000007aad6d in ff_read_packet (s=s@entry=0x7fffd0000940,  pkt=pkt@entry=0x7fffdd9bca00) at libavformat/utils.c:856  ---Type <return> to continue, or q <return> to quit---
#11 0x00000000007ae291 in read_frame_internal (s=0x7fffd0000940,  pkt=0x7fffdd9bcc00) at libavformat/utils.c:1582
#12 0x00000000007af422 in av_read_frame (s=0x7fffd0000940,  pkt=pkt@entry=0x7fffdd9bcc00) at libavformat/utils.c:1779
#13 0x00000000004a68b1 in read_thread (arg=0x7ffff7e36040)  at fftools/ffplay.c:3008

 

avformat_open_input():打开媒体⽂件。

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

avformat_open_input⽤于打开输⼊⽂件(对于RTMP/RTSP/HTTP⽹络流也是⼀样,在ffmpeg内部都抽象为URLProtocol,这⾥描述为⽂件是为了⽅便与后续提到的AVStream的流作区分),读视频⽂件的基本信息

需要提到的两个参数是fmt和options。通过fmt可以强制指定视频⽂件的封装options可以传递额外参数给封装(AVInputFormat)。

//特定选项处理 
if (!av_dict_get(format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_C ASE)) 
{  av_dict_set(&format_opts, "scan_all_pmts", "1", AV_DICT_DONT_OVE RWRITE); 
 scan_all_pmts_set = 1;  } 
 /* 3.打开⽂件,主要是探测协议类型,如果是⽹络⽂件则创建⽹络链接等 */ 
err = avformat_open_input(&ic, is->filename, is->iformat, &format_op ts); 
if (err < 0) { 
  print_error(is->filename, err); 
  ret = -1;  goto fail;  } 
 if (scan_all_pmts_set) 
   av_dict_set(&format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_C ASE); 
if ((t = av_dict_get(format_opts, "", NULL, AV_DICT_IGNORE_SUFFIX))) { 
  av_log(NULL, AV_LOG_ERROR, "Option %s not found.n", t->key); 
  ret = AVERROR_OPTION_NOT_FOUND; 
  goto fail; 
}

scan_all_pmts是mpegts的⼀个选项,表示扫描全部的ts流的"Program Map Table"表。这⾥在没有设定该选项的时候,强制设为1。最后执⾏avformat_open_input。

使⽤gdb跟踪options的设置,在av_opt_set打断点。

(gdb) b av_opt_set
(gdb) r#0 av_opt_set_dict2 (obj=obj@entry=0x7fffd0000940, options=options@entry=0x7fffdd9bcb50, search_flags=search_flags@entry=0) at libavutil/opt.c:1588
#1 0x00000000011c6837 in av_opt_set_dict (obj=obj@entry=0x7fffd0000940, options=options@entry=0x7fffdd9bcb50) at libavutil/opt.c:1605
#2 0x00000000007b5f8b in avformat_open_input (ps=ps@entry=0x7fffdd9bcbf8, filename=0x31d23d0 "source.200kbps.768x320.flv", fmt=<optimized out>, options=0x2e2d450 <format_opts>) at libavformat/utils.c:560
#3 0x00000000004a70ae in read_thread (arg=0x7ffff7e36040) at fftools/ffplay.c:2780
...... (gdb) l if (!options) 
  return 0; 
while ((t = av_dict_get(*options, "", t, AV_DICT_IGNORE_SUFFIX))) 
{   ret = av_opt_set(obj, t->key, t->value, search_flags);   if (ret == AVERROR_OPTION_NOT_FOUND) 
    ret = av_dict_set(&tmp, t->key, t->value, 0); 
  if (ret < 0) { 
    av_log(obj, AV_LOG_ERROR, "Error setting option %s to value %s.n", t->key, t->value); 
    (gdb) print **options     $3 = {count = 1, elems = 0x7fffd0001200} 
    (gdb) print (*options)->elems     $4 = (AVDictionaryEntry *) 0x7fffd0001200 
       (gdb) print *((*options)->elems)     $5 = {key = 0x7fffd0001130 "scan_all_pmts", value = 0x7fffd0001150 "1"} 
    (gdb)

参数的设置最终都是设置到对应的解复⽤器,⽐如:

mpegts.c,如下:

超详细解析FFplay之数据读取线程

 

flvdec.c,如下:

超详细解析FFplay之数据读取线程

 

avformat_find_stream_info()

在打开了⽂件后,就可以从AVFormatContext中读取流信息了。⼀般调⽤avformat_find_stream_info获取完整的流信息。为什么在调⽤了avformat_open_input后,仍然需要调⽤avformat_find_stream_info才能获取正确的流信息呢?看下注释:

/** * Read packets of a media file to get stream information. 
This * is useful for file formats with no headers such as MPEG. 
This * function also computes the real framerate in case of MPEG-2 repeat * frame mode. 
* The logical file position is not changed by this function; 
* examined packets may be buffered for later processing. 
** @param ic media file handle * @param options If non-NULL, an ic.nb_streams long array of pointers to 
* dictionaries, where i-th member contains options for
* codec corresponding to i-th stream. 
* On return each dictionary will be filled with options that were not found. 
* @return >=0 if OK, AVERROR_xxx on error 
** @note this function isn't guaranteed to open all the codecs, so
* options being non-empty at return is a perfectly normal behavior.
** @todo Let the user decide somehow what information is needed so that
* we do not waste time getting stuff the user does not need. 
*/
int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);

该函数是通过读取媒体⽂件的部分数据来分析流信息。在⼀些缺少头信息的封装下特别有⽤,⽐如说MPEG(这⾥应该说ts更准确)(FLV⽂件也是需要读取packet 分析流信息)。⽽被读取⽤以分析流信息的数据可能被缓存,供av_read_frame时使⽤,在播放时不会跳过这部分packet的读取

 

检测是否指定播放起始时间

如果指定时间则seek到指定位置avformat_seek_file。可以通过 ffplay -ss 设置起始时间,时间格式hh:mm:ss,⽐如ffplay -ss 00:00:30 test.flv 则是从30秒的起始位置开始播放。

具体调⽤流程,可以在av_opt_seek 函数打断点进⾏测试。

{ "ss", HAS_ARG, { .func_arg = opt_seek }, "seek to a given position in seconds", "pos" },
  { "t", HAS_ARG, { .func_arg = opt_duration }, "play "duration" sec onds of audio/video", "duration" },
/* if seeking requested, we execute it */ 
 /* 5. 检测是否指定播放起始时间 */ 
 if (start_time != AV_NOPTS_VALUE) {
    int64_t timestamp;    timestamp = start_time;    /* add the stream start time */ 
   if (ic->start_time != AV_NOPTS_VALUE) 
     timestamp += ic->start_time;    // seek的指定的位置开始播放 
   ret = avformat_seek_file(ic, -1, INT64_MIN, timestamp, INT64_MAX , 0); 
   if (ret < 0) { 
     av_log(NULL, AV_LOG_WARNING, "%s: could not seek to position %0.3fn", 
                            is->filename, (double)timestamp / AV_TIME_BASE); 
                } 
 }

 

2路音频,一路国语语音,一路粤语语音。

超详细解析FFplay之数据读取线程

 

设置ast,可以选择是哪一路音频播放。在这里去配置。比如这里指定0,就是播放粤语。指定1,就是国语了。

超详细解析FFplay之数据读取线程

 

这些是视频和音频参数。

超详细解析FFplay之数据读取线程

 

具体现在那个流进⾏播放我们有两种策略:

在播放起始指定对应的流。

ffplay是通过通过命令可以指定流。使用ast是指定音频流,使用vst是指定视频流,使用sst是指定字幕流。

{ "ast", OPT_STRING | HAS_ARG | OPT_EXPERT, { &wanted_stream_spec[AVMEDIA_TYPE_AUDIO] }, "select desired audio stream", "stream_specifier" }, 
  { "vst", OPT_STRING | HAS_ARG | OPT_EXPERT, { &wanted_stream_spec[AVMEDIA_TYPE_VIDEO] }, "select desired video stream", "stream_specifier" },
    { "sst", OPT_STRING | HAS_ARG | OPT_EXPERT, { &wanted_stream_spec[AVMEDIA_TYPE_SUBTITLE] }, "select desired subtitle stream", "stream_specifier" },

可以通过如下选择:

-ast n 指定⾳频流(⽐如我们在看电影时,有些电影可以⽀持普通话和英⽂切换,此时可以⽤该命令进⾏选择)。

-vst n 指定视频流。

-vst n 指定字幕流。

把对应的index值记录到st_index[AVMEDIA_TYPE_NB];。

使⽤缺省的流进⾏播放。

如果我们没有指定,则ffplay主要是通过 av_find_best_stream 来选择,自动去匹配,其原型为:

/** 
* Find the "best" stream in the file. 
* The best stream is determined according to various heuristics as the most 
* likely to be what the user expects. 
* If the decoder parameter is non-NULL, av_find_best_stream will fi nd the 
* default decoder for the stream's codec; 
streams for which no deco der can 
* be found are ignored. 
* * @param ic media file handle 
* @param type stream type: video, audio, subtitles, et c. 
* @param wanted_stream_nb user-requested stream number, 
* or -1 for automatic selection 
* @param related_stream try to find a stream related (eg. in the same 
* program) to this one, or -1 if none 
* @param decoder_ret if non-NULL, returns the decoder for the
* selected stream 
* @param flags flags; none are currently defined
* @return the non-negative stream number in case of success, 
* AVERROR_STREAM_NOT_FOUND if no stream with the requested type 
* could be found, 21 * AVERROR_DECODER_NOT_FOUND if streams were found but no d ecoder
* @note If av_find_best_stream returns successfully and decoder_re t is not 
* NULL, then *decoder_ret is guaranteed to be set to a valid AVCodec. 
*/
int av_find_best_stream(AVFormatContext *ic, 
                        enum AVMediaType type, //要选择的流类型 
                        int wanted_stream_nb, //⽬标流索引 
                        int related_stream, //相关流索引
                        AVCodec **decoder_ret,
                        int flags);

具体代码流程如下:

如果是根据用户指定来查找流。使用了正确的wanted_stream_nb,⼀般情况都是直接返回该指定流,即⽤户选择的流

 for (i = 0; i < ic->nb_streams; i++)
 {    AVStream *st = ic->streams[i]; 
   enum AVMediaType type = st->codecpar->codec_type; 
   st->discard = AVDISCARD_ALL; 
   if (type >= 0 && wanted_stream_spec[type] && st_index[type] == - 1) 
     if (avformat_match_stream_specifier(ic, st, wanted_stream_sp ec[type]) > 0) 
     st_index[type] = i;  } for (i = 0; i < AVMEDIA_TYPE_NB; i++) {
  if (wanted_stream_spec[i] && st_index[i] == -1) { 
    av_log(NULL, AV_LOG_ERROR, "Stream specifier %s does not mat ch any %s streamn", wanted_stream_spec[i], av_get_media_type_string (i));
    st_index[i] = INT_MAX;   } }

使用av_find_best_stream选择流。如果⽤户没有指定流,或指定部分流,或指定流不存在,则主要由av_find_best_stream发挥作⽤。

if (!video_disable)
  st_index[AVMEDIA_TYPE_VIDEO] =
    av_find_best_stream(ic, AVMEDIA_TYPE_VIDEO, 
                        st_index[AVMEDIA_TYPE_VIDEO], -1, NULL, 0); 
if (!audio_disable) 
  st_index[AVMEDIA_TYPE_AUDIO] =  av_find_best_stream(ic, AVMEDIA_TYPE_AUDIO,
                                                      st_index[AVMEDIA_TYPE_AUDIO], 
                                                      st_index[AVMEDIA_TYPE_VIDEO], 
                                                      NULL, 0); 
if (!video_disable && !subtitle_disable)
  st_index[AVMEDIA_TYPE_SUBTITLE] = 
    av_find_best_stream(ic, AVMEDIA_TYPE_SUBTITLE,
                        st_index[AVMEDIA_TYPE_SUBTITLE],
                        (st_index[AVMEDIA_TYPE_AUDIO] >= 0 ? 
                         st_index[AVMEDIA_TYPE_AUDIO] : 
                         st_index[AVMEDIA_TYPE_VIDEO]), 
                        NULL, 0);

如果指定了相关流,且未指定⽬标流的情况,会在相关流的同⼀个节⽬中查找所需类型的流,但⼀般结果,都是返回该类型第1个流

通过AVCodecParameters和av_guess_sample_aspect_ratio计算出显示窗⼝的宽、⾼。从待处理流中获取相关参数,设置显示窗⼝的宽度、⾼度及宽⾼⽐。由于帧宽⾼⽐由解码器设置,但流宽⾼⽐由解复⽤器设置,因此这两者可能不相等。基本逻辑是优先使⽤流宽⾼⽐(前提是值是合理的),其次使⽤帧宽⾼⽐,这样,流宽⾼⽐(容器设置,易于修改)可以覆盖帧宽⾼⽐。

if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) { 
   AVStream *st = ic->streams[st_index[AVMEDIA_TYPE_VIDEO]];
  AVCodecParameters *codecpar = st->codecpar;
//  根据流和帧宽⾼⽐猜测帧的样本宽⾼⽐。
  //此函数会尝试返回待显示帧应当使⽤的宽⾼⽐值。
  AVRational sar = av_guess_sample_aspect_ratio(ic, st, NULL);
  if (codecpar->width) { 
    // 设置显示窗⼝的⼤⼩和宽⾼⽐ 
    set_default_window_size(codecpar->width, codecpar->heigh t, sar); 
  } 
}

具体流程如上所示,这⾥实质只是设置了default_width、default_height变量的⼤⼩,没有真正改变窗⼝的⼤⼩。真正调整窗⼝⼤⼩是在视频显示调⽤video_open()函数进⾏设置

 

stream_component_open()

经过以上步骤,⽂件打开成功,且获取了流的基本信息,并选择⾳频流、视频流、字幕流。接下来就可以所选流对应的解码器了

 /* open the streams */
/* 5.打开视频、⾳频解码器。在此会打开相应解码器,并创建相应的解码线程。 */ 
if (st_index[AVMEDIA_TYPE_AUDIO] >= 0) {
  stream_component_open(is, st_index[AVMEDIA_TYPE_AUDIO]); 
} ret = -1; 
if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) {
  ret = stream_component_open(is, st_index[AVMEDIA_TYPE_VIDEO] );  } 
if (is->show_mode == SHOW_MODE_NONE) {
  //选择怎么显示,如果视频打开成功,就显示视频画⾯,否则,显示⾳频对应的频谱图
  is->show_mode = ret >= 0 ? SHOW_MODE_VIDEO : SHOW_MODE_RDFT; 
} 
if (st_index[AVMEDIA_TYPE_SUBTITLE] >= 0) { 
 stream_component_open(is, st_index[AVMEDIA_TYPE_SUBTITLE]); 
}

⾳频、视频、字幕等流都要⽤stream_component_open,他们直接有共同的流程,也有差异化的流程,差异化流程使⽤switch进⾏区分。

 

int stream_component_open(VideoState *is, int stream_index);

stream_component_open这个函数比较长,逐步分析。

 /* 为解码器分配⼀个编解码器上下⽂结构体 */
avctx = avcodec_alloc_context3(NULL);
if (!avctx) 
  return AVERROR(ENOMEM);
/* 将码流中的编解码器信息拷⻉到新分配的编解码器上下⽂结构体 */
ret = avcodec_parameters_to_context(avctx, ic->streams[stream_in dex]->codecpar);if (ret < 0) 
  goto fail;
// 设置pkt_timebase
avctx->pkt_timebase = ic->streams[stream_index]->time_base;

总结来说就是,先通过 avcodec_alloc_context3 分配了解码器上下⽂ AVCodecContex ,然后通过avcodec_parameters_to_context 把所选流的解码参数赋给 avctx ,最后设了 time_base。

注意,还有一个函数,有这样一个区别。

avcodec_parameters_to_context 解码时⽤,avcodec_parameters_from_context则⽤于编码

/* 根据codec_id查找解码器 */
codec = avcodec_find_decoder(avctx->codec_id);// 获取指定的解码器名字,如果没有设置则为NULLswitch(avctx->codec_type){    case AVMEDIA_TYPE_AUDIO : 
    is->last_audio_stream = strea m_index;    // 获取指 定的解码器名字    forced_codec_name = audio_codec_name;     break;
    case AVMEDIA_TYPE_SUBTITLE: 
    // 获取指 定的解码器名字    is->last_subtitle_stream = strea m_index;     forced_codec_name = subtitle_codec_name;    break;
    case AVMEDIA_TYPE_VIDEO : 
    is->last_video_stream = strea m_index;     // 获取指 定的解码器名字    forced_codec_name = video_codec_name;    break;
}}//如果名字找到了,根据名字去找对应的解码器if (forced_codec_name) 
  codec = avcodec_find_decoder_by_name(forced_codec_name);if (!codec) {
  if (forced_codec_name) 
    av_log(NULL, AV_LOG_WARNING,  "No codec could be found with name '%s'n", forced_codec_name);
  else
    av_log(NULL, AV_LOG_WARNING,"No decoder could be found for codec %sn", avcodec_get_name(avctx->codec_id));
  ret = AVERROR(EINVAL);  goto fail;}

总结来说,这段主要是通过 avcodec_find_decoder 找到所需解码器(AVCodec)。如果⽤户有指定解码器,则设置 forced_codec_name ,并通过 avcodec_find_decoder_by_name 查找解码器。找到解码器后(如果用户没有指定解码器,就需要通过寻找,然后匹配,所以一般在代码中,都是要去匹配),就可以通过 avcodec_open2 打开解码器了。forced_codec_name对应到⾳频、视频、字幕不同的传⼊的解码器名字,如果有设置,⽐如ffplay -acodec aac xx.flv, 此时audio_codec_name被设置为"aac"(音频解码器名字),则相应的forced_codec_name为“aac”。

最后,一个大的switch语句,去启动不同的解码线程。

switch (avctx->codec_type) { 
  case AVMEDIA_TYPE_AUDIO: 
    sample_rate = avctx->sample_rate;     nb_channels = avctx->channels;     channel_layout = avctx->channel_layout;    /* prepare audio output 准备⾳频输出*/
    if ((ret = audio_open(is, channel_layout, nb_channels, sampl e_rate, &is->audio_tgt)) < 0)
      goto fail;
    is->audio_hw_buf_size = ret;    is->audio_src = is->audio_tgt;    is->audio_buf_size = 0;
    is->audio_buf_index = 0;
        /* init averaging filter 初始化averaging滤镜, ⾮audio master时 使⽤ */
    is->audio_diff_avg_coef = exp(log(0.01) / AUDIO_DIFF_AVG_NB );
    is->audio_diff_avg_count = 0;
    /* 由于我们没有精确的⾳频数据填充FIFO,故只有在⼤于该阈值时才进⾏校正⾳频 同步*/
    is->audio_diff_threshold = (double)(is->audio_hw_buf_size) / is->audio_tgt.bytes_per_sec;    // 获取audio的stream索引
    is->audio_stream = stream_index;
    // 获取audio的stream指针
    is->audio_st = ic->streams[stream_index];
    
    // 初始化ffplay封装的⾳频解码器
    decoder_init(&is->auddec, avctx, &is->audioq, is->continue_r ead_thread);
    
    if ((is->ic->iformat->flags & (AVFMT_NOBINSEARCH | AVFMT_NOG ENSEARCH 
                                   | AVFMT_NO_BYTE_SEEK)) && !is->ic->iformat->read_seek)
      { 26 is->auddec.start_pts = is->audio_st->start_time; 27 is->auddec.start_pts_tb = is->audio_st->time_base; 28 }

总结来说,即根据具体的流类型,作特定的初始化。但不论哪种流,基本步骤都包括了ffplay封装的解码器的初始化和启动解码器线程

decoder_init 初始化解码器

//绑定对应的解码器上下⽂

d->avctx = avctx;

//绑定对应的packet队列

d->queue = queue;

//设置条件变量

//绑定VideoState的continue_read_thread,当解码线程没有packet可读时唤醒read_thread赶紧读取数据。

d->empty_queue_cond = empty_queue_cond;

//初始化start_pts

d->start_pts = AV_NOPTS_VALUE;

//初始化pkt_serial

d->pkt_serial = -1;

 

decoder_start 启动解码器

//启⽤对应的packet 队列

packet_queue_start

//创建对应的解码线程

SDL_CreateThread

注意:需要注意的是,对应⾳频⽽⾔,这⾥还初始化了输出参数,这块在讲⾳频输出的时候再重点展开。

 

For循环读取数据

主要包括以下步骤:

(1)检测是否退出

 if (is->abort_request)
   break;

当退出事件发⽣时,调⽤do_exit() -> stream_close() -> 将is->abort_request置为1。退出该for循环,并最终退出该线程。

(2)检测是否暂停/继续。

这⾥的暂停、继续只是对⽹络流有意义。⽐如rtsp,av_read_pause。

// 2 检测是否暂停/继续 
if (is->paused != is->last_paused) { 
  is->last_paused = is->paused;   if (is->paused) 
    is->read_pause_return = av_read_pause(ic);  // ⽹络流的时候有⽤
  else 
    av_read_play(ic); }
/* pause the stream */ 
 static int rtsp_read_pause(AVFormatContext *s) 
 { RTSPState *rt = s->priv_data; 5 RTSPMessageHeader reply1, *reply = &reply1;
    if (rt->state != RTSP_STATE_STREAMING) 
      return 0;
  else if (!(rt->server_type == RTSP_SERVER_REAL && rt->need_subsc ription)) {
    //发送给服务器命令
    ff_rtsp_send_cmd(s, "PAUSE", rt->control_uri, NULL, reply, N ULL);
    if (reply->status_code != RTSP_STATUS_OK) { 
       return ff_rtsp_averror(reply->status_code, -1); 
    } 
  }
  rt->state = RTSP_STATE_PAUSED;
  return 0;
 }

av_read_play。

static int rtsp_read_play(AVFormatContext *s) 
 { 
   RTSPState *rt = s->priv_data;    RTSPMessageHeader reply1, *reply = &reply1;     ......     //发送给服务器
    ff_rtsp_send_cmd(s, "PLAY", rt->control_uri, cmd, reply, NULL); 
    .... 
    rt->state = RTSP_STATE_STREAMING;
   return 0; 
 }

(3)检测是否需要seek。

if (is->seek_req) {
  // 是否有seek请求 
   int64_t seek_target = is->seek_pos; 
  int64_t seek_min = is->seek_rel > 0 ? seek_target - is->seek_ rel + 2: INT64_MIN; 
  int64_t seek_max = is->seek_rel < 0 ? seek_target - is->seek_ rel - 2: INT64_MAX; 
   // FIXME the +-2 is due to rounding being not done in the correc t direction in generation
  // of the seek_pos/seek_rel variables
  // 修复由于四舍五⼊,没有再seek_pos/seek_rel变量的正确⽅向上进⾏
  ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek _max, is->seek_flags);
  if (ret < 0) {
     av_log(NULL, AV_LOG_ERROR, 12 "%s: error while seekingn", is->ic->url); 
  } else {
    /* seek的时候,要把原先的数据情况,并重启解码器(释放缓存),put flush_pkt的⽬的是 告知解码线程需要
     * reset decoder  */     // 如果有⾳频流 
    if (is->audio_stream >= 0) {
      // 清空packet队列数据 
       packet_queue_flush(&is->audioq); 
       // 放⼊flush pkt, ⽤来开起新的⼀个播放序列, 解码器读取到flush_pk t也清空解码器 
      packet_queue_put(&is->audioq, &flush_pkt); 
     }     // 如果有字幕流
    if (is->subtitle_stream >= 0) {
      // 同上作用
      packet_queue_flush(&is->subtitleq); 
      packet_queue_put(&is->subtitleq, &flush_pkt); 
    }    // 如果有视频流
    if (is->video_stream >= 0) { 
      // 同上作用
        packet_queue_flush(&is->videoq); 
      packet_queue_put(&is->videoq, &flush_pkt); 
    }    //按字节还是按时间,设置时钟。
    if (is->seek_flags & AVSEEK_FLAG_BYTE) { 
       //按字节
      set_clock(&is->extclk, NAN, 0); 
       } else { 
         //按时钟
         set_clock(&is->extclk, seek_target / (double)AV_TIME_BAS E, 0); 
         }      }    is->seek_req = 0;
  is->queue_attachments_req = 1;
  is->eof = 0;
  if (is->paused)
    // 如果本身是pause状态的则显示⼀帧(seek到那里的帧)继续暂停
    step_to_next_frame(is);
}

主要的seek操作通过avformat_seek_file完成(该函数的具体使⽤在播放控制seek时做详解)。根据avformat_seek_file的返回值,如果seek成功,需要做到以下2点:

清除PacketQueue的缓存,并放⼊⼀个flush_pkt。放⼊的flush_pkt可以PacketQueue的serial增1,以区分seek前后的数据(PacketQueue函数的分析0),该flush_pkt也会触发解码器重新刷新解码器缓存,avcodec_flush_buffers(),以避免解码时使⽤了原来的buffer作为参考出现⻢赛克。

(4)检测video是否为attached_pic。封面也相当于是一个Video Stream,相当于一个AVPacket。AV_DISPOSITION_ATTACHED_PIC 是⼀个标志。如果⼀个流中含有这个标志的话,那么就是说这个流是 *.mp3等 ⽂件中的⼀个 Video Stream 。并且该流只有⼀个 AVPacket ,也就是attached_pic 。这个 AVPacket 中所存储的内容就是这个 *.mp3等 ⽂件的封⾯图⽚。因此,也可以很好的解释了⽂章开头提到的为什么 st->disposition & AV_DISPOSITION_ATTACHED_PIC,这个操作可以决定是否可以继续向缓冲区中添加 AVPacket 。

// 4 检测video是否为attached_pic 
 if (is->queue_attachments_req) { 
    // attached_pic 附带的图⽚。⽐如说⼀些MP3,AAC⾳频⽂件附带的专辑封⾯,所以 需要注意的是⾳频⽂件不⼀定只存在⾳频流本身 
    if (is->video_st && is->video_st->disposition & AV_DISPOSITION_A TTACHED_PIC) { 
      AVPacket copy = { 0 }; 
      if ((ret = av_packet_ref(©, &is->video_st->attached_pic) ) < 0) 
        goto fail;       packet_queue_put(&is->videoq, ©); 
      packet_queue_put_nullpacket(&is->videoq, is->video_stream); 
    }    is->queue_attachments_req = 0; 
 }

 

(5)检测队列是否已经有⾜够数据。

⾳频、视频、字幕队列都不是⽆限⼤的,如果不加以限制⼀直往队列放⼊packet,那将导致队列占⽤⼤量的内存空间,影响系统的性能,所以必须对队列的缓存⼤⼩进⾏控制。PacketQueue默认情况下会有⼤⼩限制,达到这个⼤⼩后,就需要等待10ms,以让消费者——解码线程能有时间消耗。

// 检测队列是否已经有⾜够数据 
 /* if the queue are full, no need to read more */ 
 /* 缓存队列有⾜够的包,不需要继续读取数据 */ 
// 缓冲区不是⽆限⼤
 if (infinite_buffer<1 &&
  (is->audioq.size + is->videoq.size + is->subtitleq.size > MAX_QU EUE_SIZE 
   || (stream_has_enough_packets(is->audio_st, is->audio_st ream, &is->audioq) && 
     stream_has_enough_packets(is->video_st, is->video_st ream, &is->videoq) && 
     stream_has_enough_packets(is->subtitle_st, is->subti tle_stream, &is->subtitleq)))) 
 { 
   /* wait 10 ms */
    SDL_LockMutex(wait_mutex); 
   // 如果没有唤醒则超时10ms退出,⽐如在seek操作时这⾥会被唤醒 
   SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); 
   SDL_UnlockMutex(wait_mutex); 
   continue; 
 }

缓冲区满有两种可能:

第一,audioq,videoq,subtitleq三个PacketQueue的总字节数达到了MAX_QUEUE_SIZE(15M,为什么是15M?这⾥只是⼀个经验计算值,⽐如4K视频的码率以50Mbps计算,则15MB可以缓存2.4秒,从这么计算实际上如果我们真的是播放4K⽚源,15MB是偏⼩的数值,有些⽚源⽐较坑 同⼀个⽂件位置附近的pts差值超过5秒,此时如果视频要缓存5秒才能做同步,那15MB的缓存⼤⼩就不够了)。所以要根据实际的情况进行调整。

第二,等待10ms的条件是,⾳频、视频、字幕流都已有够⽤的包(stream_has_enough_packets),三者要同时成立才行。

第⼀种好理解,看下第⼆种中的stream_has_enough_packets。这是好多种情况的组合。

static int stream_has_enough_packets(AVStream *st, int stream_id, Pac ketQueue *queue)
{  return stream_id < 0 || // 没有该流
    queue->abort_request || // 请求退出 
    (st->disposition & AV_DISPOSITION_ATTACHED_PIC) || // 是ATTACHED_PIC
    queue->nb_packets > MIN_FRAMES // packet数>25
  && (!queue->duration || // 满⾜PacketQueue总时⻓为0
      av_q2d(st->time_base) * queue->duration > 1.0);//或总时⻓超过1s
}

有这么⼏种情况包是够⽤的,也就是说可以足够去用,不用担心空包问题:

第一,流没有打开(stream_id < 0),没有相应的流返回逻辑true。

第二,有退出请求(queue->abort_request)。

第三,配置了AV_DISPOSITION_ATTACHED_PIC。

第四,packet队列内包个数⼤于MIN_FRAMES(>25),并满⾜PacketQueue总时⻓为0或总时⻓超过1s。

包是否足够思路:

第一,看总数据⼤⼩

第二,每个packet队列的情况。

 

(6)检测码流是否已经播放结束。

是否循环播放。

是否⾃动退出。

⾮暂停状态才进⼀步检测码流是否已经播放完毕,注意:数据播放完毕和码流数据读取完毕是两个概念。只有,PacketQueue和FrameQueue都消耗完毕,才是真正的播放完毕。

//  检测码流是否已经播放结束
 if (!is->paused  // ⾮暂停 
     && // 这⾥的执⾏是因为码流读取完毕后 插⼊空包所致
      (!is->audio_st // 没有⾳频流
       || (is->auddec.finished == is->audioq.serial // 或者⾳频播放完毕
           && frame_queue_nb_remaining(&is->sampq) == 0))
     && (!is->video_st // 没有视频流
         || (is->viddec.finished == is->videoq.serial // 或者视频播放完毕
             && frame_queue_nb_remaining(&is->pictq) == 0))) {
   if (loop != 1 // a 是否循环播放 
        && (!loop || --loop)) {
     //循环播放,自动从头开始
      stream_seek(is, start_time != AV_NOPTS_VALUE ? start_time : 0, 0, 0);
     } else if (autoexit) { // b 是否⾃动退出 
       ret = AVERROR_EOF;        goto fail;      }  }

这⾥判断播放已完成的条件需要同时满⾜这几个条件:

第一,不在暂停状态

第二,⾳频未打开;或者打开了,但是解码已解完所有packet,⾃定义的解码器(decoder)serial等于PacketQueue的serial,并且FrameQueue中没有数据帧,音频数据已经消耗完毕。

PacketQueue.serial -> packet.serail -> decoder.pkt_serial

decoder.finished = decoder.pkt_serial

is->auddec.finished == is->audioq.serial 最新的播放序列的packet都解码完毕。

frame_queue_nb_remaining(&is->sampq) == 0 对应解码后的数据也播放完毕。

第三,视频未打开;或者打开了,但是解码已解完所有packet,⾃定义的解码器(decoder)serial等于PacketQueue的serial,并且FrameQueue中没有数据帧。视频数据已经播放完毕。

在确认⽬前码流已播放结束的情况下,⽤户有两个变量可以控制播放器⾏为:

第一,loop: 控制播放次数(当前这次也算在内,也就是最⼩就是1次了),0表示⽆限次。

第二,autoexit:⾃动退出,也就是播放完成后⾃动退出。就是不用自动循环。

loop条件简化的⾮常不友好,其意思是:如果loop==1,那么已经播了1次了,⽆需再seek重新播放。如果loop不是1,==0,随意,⽆限次循环;减1后还⼤于0(--loop),也允许循环。也就是每次播放完毕后,循环的次数就要减1。

是否循环播放

如果循环播放,即是将⽂件seek到起始位置 stream_seek(is, start_time != AV_NOPTS_VALUE ?start_time : 0, 0, 0); 注意,这⾥讲的的起始位置不⼀定是从头开始,具体也要看⽤户是否指定了起始播放位置。

是否⾃动退出。

如果播放完毕⾃动退出。

(7)使⽤av_read_frame读取数据包。

读取数据包很简单,但要注意传⼊的packet,av_read_frame不会释放其数据,⽽是每次都重新申请数据。注意,内存泄漏问题。

//读取媒体数据,得到的是⾳视频分离后、解码前的数据
// 调⽤不会释放pkt的数据,都是要⾃⼰去释放
 ret = av_read_frame(ic, pkt); 

(8)检测数据是否读取完毕。

if (ret < 0) {
   if ((ret == AVERROR_EOF || avio_feof(ic->pb)) 
         && !is->eof) 
     {       // 插⼊空包说明码流数据读取完毕了,之前讲解码的时候说过刷空包是为了从解码 器把所有帧都读出来
       if (is->video_stream >= 0)
         //插入视频空包
         packet_queue_put_nullpacket(&is->videoq, is->video_strea m);
       if (is->audio_stream >= 0)
         //插入音频空包
         packet_queue_put_nullpacket(&is->audioq, is->audio_strea m);
       if (is->subtitle_stream >= 0)
         //插入字幕空包
         packet_queue_put_nullpacket(&is->subtitleq, is->subtitle _stream);
       is->eof = 1;// ⽂件读取完毕
       }  if (ic->pb && ic->pb->error) 
     break;
  SDL_LockMutex(wait_mutex);  //等待超时时间,运行读取线程。
  SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10);
  SDL_UnlockMutex(wait_mutex);  // 继续循环 保证线程的运⾏,⽐如要seek到某个位置播放可以继 续响应
  continue;
  } else { 
    is->eof = 0; 
  }

数据读取完毕后,放对应⾳频、视频、字幕队列插⼊“空包”,以通知解码器冲刷buffer,将缓存的所有数据都解出来frame并取出来。然后继续在for{}循环,直到收到退出命令,或者loop播放seek等操作

(9)检测是否在播放范围内。

播放器可以设置:-ss 起始位置,以及 -t 播放时⻓。

//  检测是否在播放范围内 
 /* check if packet is in play range specified by user, then queue, o therwise discard */ 
// 获取流的起始时间
 stream_start_time = ic->streams[pkt->stream_index]->start_time; 
// 获取pack et的时间戳 
 pkt_ts = pkt->pts == AV_NOPTS_VALUE ? pkt->dts : pkt->pts;
 // 这⾥的duration是在命令⾏时⽤来指定播放⻓度 
 pkt_in_play_range = duration == AV_NOPTS_VALUE ||
   (pkt_ts - (stream_start_time != AV_NOPTS_VALUE ? stream_start_ti me : 0)) *
   av_q2d(ic->streams[pkt->stream_index]->time_base) - 
     (double)(start_time != AV_NOPTS_VALUE ? start_time : 0) / 100000 0 
 <= ((double)duration / 1000000);

从流获取的参数说明,如下:

第一,stream_start_time,是从当前流AVStream->start_time获取到的时间,如果没有定义具体的值则默认为AV_NOPTS_VALUE,即该值是⽆效的;那stream_start_time有意义的就是0值。也就是说,默认是从0开始。

第二,pkt_ts,当前packet的时间戳,pts有效就⽤pts的,pts⽆效就⽤dts。

第三,duration,使⽤"-t value"指定的播放时⻓,默认值AV_NOPTS_VALUE,即该值⽆效不⽤参考。

第四,start_time,使⽤“-ss value”指定播放的起始位置,默认AV_NOPTS_VALUE,即该值⽆效不⽤参考。

pkt_in_play_range的值为0或1。

没有指定duration播放时⻓时,很显然duration == AV_NOPTS_VALUE的逻辑值为1,所以pkt_in_play_range为1。

当duration被指定(-t value)且有效时,主要判断。

(pkt_ts - (stream_start_time != AV_NOPTS_VALUE ? stream_start_time : 0)) *
   av_q2d(ic->streams[pkt->stream_index]->time_base) - 
   (double)(start_time != AV_NOPTS_VALUE ? start_time : 0) / 1000000 
 <= ((double)duration / 1000000);

实质就是当前时间戳 pkt_ts - start_time 是否 < duration,这⾥分为:

stream_start_time是否有效:有效就⽤实际值,⽆效就是从0开始。start_time 是否有效,有效就⽤实际值,⽆效就是从0开始。即是pkt_ts - stream_start_time - start_time < duration。设置了就有有效值,没设置就默认从0开始。

(10)将数据插⼊对应的队列。

 //  将⾳视频数据分别送⼊相应的queue中
 if (pkt->stream_index == is->audio_stream && pkt_in_play_range) { 
   //插入到音频packet队列
   packet_queue_put(&is->audioq, pkt); 
   } else if (pkt->stream_index == is->video_stream && pkt_in_play_rang e
                && !(is->video_st->disposition & AV_DISPOSITION_ATTACHED _PIC)) { 
    //printf("pkt pts:%ld, dts:%ldn", pkt->pts, pkt->dts); 
     //插入到视频packet队列
     packet_queue_put(&is->videoq, pkt);
    } else if (pkt->stream_index == is->subtitle_stream && pkt_in_play_r ange) { 
      //插入到字幕packet队列
      packet_queue_put(&is->subtitleq, pkt); 
       } else {
         av_packet_unref(pkt);// 不⼊队列则直接释放数据 
       }

(3)退出线程处理

主要包括以下步骤:

第一,解复⽤器有打开则关闭avformat_close_input。

第二,调⽤SDL_PushEvent发送退出事件FF_QUIT_EVENT,发送的FF_QUIT_EVENT退出播放事件由event_loop()函数相应,收到FF_QUIT_EVENT后调⽤do_exit()做退出操作。

第三,消耗互斥量wait_mutex。

解码如下视频格式和参数。

超详细解析FFplay之数据读取线程

 

可以看出来CPU占有率,还是挺低的,大概就在6%左右。效果不错。

超详细解析FFplay之数据读取线程

 

 

本篇文章就分享到这里,欢迎关注,点赞,转发,收藏。也欢迎私信讨论技术问题。



Tags:FFplay   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
本文还是着重分析数据读取线程。还是先上ffplay"藏宝图"。 从ffplay框架分析我们可以看到,ffplay有专⻔的线程read_thread()读取数据,且在调⽤av_read_frame读取数据包之前...【详细内容】
2020-09-15  Tags: FFplay  点击:(129)  评论:(0)  加入收藏
1.框架分析 ffplay.c是FFmpeg源码⾃带的播放器,调⽤FFmpeg和SDL API实现⼀个⾮常有⽤的播放器。例如哔哩哔哩著名开源项⽬ijkplayer也是基于ffplay.c进⾏⼆次开发。ffplay实...【详细内容】
2020-09-09  Tags: FFplay  点击:(99)  评论:(0)  加入收藏
▌简易百科推荐
近日只是为了想尽办法为 Flask 实现 Swagger UI 文档功能,基本上要让 Flask 配合 Flasgger, 所以写了篇 Flask 应用集成 Swagger UI 。然而不断的 Google 过程中偶然间发现了...【详细内容】
2021-12-23  Python阿杰    Tags:FastAPI   点击:(6)  评论:(0)  加入收藏
文章目录1、Quartz1.1 引入依赖<dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.2</version></dependency>...【详细内容】
2021-12-22  java老人头    Tags:框架   点击:(11)  评论:(0)  加入收藏
今天来梳理下 Spring 的整体脉络啦,为后面的文章做个铺垫~后面几篇文章应该会讲讲这些内容啦 Spring AOP 插件 (了好久都忘了 ) 分享下 4ye 在项目中利用 AOP + MybatisPlus 对...【详细内容】
2021-12-07  Java4ye    Tags:Spring   点击:(14)  评论:(0)  加入收藏
&emsp;前面通过入门案例介绍,我们发现在SpringSecurity中如果我们没有使用自定义的登录界面,那么SpringSecurity会给我们提供一个系统登录界面。但真实项目中我们一般都会使用...【详细内容】
2021-12-06  波哥带你学Java    Tags:SpringSecurity   点击:(18)  评论:(0)  加入收藏
React 简介 React 基本使用<div id="test"></div><script type="text/javascript" src="../js/react.development.js"></script><script type="text/javascript" src="../js...【详细内容】
2021-11-30  清闲的帆船先生    Tags:框架   点击:(19)  评论:(0)  加入收藏
流水线(Pipeline)是把一个重复的过程分解为若干个子过程,使每个子过程与其他子过程并行进行的技术。本文主要介绍了诞生于云原生时代的流水线框架 Argo。 什么是流水线?在计算机...【详细内容】
2021-11-30  叼着猫的鱼    Tags:框架   点击:(21)  评论:(0)  加入收藏
TKinterThinter 是标准的python包,你可以在linx,macos,windows上使用它,你不需要安装它,因为它是python自带的扩展包。 它采用TCL的控制接口,你可以非常方便地写出图形界面,如...【详细内容】
2021-11-30    梦回故里归来  Tags:框架   点击:(26)  评论:(0)  加入收藏
前言项目中的配置文件会有密码的存在,例如数据库的密码、邮箱的密码、FTP的密码等。配置的密码以明文的方式暴露,并不是一种安全的方式,特别是大型项目的生产环境中,因为配置文...【详细内容】
2021-11-17  充满元气的java爱好者  博客园  Tags:SpringBoot   点击:(25)  评论:(0)  加入收藏
一、搭建环境1、创建数据库表和表结构create table account(id INT identity(1,1) primary key,name varchar(20),[money] DECIMAL2、创建maven的工程SSM,在pom.xml文件引入...【详细内容】
2021-11-11  AT小白在线中  搜狐号  Tags:开发框架   点击:(29)  评论:(0)  加入收藏
SpringBoot开发的物联网通信平台系统项目功能模块 功能 说明 MQTT 1.SSL支持 2.集群化部署时暂不支持retain&will类型消 UDP ...【详细内容】
2021-11-05  小程序建站    Tags:SpringBoot   点击:(55)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条