ffplay自定义系列

第一章 自定义播放器接口(本章)
第二章 倍速播放
第三章 dxva2硬解渲染
第四章 提供C#接口
第五章 制作wpf播放器


文章目录

  • ffplay自定义系列
  • 前言
  • 一、接口设计
    • 1、初始化
    • 2、播放控制
    • 3、信息回馈
    • 4、渲染
  • 二、接口实现
    • 1、面向对象化
      • (1)、转变成员变量
      • (2)、删除全局变量
      • (3)、删除方法
    • 2、音频多路混合
    • 3、初始化
      • (1)、初始化
      • (2)、反初始化
    • 4、播放控制
      • (1)、播放
      • (2)、停止播放
      • (3)、暂停
      • (4)、循环
      • (5)、设置音量
      • (6)、静音
      • (7)、定位
      • (8)、倍速播放
    • 5、信息回馈
      • (1)、开始播放
      • (2)、播放进度
      • (3)、播放结束
    • 6、渲染
      • (1)、窗口句柄
      • (2)、自定义
  • 三、优化
    • 1、精准定位
  • 四、下载

前言

曾经做一个视频多轨道编辑软件项目的时候,需要定制化的视频播放器,根据多方面的考虑,决定对ffplay进行改造以达到定制化的效果。最近自己个人对其作了重新的设计实现,最终实现了一个跨平台的高度灵活的自定义多实例播放器模块,下面是详细的说明。


一、接口设计

需要多实例的使用ffplay,则必然需要设计一套提供给外部调用的接口,接口的设计按照面向对象的方式以达到多实例。

1、初始化

一个播放器当成一个实例,通过初始化方法得到一个播放器对象,反初始化方法销毁播放器对象。

//播放器对象
typedef  void* NPlay;
//初始化NPlay Np_Create();
//反初始化void Np_Destroy(NPlay p);

2、播放控制

播放控制包含了,播放、停止、暂停、循环、设置音量、静音、定位等方法,用于外部操控播放器。

//播放void Np_Play(NPlay play, const char* url,double startTime);
//停止void Np_Stop(NPlay play);
//暂停void Np_Pause(NPlay play, int isPaused);
//循环void Np_Loop(NPlay play, int isLoop);
//设置音量void Np_SetVolume(NPlay player, int value);
//静音void Np_Mute(NPlay play, int isMuted);
//定位void Np_Seek(NPlay play, double time);

3、信息回馈

打开媒体文件或者接收媒体流时通常需要知道媒体信息如视频格式、音频格式、时长、帧率、比特率等,播放时需要知道播放进度,渲染时需要知道像素格式,播放结束时需要收到通知。

//像素格式
typedef enum
{NP_FORMAT_YU12,NP_FORMAT_NV12,NP_FORMAT_NV21,NP_FORMAT_RGB24,
}Np_PixFormat;
//播放器对象
typedef  void* NPlay;
//回调方法
typedef void(*Np_Callback) (void* userData);
//开始回调方法
typedef void(*Np_BeginCallback) (void* userData, Np_PixFormat*format,int width,int height,double duration);
//播放位置回调方法
typedef void(*Np_PosChangedCallback) (void* userData, double pos);
//设置回调的自定义数据void Np_SetUserData(NPlay play,void* userdata);
//设置播放开始回调void Np_SetBeginCallback(NPlay play, Np_BeginCallback);
//设置播放结束回调void Np_SetEndCallback(NPlay play, Np_Callback);
//设置播放位置回调void Np_SetPosChangedCallback(NPlay play, Np_PosChangedCallback);

4、渲染

播放器的渲染是会与外部模块产生关联的一个功能,故需要提供接口。提供的接口有两种形式,一设置窗口句柄(仅限Windows系统),二是自定义渲染。

//视频渲染回调方法
typedef void(*Np_RenderCallback) (void* userData, unsigned char *data[8], int linesize[8],int width,int height);
//设置窗口句柄void Np_SetWindow(NPlay  play, void* hwnd);
//设置视频渲染回调void Np_SetRenderCallback(NPlay play, Np_RenderCallback);

二、接口实现

1、面向对象化

接口已经设计好了,为了与接口相适应,ffplay的内部实现也需要改造为面向对象,这里有其实两个前提:一是ffplay的实现很大程度遵循了面向对象的思想、二是ffplay的代码结构条理清晰。我们要做的有三个步骤:

(1)、转变成员变量

首先将ffplay.c的所有全局变量,放入VideoState结构体中,接着编译,然后根据编译器提示的错误修改。切记需要记录全局变量的默认值,在实例化方法中,将其对应设置。

(2)、删除全局变量

修改的过程中可以将判断为无用的全局变量删除以常量代替,简化代码的复杂度。

(3)、删除方法

完成上述步骤后,可查找ffplay.c方法的引用关系,与上述接口设计无关的方法可以删除,让代码整洁及易于维护,比如event_loop这个方法是响应界面按键事件的,与上述接口设计无关,就可以删除的。
改造后在变量及设置初始值方法如下:

static void set_default_param(VideoState* s) {s->window = 0;s->renderer = 0;s->hwnd = 0;//窗口句柄s->audio_disable = 0;s->video_disable = 0;s->subtitle_disable = 0;s->wanted_stream_spec[0] = 0;s->seek_by_bytes = -1;s->display_disable = 0;s->audio_volume = SDL_MIX_MAXVOLUME;s->show_status = 0;s->av_sync_type = AV_SYNC_AUDIO_MASTER;s->start_time = AV_NOPTS_VALUE;s->duration = AV_NOPTS_VALUE;s->fast = 0;s->genpts = 0;s->lowres = 0;s->decoder_reorder_pts = -1;s->autoexit = 0;s->loop = 1;//修改为是否循环播放,原来是循环的次数。s->framedrop = -1;s->infinite_buffer = -1;s->show_mode = SHOW_MODE_NONE;s->audio_codec_name = 0;s->subtitle_codec_name = 0;s->video_codec_name = 0;s->rdftspeed = 0.02;s->find_stream_info = 1;s->audio_callback_time = 0;av_init_packet(&s->flush_pkt);s->flush_pkt.data = (uint8_t*)&s->flush_pkt;
}

2、音频多路混合

完成了上述内容后,得到的对象化的ffplay还是不能直接使用。我们知道,播放器包含视频渲染、音频播放两个部分。视频渲染通过设置窗口句柄或自定义渲染是可以做到多实例的,但音频的播放则有所不同,音频播放需要打开一个音频设备,然后向音频设备写入音频数据,播放出声音。而一个进程通常只能打开一次音频设备,音频设备通常也只能同时有一个写入。如果考虑通过打开多个音频设备实现多实例显然是不合理的。基于这些情况,这里的解决方案是,只打开一个音频设备,对于多路的音频数据,先混合之后再一同写入到音频设备。
由于ffplay采用了sdl的音频接口且为回调写数据的方式,我只需要先记录每个ffplay的实例,设置一个自己的回调方法,在回调用中遍历所有实例调用ffplay原本的改造面向对象的回调方法(sdl_audio_callback),同时在方法内所有分支都使用SDL_MixAudio方法写入数据。
这里可能会有一个疑问,即音频播放改成了上述多路合并的方式,对时钟同步是否会有影响,是否会导致所有实例的时钟被绑定到了一起。关于这个问题,第一从单个实例角度看除了更换了写入方法,其他和ffplay原本的逻辑是一摸一样的甚至回调频率也是一样的,且各个实例之间的数据是独立的,所以理论上时钟同步是不会相互干扰的。第二,通过了实际测试时钟同步正常没有相互干扰。

#define MUTI_OPEN_NUM 32 //支持多开数
static SDL_mutex* audio_streams_mutex = NULL;
static VideoState* open_audio_streams[MUTI_OPEN_NUM];
static void sdl_audio_callback_sum(void* opaque, Uint8* stream, int len)
{int n = 0;SDL_LockMutex(audio_streams_mutex);for (int i = 0; i < MUTI_OPEN_NUM; i++){if (open_audio_streams[i]){open_audio_streams[i]->audio_callback_index = n++;sdl_audio_callback(open_audio_streams[i], stream, len);}}SDL_UnlockMutex(audio_streams_mutex);
}

3、初始化

(1)、初始化

通过初始化得到一个播放对象,相当于c++中的构造函数。有个一个要点,实例化时需要设置改造成成员变量的全局变量的默认值。

NPlay Np_Create() {if (!init_global){avformat_network_init();int flags = SDL_INIT_EVERYTHING;if (SDL_Init(flags)) {SDL_Quit();return NULL;}if (audio_streams_mutex == NULL){audio_streams_mutex = SDL_CreateMutex();}memset(open_audio_streams, 0, sizeof(VideoState*) * MUTI_OPEN_NUM);init_global = 1;}VideoState* s = av_mallocz(sizeof(VideoState));set_default_param(s);return s;
}

(2)、反初始化

将一个播放对象销毁,释放内存及资源,需要从全局记录的对象集合删除销毁的对象,当只剩最有一个对象被销毁时,需要检查关闭音频设备。

void Np_Destroy(NPlay p)
{stream_close((VideoState*)p);av_free((VideoState*)p);
}

4、播放控制

(1)、播放

调用播放后会开始初始化ffplay的播放相关资源:解复用、视频解码器、音频解码器等, 开启播放线程:解复用线程、视频解码线程、音频解码线程、字幕解码线程、视频渲染线程,开始进行播放,通过直接调用stream_open实现。
播放通常需要输入的参数,为一个url及起始时间,url即播放的地址,可以时文件路径、点播地址、rtsp地址、rtmp地址。起始时间时播放起始的时间,通过播放和定位实现的用户体验可能时不好的,所以需要这个参数。
实现如下:

void Np_Play(NPlay play, const char* url, double startTime)
{VideoState* s = (VideoState*)play;if (s->ic){Np_Stop(play);}s->start_time = (int64_t)(startTime * AV_TIME_BASE);stream_open(s, url, NULL);
}

(2)、停止播放

调用停止播放,销毁当前ffplay的播放资源及线程,通过直接调用stream_close实现。
实现如下:

void Np_Stop(NPlay p)
{stream_close((VideoState*)p);
}

(3)、暂停

暂停通过直接调用toggle_pause实现
实现如下:

void Np_Pause(NPlay play, int isPaused)
{VideoState* s = (VideoState*)play;if (s->paused != isPaused)toggle_pause(s);
}

(4)、循环

直接使用loop 字段,通过一些改造,实现。
ffplay的loop字段表示循环的次数,这里需要改为是否循环。
在read_thread中:

if (is->loop/*loop != 1 && (!loop || --loop)*/) {stream_seek(is, is->start_time != AV_NOPTS_VALUE ? is->start_time : 0, 0, 0);
}

接口实现如下:

void Np_Loop(NPlay play, int value)
{VideoState* s = (VideoState*)play;s->loop = value;
}

(5)、设置音量

直接使用audio_volume字段即可。在sdl音频回调方法sdl_audio_callback中会根据audio_volume的值作为参数进行音频混合。
实现如下:

void Np_SetVolume(NPlay play, int value)
{VideoState* is = (VideoState*)play;if (value < 0)value = 0;if (value > 100)value = 100;is->audio_volume = (int)(value * SDL_MIX_MAXVOLUME / 100.0);
}

(6)、静音

直接使用muted字段即可。muted字段在sdl音频回调方法sdl_audio_callback中有判断,如果为真,则不进行写入。
实现如下:

void Np_Mute(NPlay play, int isMuted)
{VideoState* s = (VideoState*)play;s->muted = isMuted;
}

(7)、定位

直接使用stream_seek方法即可。调用之后,seek_req字段会被置1,在read_thread的解复循环中解复前会判断这个字段,如果为真,则进行定位。
实现如下:

void Np_Seek(NPlay play, double time)
{VideoState* is = (VideoState*)play;stream_seek(is, (int64_t)(time * AV_TIME_BASE), 0, 0);
}

(8)、倍速播放

基本思路是修改视频通过修改avframe的pts,音频通过使用soundtouch库的settempo方法转换音频数据。这样做的主要原因有两个,一个是通过修改音频的samplerate会导致变速又变调,另一个是使用ffmpeg的滤镜实现音频变速的音频质量非常不好。但后来发现新版本的ffmpeg滤镜效果变好了,音质与soundtouch接近。
ffmpeg滤镜版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120560167
soundtouch版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120521242
sonic版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120519347

5、信息回馈

(1)、开始播放

开始播放是一个事件回调,作用是获取视频信息如分辨率、像素格式,及设置像素格式用于自定义渲染。其回调的时机是在read_thread方法中初始化完所有对象,在进入解复用循环前。
read_thread中添加如下代码:

Np_PixFormat format = NP_FORMAT_YU12;
if (is->viddec.avctx)
{switch (is->viddec.avctx->pix_fmt){case  AV_PIX_FMT_YUV420P:format = NP_FORMAT_YU12;break;case  AV_PIX_FMT_NV12:format = NP_FORMAT_NV12;break;case AV_PIX_FMT_NV21:format = NP_FORMAT_NV21;break;case AV_PIX_FMT_RGB24:format = NP_FORMAT_RGB24;break;default:format = NP_FORMAT_YU12;break;}
}
if (is->begin_callback)
{int width = is->viddec.avctx ? is->viddec.avctx->width : 0;int height = is->viddec.avctx ? is->viddec.avctx->height : 0;double duration = is->ic->duration / (double)AV_TIME_BASE;is->begin_callback(is->userdata, &format, width, height, duration);
}
switch (format)
{case    NP_FORMAT_YU12:is->render_format = AV_PIX_FMT_YUV420P;break;
case    NP_FORMAT_NV12:is->render_format = AV_PIX_FMT_NV12;break;
case    NP_FORMAT_NV21:is->render_format = AV_PIX_FMT_NV21;break;
case    NP_FORMAT_RGB24:is->render_format = AV_PIX_FMT_RGB24;break;
default:is->render_format = AV_PIX_FMT_YUV420P;break;
}

接口实现:

void Np_SetBeginCallback(NPlay play, Np_BeginCallback value)
{VideoState* is = (VideoState*)play;is->begin_callback = value;
}

(2)、播放进度

播放进度的获取时机,不能在解复用线程也不能在解码线程,应该放在渲染或播放线程。具体做法是优先在视频渲染线程回调,当没有视频流时才在sdl音频回调方法中回调。
在video_display方法中回调:

 //回调当前播放时间if (is->pos_changed_callback != NULL){is->pos_changed_callback(is->userdata, get_master_clock(is));}

在sdl_audio_callback中回调:

if (is->viddec.avctx == NULL)//如果没有视频流,则在音频播放时回调当前时间{if (is->pos_changed_callback){is->pos_changed_callback(is->userdata, get_master_clock(is));}}

接口实现:

void Np_SetPosChangedCallback(NPlay play, Np_PosChangedCallback value)
{VideoState* is = (VideoState*)play;is->pos_changed_callback = value;
}

(3)、播放结束

回调的位置放在read_thread的最后即可:

if (is->end_callback)
{is->end_callback(is->userdata);
}

接口实现:

void Np_SetEndCallback(NPlay play, Np_Callback value)
{VideoState* is = (VideoState*)play;is->end_callback = value;
}

6、渲染

(1)、窗口句柄

在Windows系统,可以实现直接设置窗口句柄的方式渲染视频。内部实现通过sdl关联窗口句柄然后使用sdl进行渲染。
在video_open方法中进行关联:

static int video_open(VideoState* is)
{//通过外部句柄创建sdl windowif (!is->window) {if (is->hwnd){is->window = SDL_CreateWindowFrom(is->hwnd);}elsereturn -1;}
//通过外部句柄创建sdl window --endif (is->window) {SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");SDL_RendererInfo info;is->renderer = SDL_CreateRenderer(is->window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);if (!is->renderer) {av_log(NULL, AV_LOG_WARNING, "Failed to initialize a hardware accelerated renderer: %s\n", SDL_GetError());is->renderer = SDL_CreateRenderer(is->window, -1, 0);}if (is->renderer) {if (!SDL_GetRendererInfo(is->renderer, &info))av_log(NULL, AV_LOG_VERBOSE, "Initialized %s renderer.\n", info.name);}SDL_GetWindowSize(is->window, &is->width, &is->height);}if (!is->window || !is->renderer) {av_log(NULL, AV_LOG_FATAL, "SDL: could not set video mode - exiting\n");}return 0;
}

接口实现:

void Np_SetWindow(NPlay play, void* hwnd)
{VideoState* s = (VideoState*)play;s->hwnd = hwnd;
}

(2)、自定义

自定义渲染则是,在视频渲染线程中,不调用ffplay原本的sdl渲染,而回调自定义渲染方法实现如下:

static void video_display(VideoState* is)
{//回调当前播放时间if (is->pos_changed_callback != NULL){is->pos_changed_callback(is->userdata, get_master_clock(is));}//自定义渲染if (is->render_callback){Frame* vp;vp = frame_queue_peek_last(&is->pictq);if (is->render_format != vp->frame->format){is->render_convert_ctx = sws_getCachedContext(is->render_convert_ctx,vp->frame->width, vp->frame->height, vp->frame->format, vp->frame->width, vp->frame->height,is->render_format, sws_flags, NULL, NULL, NULL);if (is->render_convert_ctx != NULL){int size = av_image_get_buffer_size(is->render_format, vp->width, vp->height, 1);if (is->render_buf == NULL || size > is->render_buf_size){if (is->render_buf){av_free(is->render_buf);}is->render_buf = av_malloc(size);is->render_buf_size = size;}uint8_t* dst_data[4];int dst_linesize[4];av_image_fill_arrays(dst_data, dst_linesize, is->render_buf, is->render_format, vp->width, vp->height, 1);sws_scale(is->render_convert_ctx, (const uint8_t* const*)vp->frame->data, vp->frame->linesize,0, vp->frame->height, dst_data, dst_linesize);is->render_callback(is->userdata, is->render_buf, is->render_buf_size, vp->width, vp->height);}}else{is->render_callback(is->userdata, vp->frame->data, vp->frame->linesize, vp->width, vp->height);}}//自定义渲染 --endif (!is->window)return;SDL_SetRenderDrawColor(is->renderer, 0, 255, 255, 255);SDL_RenderClear(is->renderer);if (is->video_st){video_image_display(is);}SDL_RenderPresent(is->renderer);
}

接口实现:

void Np_SetRenderCallback(NPlay play, Np_RenderCallback value)
{VideoState* is = (VideoState*)play;is->render_callback = value;
}

三、优化

通过上述步骤基本可以得到一个,高度定制可用的ffplay播放器,但在使用中发现还是有些地方是需要优化的。

1、精准定位

原本的ffplay没有精准定位功能,以h264为例,如果当前定位的时间不是关键帧,则会自动往前跳到最近的一个idr即gop的首帧,一般的用来看视频的播放器尚可接受,但是作为视频编辑工具使用则不行。所以需要给ffplay拓展此功能。
由于ffplay的定位功能是在read_thread的解复用循环中实现的,因此精准定位当然只能在这个地方进行拓展。
实现思路是,在read_thread中对av_read_frame得到的avpacket的pts与定位的时间进行对比,如果它们的差值大于某个阈值则认为定位不准,需进行重新定位。重新定位的做法是,从上述解复的帧开始继续解码,比较pts,直到pts和定位时间差值小于阈值,回到read_thread的原本流程继续播放。
实现如下:

//gop seek   if (seek_time > 0 && pkt->stream_index == is->video_stream && (pkt_time = pkt_ts * av_q2d(is->ic->streams[is->video_stream]->time_base)) < seek_time) {//为了确保视频解码线程已经解码完毕,暂时这样,此方法有风险。找代替方案的思路是:下面的代码逻辑需要确保视频解码线程没有在解码。SDL_Delay(40);AVFrame* frame = av_frame_alloc();int got_picture = 0;avcodec_send_packet(is->viddec.avctx, pkt);avcodec_receive_frame(is->viddec.avctx, frame);av_packet_unref(pkt);av_frame_unref(frame);while (pkt_time < seek_time && pkt_time >= 0) {ret = av_read_frame(ic, pkt);if (ret >= 0){if (pkt->stream_index == is->video_stream){ret = avcodec_send_packet(is->viddec.avctx, pkt);if (ret == 0){got_picture = avcodec_receive_frame(is->viddec.avctx, frame) == 0;if (got_picture){pkt_time = (pkt->pts == AV_NOPTS_VALUE ? pkt->dts : pkt->pts) * av_q2d(is->ic->streams[is->video_stream]->time_base);av_frame_unref(frame);}}}av_packet_unref(pkt);}else{break;}}av_frame_free(&frame);seek_time = 0;continue;}//gop seek -end

四、下载

目前提供动态库下载,C++也可以使用
ffplay自定义播放器封装C#接口

基于ffplay改造成自定义多开播放器相关推荐

  1. 基于MSP430G2553官方开发板的音乐播放器

    基于MSP430G2553官方开发板的音乐播放器 实现目标 硬件资源 芯片资源使用情况 外接硬件 程序实现 开发环境配置 各部分硬件驱动 主循环功能实现 实现目标 实现以蜂鸣器为播放设备,能够对简谱乐 ...

  2. 基于Arduino Uno开发板制作音乐播放器

    基于Arduino Uno开发板制作音乐播放器 本文将基于Arduino开发板实现一个音乐播放器. 利用Arduino Uno读取sd卡模块中内存卡的音乐,传输信号到扬声器进行播放. 一.项目软硬件简 ...

  3. 基于Qt的仿酷狗音乐播放器设计(二)

    简述 在上一文"基于Qt的仿酷狗音乐播放器设计(一)"中,博主给出了仿酷狗界面的部分内容,在本文中将继续分析酷狗界面,并作出相应的分析. 下面我们来看一下酷狗界面中的左侧滑动页控制 ...

  4. python基于yolov3实现的手势控制音乐播放器

    python基于yolov3实现的手势控制音乐播放器 效果演示 总体框架 手势识别模块 音乐播放器模块 一个小总结吧 效果演示 话不多说,先上最后的成品展示. python基于yolov3实现的手势控 ...

  5. YUVPlayer: 基于Android平台的YUV视频原始数据播放器

    基于Android平台的YUV视频原始数据播放器 编译环境 FFmpeg版本: 4.2.2 NDK版本:r17c 运行环境 x86(模拟器) arm64-v8a(64位手机) 功能点 从文件中读取YU ...

  6. 最简单的基于Flash的流媒体示例:网页播放器(HTTP,RTMP,HLS)

    音视频领域,再次搜索,依然是大神雷霄骅的杰作.再次致敬,一路走好. ===================================================== Flash流媒体文章列表 ...

  7. linux(ubuntu)下基于java的在线音乐仿qq播放器,

    linux下基于java的在线音乐仿qq播放器,界面挺漂亮,界面全都是本人自己用java来画的,主要是我自己喜欢用ubuntu,但是由于没有人去做它的播放器,就自己来了,可以在线,有专辑图片,播放列表 ...

  8. 一个基于Android开发的简单的音乐播放器

    一个基于Android开发的简单的音乐播放器 记得当时老师让我们写因为播放器时,脑子一头雾水,网上杂七杂八的资料也很少有用,因此索性就自己写一篇,希望对有缘人有用. 因为有好多人问我要源码,所以附上g ...

  9. 自定义制作音频播放器_使用HTML5制作音频播放器,第3部分:微数据和皮肤

    自定义制作音频播放器 In the first two articles of this series I introduced the concept and code of a customize ...

最新文章

  1. PHP数据结构之——链表
  2. 如何将Android的AOSP仓库放置到自己的gitlab服务器上?
  3. 3.3 神经网络的输出-深度学习-Stanford吴恩达教授
  4. 递归和迭代_迭代与递归
  5. 线性代数 【22】 抽象的向量空间
  6. UI素材干货模板|网页“按钮”组件,教你要如何设计!
  7. Javascript、Jquery获取浏览器和屏幕各种高度宽度
  8. MySQL单表删除重复列SQL语句
  9. bzoj 1076 奖励关 状压+期望dp
  10. 快速排序C语言实现 - 源码详解
  11. html怎么快捷复制粘贴,怎么快速复制粘贴文本?快速粘贴文本教程
  12. 学计算机的用双核CPU够吗,电脑cpu核数越多越好吗
  13. 有什么好用的语音转文字软件?介绍三个语音文件转文字的软件
  14. window server2008下安装VS.NET2008
  15. 梯度,散度,拉普拉斯算子
  16. macbook pro 怎么设置分屏_小米Pro要不要整黑苹果——Hackintosh浅度体验记录
  17. python连接MySQL数据库实现界面化图书管理系统
  18. 淘宝高转化主图怎么做?大神导航,一个神奇的网站,从此开启大神之路!
  19. infoq_从2019年开始的InfoQ编辑推荐讲座
  20. vue中调用百度地图实现搜索等功能

热门文章

  1. Go Module使用 六大场景讲解示例
  2. 测试下1K个宏和程序运行空间大小的关系
  3. html保存时出现nul,c# – 有时保存的文件只包含NUL字符
  4. 【java】几种跳出 for循环的方法
  5. linux命令:tar(打包、压缩、解压)
  6. Linux入职基础-3.6_ramdisk提升Apache性能实例(运维必懂)
  7. javad八大基本数据类型
  8. 灵活替换、无惧缺芯,ARM工控板中的模块化设计
  9. Bootstrap教程
  10. php获得客户端ip地址范例