零基础读懂视频播放器控制原理: ffplay 播放器源代码分析
Posted STN_LCD
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了零基础读懂视频播放器控制原理: ffplay 播放器源代码分析相关的知识,希望对你有一定的参考价值。
https://www.qcloud.com/community/article/535574001486630869
视频播放器原理其实大抵相同,都是对音视频帧序列的控制。只是一些播放器在音视频同步上可能做了更为复杂的帧预测技术,来保证音频和视频有更好的同步性。
ffplay 是 FFMpeg 自带的播放器,使用了 ffmpeg 解码库和用于视频渲染显示的 sdl 库,也是业界播放器最初参考的设计标准。本文对 ffplay 源码进行分析,试图用更基础而系统的方法,来尝试解开播放器的音视频同步,以及播放/暂停、快进/后退的控制原理。
由于 FFMpeg 本身的跨平台特性,相比在移动端看音视频代码,在 PC 端利用 VS 查看和调试代码,分析播放器原理,要高效迅速很多。
由于 FFMpeg 官方提供的 ffmplay 在 console 中进行使用不够直观,本文直接分析 CSDN 上将 ffplay 移植到 VC 的代码(ffplay for MFC)进行分析。
文章目录:
一、初探mp4文件
二、以最简单播放器开始:FFmpeg解码 + SDL显示
三、先抛五个问题
四、ffplay代码总体结构
五、视频播放器的操作控制
5.1 ffplay所定义的关键结构体VideoState
5.2 补充基础知识——PTS和DTS
5.2 如何控制音视频同步
5.4 如何控制视频的播放和暂停?
5.5 逐帧播放是如何做的?
5.6 快进和后退
六、 这次分析ffplay代码的反省总结
一、初探mp4文件
为了让大家对视频文件有一个初步认识,首先来看对一个MP4文件的简单分析,如图1。
图1 对MP4文件解参
从图一我们知道,每个视频文件都会有特定的封装格式、比特率、时长等信息。视频解复用之后,就划分为video_stream和audio_stream,分别对应视频流和音频流。
解复用之后的音视频有自己独立的参数,视频参数包括编码方式、采样率、画面大小等,音频参数包括采样率、编码方式和声道数等。
对解复用之后的音频和视频Packet进行解码之后,就变成原始的音频(PWM)和视频(YUV/RGB)数据,才可以在进行显示和播放。
其实这已经差不多涉及到了,视频解码播放的大部分流程,整个视频播放的流程如图2所示。
图2 视频播放流程(图摘自http://blog.csdn.net/leixiaohua1020/article/details/50534150)
二、以最简单播放器开始:FFmpeg解码 + SDL显示
为将问题简单化,先不考虑播放音频,只播放视频,代码流程图如图3所示:
图3 播放器流程图(图源见水印)
流程图说明如下:
1.FFmpeg初始化的代码比较固定,主要目的就是为了设置 AVFormatContext 实例中相关成员变量的值,调用av_register_all、avformat_open_input av_find_stream_info和avcodec_find_decoder等函数。
如图4所示,初始化之后的AVFormatContext实例里面具体的值,调用av_find_stream_info就是找到文件中的音视频流数据,对其中的streams(包含音频、视频流)变量进行初始化。
图4 AVFormatContext初始化实例
2.av_read_frame不断读取stream中的下一帧,对其进行解复用得到视频的AVPacket,随后调用avcodec_decode_video2是视频帧AVPacket进行解码,得到图像帧AVFrame。
3.得到AVFrame之后,接下来就是放到SDL中进行渲染显示了,也很简单,流程见下面代码注释:
SDL_Overlay *bmp;
//将解析得到的AVFrame的数据拷贝到SDL_Overlay实例当中
SDL_LockYUVOverlay(bmp);
bmp->pixels[0]=pFrameYUV->data[0];
bmp->pixels[2]=pFrameYUV->data[1];
bmp->pixels[1]=pFrameYUV->data[2];
bmp->pitches[0]=pFrameYUV->linesize[0];
bmp->pitches[2]=pFrameYUV->linesize[1];
bmp->pitches[1]=pFrameYUV->linesize[2];
SDL_UnlockYUVOverlay(bmp);
//设置SDL_Rect,因为涉及到起始点和显示大小,用rect进行表示。
SDL_Rect rect;
rect.x = 0;
rect.y = 0;
rect.w = pCodecCtx->width;
rect.h = pCodecCtx->height;
//将SDL_Overlay数据显示到SDL_Surface当中。
SDL_DisplayYUVOverlay(bmp, &rect);
//延时40ms,留足ffmpeg取到下一帧并解码该帧的时间,随后继续读取下一帧
SDL_Delay(40);
由上面的原理可知,从帧流中获取到AVPacket,并且解码得到AVFrame,渲染到SDL窗口中。
图5 视频播放状态图
对视频播放的流程总结一下就是:读取下一帧——>解码——>播放——>不断往复,状态图如图5所示。
三、先抛五个问题
本文还是以问题抛问题的思路,以逐步对每个问题进行原理性分析,加深对音视频解码和播放的认识。以下这些问题也是每一个播放器所需要面对的基础问题和原理:
1.我们在观看电影时发现,电影可以更换不同字幕,甚至不同音频,比如中英文字幕和配音,最后在同一个画面中进行显示,视频关于画面、字幕和声音是如何组合的?
其实每一个视频文件,读取出来之后发现,都会被区分不同的流。为了让大家有更具体的理解,以FFMpeg中的代码为例,AVMediaType定义了具体的流类型:
enum AVMediaType {
AVMEDIA_TYPE_VIDEO, //视频流
AVMEDIA_TYPE_AUDIO, //音频流
AVMEDIA_TYPE_SUBTITLE, //字幕流
};
利用av_read_frame读取出音视频帧之后,随后就利用avcodec_decode_video2对视频捷星解码,或者调用avcodec_decode_audio4对音频进行解码,得到可以供渲染和显示的音视频原始数据。
图像和字幕都将会以Surface或者texture的形式,就像android中的SurfaceFlinger,将画面不同模块的显示进行组合,生成一幅新的图像,显示在视频画面中。
2.既然视频有帧率的概念,音频有采样率的概念,是否直接利用帧率就可以控制音视频的同步了呢?
每一个视频帧和音频帧在时域上都对应于一个时间点,按道理来说只要控制每一个音视频帧的播放时间,就可以实现同步。
但实际上,对每一帧显示的时间上的精确控制是很难的,更何况音频和视频的解码所需时间不同,极容易引起音视频在时间上的不同步。
所以,播放器具体是如何做音视频同步的呢?
3.视频的音频流、视频流和字幕流,他们在时间上是连续的还是离散的?不同流的帧数相同吗?
由于计算机只能数字模拟离散的世界,所以在时间上肯定是离散的。那既然是离散的,他们的帧数是否相同呢?
视频可以理解为诸多音频帧、视频帧和字幕帧在时间上的序列,他们在时间上的时长,跟视频总时长是相同的,但是由于每个帧解码时间不同,必然会导致他们在每帧的时间间隔不相同。
音频原始数据本身就是采样数据,所以是有固定时钟周期。但是视频假如想跟音频进行同步的话,可能会出现跳帧的情况,每个视频帧播放时间差,都会起伏不定,不是恒定周期。
所以结论是,三者在视频总时长上播放的帧数肯定是不一样的。
4.视频播放就是一系列的连续帧不停渲染。对视频的控制操作包括:暂停和播放、快进和后退。那有没有想过,每次快进/后退的幅度,以时间为量度好,还是以每次跳跃的帧数,就是每次快进是前进多长时间,还是前进多少帧。 时间 VS 帧数?
由上面问题分析,我们知道,视频是以音频流、视频流和字幕流进行分流的,假如以帧数为基础,由于不同流的帧数量不一定相同,以帧数为单位,很容易导致三个流播放的不一致。
因此以时间为量度,相对更好,直接搜寻mp4文件流,当前播放时间的前进或后退时长的seek时间点,随后重新对文件流进行分流解析,就可以达到快进和后退之后的音视频同步效果。
我们可以看到绝大部分播放器,快进/倒退都是以时长为步进的,我们可以看看ffplay是怎么样的,以及是如何实现的。
5.上一节中,实现的简单播放器,解码和播放都是在同一个线程中,解码速度直接影响播放速度,从而将直接造成播放不流畅的问题。那如何在解码可能出现速度不均匀的情况下,进行流畅的视频播放呢?
很容易想到,引入缓冲队列,将视频图像渲染显示和视频解码作为两个线程,视频解码线程往队列中写数据,视频渲染线程从队列中读取数据进行显示,这样就可以保证视频是可以流程播放的。
因此需要采用音频帧、视频帧和字幕帧的三个缓冲队列,那如何保证音视频播放的同步呢?
PTS是视频帧或者音频帧的显示时间戳,究竟是如何利用起来的,从而控制视频帧、音频帧以及字幕帧的显示时刻呢?
那我们就可以探寻ffplay,究竟是如何去做缓冲队列控制的。
所有以上五个问题,我们都将在对ffplay源代码的探寻中,逐步找到更具体的解答。
四、ffplay代码总体结构
图6 ffplay代码总体流程
网上有人做了ffplay的总体流程图,如图6。有了这幅图,代码看起来,就会轻松了很多。流程中具体包含的细节如下:
1.启动定时器Timer,计时器40ms刷新一次,利用SDL事件机制,触发从图像帧队列中读取数据,进行渲染显示;
2.stream_componet_open函数中,av_read_frame()读取到AVPacket,随后放入到音频、视频或字幕Packet队列中;
3.video_thread,从视频packet队列中获取AVPacket并进行解码,得到AVFrame图像帧,放到VideoPicture队列中。
4..audio_thread线程,同video_thread,对音频Packet进行解码;
5.subtitle_thread线程,同video_thread,对字幕Packet进行解码。
五、视频播放器的操作控制
视频播放器的操作包括播放/暂停、快进/倒退、逐帧播放等,这些操作的实现原理是什么呢,下面对其从代码层面逐个进行分析。
5.1 ffplay所定义的关键结构体VideoState
与FFmpeg解码类似,定义了一个AVFormatContext结构体,用于存储文件名、音视频流、解码器等字段,供全局进行访问。
ffplay也定义了一个结构体VideoState,通过对VideoState的分析,就可以大体知道播放器基本实现原理。
typedef struct VideoState {
// Demux解复用线程,读视频文件stream线程,得到AVPacket,并对packet入栈
SDL_Thread *read_tid;
//视频解码线程,读取AVPacket,decode 爬出可以成AVFrame并入队
SDL_Thread *video_tid;
//视频播放刷新线程,定时播放下一帧
SDL_Thread *refresh_tid;
int paused; //控制视频暂停或播放标志位
int seek_req; //进度控制标志
int seek_flags;
AVStream *audio_st; //音频流
PacketQueue audioq; //音频packet队列
double audio_current_pts; //当前音频帧显示时间
AVStream *subtitle_st; //字幕流
PacketQueue subtitleq;//字幕packet队列
AVStream *video_st; //视频流
PacketQueue videoq;//视频packet队列
double video_current_pts; ///当前视频帧pts
double video_current_pts_drift;
VideoPicture pictq[VIDEO_PICTURE_QUEUE_SIZE]; //解码后的图像帧队列
}
从VideoState结构体中可以看出:
1.解复用、视频解码和视频刷新播放,分属三个线程中,并行控制;
2.音频流、视频流、字幕流,都有自己的缓冲队列,供不同线程读写,并且有自己的当前帧的PTS;
3.解码后的图像帧单独放在pictq队列当中,SDL利用其进行显示。
其中PTS是什么呢,这在音视频中是一个很重要的概念,直接决定视频帧或音频帧的显示时间,下面具体介绍一下。
5.2 补充基础知识——PTS和DTS
图7 音视频解码分析
图7为输出的音频帧和视频帧序列,每一帧都有PTS和DTS标签,这两个标签究竟是什么意思呢?
DTS(Decode Time Stamp)和PTS(Presentation Time Stamp)都是时间戳,前者是解码时间,后者是显示时间,都是为视频帧、音频帧打上的时间标签,以更有效地支持上层应用的同步机制。
也就是说,视频帧或者音频在解码时,会记录其解码时间,视频帧的播放时间依赖于PTS。
对于声音来说 ,这两个时间标签是相同的;但对于某些视频编码格式,由于采用了双向预测技术,DTS会设置一定的超时或延时,保证音视频的同步,会造成DTS和PTS的不一致。
5.3 如何控制音视频同步
我们已经知道,视频帧的播放时间其实依赖pts字段的,音频和视频都有自己单独的pts。但pts究竟是如何生成的呢,假如音视频不同步时,pts是否需要动态调整,以保证音视频的同步?
下面先来分析,如何控制视频帧的显示时间的:
static void video_refresh(void *opaque){
//根据索引获取当前需要显示的VideoPicture
VideoPicture *vp = &is->pictq[is->pictq_rindex];
if (is->paused)
goto display; //只有在paused的情况下,才播放图像
// 将当前帧的pts减去上一帧的pts,得到中间时间差
last_duration = vp->pts - is->frame_last_pts;
//检查差值是否在合理范围内,因为两个连续帧pts的时间差,不应该太大或太小
if (last_duration > 0 && last_duration < 10.0) {
/* if duration of the last frame was sane, update last_duration in video state */
is->frame_last_duration = last_duration;
}
//既然要音视频同步,肯定要以视频或音频为参考标准,然后控制延时来保证音视频的同步,
//这个函数就做这个事情了,下面会有分析,具体是如何做到的。
delay = compute_target_delay(is->frame_last_duration, is);
//获取当前时间
time= av_gettime()/1000000.0;
//假如当前时间小于frame_timer + delay,也就是这帧改显示的时间超前,还没到,就直接返回
if (time < is->frame_timer + delay)
return;
//根据音频时钟,只要需要延时,即delay大于0,就需要更新累加到frame_timer当中。
if (delay > 0)
/更新frame_timer,frame_time是delay的累加值
is->frame_timer += delay * FFMAX(1, floor((time-is->frame_timer) / delay));
SDL_LockMutex(is->pictq_mutex);
//更新is当中当前帧的pts,比如video_current_pts、video_current_pos 等变量
update_video_pts(is, vp->pts, vp->pos);
SDL_UnlockMutex(is->pictq_mutex);
display:
/* display picture */
if (!display_disable)
video_display(is);
}
函数compute_target_delay根据音频的时钟信号,重新计算了延时,从而达到了根据音频来调整视频的显示时间,从而实现音视频同步的效果。
static double compute_target_delay(double delay, VideoState *is)
{
double sync_threshold, diff;
//因为音频是采样数据,有固定的采用周期并且依赖于主系统时钟,要调整音频的延时播放较难控制。所以实际场合中视频同步音频相比音频同步视频实现起来更容易。
if (((is->av_sync_type == AV_SYNC_AUDIO_MASTER && is->audio_st) ||
is->av_sync_type == AV_SYNC_EXTERNAL_CLOCK)) {
//获取当前视频帧播放的时间,与系统主时钟时间相减得到差值
diff = get_video_clock(is) - get_master_clock(is);
sync_threshold = FFMAX(AV_SYNC_THRESHOLD, delay);
//假如当前帧的播放时间,也就是pts,滞后于主时钟
if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
if (diff <= -sync_threshold)
delay = 0;
//假如当前帧的播放时间,也就是pts,超前于主时钟,那就需要加大延时
else if (diff >= sync_threshold)
delay = 2 * delay;
}
}
return delay;
}
图8 音视频帧显示序列
所以这里的流程就很简单了,图8简单画了一个音视频帧序列,想表达的意思是,音频帧数量和视频帧数量不一定对等,另外每个音频帧的显示时间在时间上几乎对等,每个视频帧的显示时间,会根据具体情况有延时显示,这个延时就是有上面的compute_target_delay函数计算出来的。
计算延迟后,更新pts的代码如下:
static void update_video_pts(VideoState *is, double pts, int64_t pos) {
double time = av_gettime() / 1000000.0;
/* update current video pts */
is->video_current_pts = pts;
is->video_current_pts_drift = is->video_current_pts - time;
is->video_current_pos = pos;
is->frame_last_pts = pts;
}
整个流程可以概括为:
显示第一帧视频图像;
根据音频信号,计算出第二帧的delay时间,更新该帧的pts;
当pts到达后,显示第二帧视频图像;
重复以上步骤,到最后一帧。
也许在这里仍然会让人很困惑,为什么单单根据主时钟,就可以播放下一帧所需要的延时呢?
其实视频是具备一定长度的播放流,具体可以分为音频流、视频流和字幕流,三者同时在一起播放形成了视频,当然他们总的播放时间是跟视频文件的播放时长是一样的。
由于音频流本身是pwm采样数据,以固定的频率播放,这个频率是跟主时钟相同或是它的分频,从时间的角度来看,每个音频帧是自然均匀流逝。
所以音频的话,直接按照主时钟或其分频走就可以了。
视频,要根据自己的显示时间即pts,跟主时钟当前的时间进行对比,确定是超前还是滞后于系统时钟,从而确定延时,随后进行准确的播放,这样就可以保证音视频的同步了。
那接下来,还有一个问题,计算出延时之后,难道需要sleep一下做延迟显示吗?
其实并不是如此,上面分析我们知道delay会更新到当前需要更新视频帧的pts (video_current_pts),对当前AVFrame进行显示前,先检测其pts时间,假如还没到,就不进行显示了,直接return。直到下一次刷新,重新进行检测(ffplay采用的40ms定时刷新)。
代码如下,未到更新后的pts时间( is->frame_timer + dela),直接return:
if (av_gettime()/1000000.0 < is->frame_timer + delay)
return;
那接下来就是分析如何播放视频帧,就很简单了,只是这里多加了一个字幕流的处理:
static void video_image_display(VideoState *is)
{
VideoPicture *vp;
SubPicture *sp;
AVPicture pict;
SDL_Rect rect;
int i;
vp = &is->pictq[is->pictq_rindex];
if (vp->bmp) {
//字幕处理
if (is->subtitle_st) {}
}
//计算图像的显示区域
calculate_display_rect(&rect, is->xleft, is->ytop, is->width, is->height, vp);
//显示图像
SDL_DisplayYUVOverlay(vp->bmp, &rect);
//将pic队列的指针向前移动一个位置
pictq_next_picture(is);
}
VIDEO_PICTURE_QUEUE_SIZE 只设置为4,很快就会用完了。数据满了如何重新更新呢?
一旦检测到超出队列大小限制,就处于等待状态,直到pictq被取出消费,从而避免开启播放器,就把整个文件全部解码完,这样会代码会很吃内存。
static int queue_picture(VideoState *is, AVFrame *src_frame, double pts1, int64_t pos){
/* keep the last already displayed picture in the queue */
while (is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE - 2 &&
!is->videoq.abort_request) {
SDL_CondWait(is->pictq_cond, is->pictq_mutex);
}
SDL_UnlockMutex(is->pictq_mutex);
}
5.4 如何控制视频的播放和暂停?
static void stream_toggle_pause(VideoState *is)
{
if (is->paused) {
//由于frame_timer记下来视频从开始播放到当前帧播放的时间,所以暂停后,必须要将暂停的时间( is->video_current_pts_drift - is->video_current_pts)一起累加起来,并加上drift时间。
is->frame_timer += av_gettime() / 1000000.0 + is->video_current_pts_drift - is->video_current_pts;
if (is->read_pause_return != AVERROR(ENOSYS)) {
//并更新video_current_pts
is->video_current_pts = is->video_current_pts_drift + av_gettime() / 1000000.0;
}
//drift其实就是当前帧的pts和当前时间的时间差
is->video_current_pts_drift = is->video_current_pts - av_gettime() / 1000000.0;
}
//paused取反,paused标志位也会控制到图像帧的展示,按一次空格键实现暂停,再按一次就实现播放了。
is->paused = !is->paused;
}
特别说明:paused标志位控制着视频是否播放,当需要继续播放的时候,一定要重新更新当前所需要播放帧的pts时间,因为这里面要加上已经暂停的时间。
5.5 逐帧播放是如何做的?
在视频解码线程中,不断通过stream_toggle_paused,控制对视频的暂停和显示,从而实现逐帧播放:
static void step_to_next_frame(VideoState *is)
{
//逐帧播放时,一定要先继续播放,然后再设置step变量,控制逐帧播放
if (is->paused)
stream_toggle_pause(is);//会不断将paused进行取反
is->step = 1;
}
其原理就是不断的播放,然后暂停,从而实现逐帧播放:
static int video_thread(void *arg)
{
if (is->step)
stream_toggle_pause(is);
……………………
if (is->paused)
goto display;//显示视频
}
}
5.6 快进和后退
关于快进/后退,首先抛出两个问题:
1. 快进以时间为维度还是以帧数为维度来对播放进度进行控制呢?
2.一旦进度发生了变化,那么当前帧,以及AVFrame队列是否需要清零,整个对stream的流是否需要重新来进行控制呢?
ffplay中采用以时间为维度的控制方法。对于快进和后退的控制,都是通过设置VideoState的seek_req、seek_pos等变量进行控制。
do_seek:
//实际上是计算is->audio_current_pts_drift + av_gettime() / 1000000.0,确定当前需要播放帧的时间值
pos = get_master_clock(cur_stream);
pos += incr; //incr为每次快进的步进值,相加即可得到快进后的时间点
stream_seek(cur_stream, (int64_t)(pos AV_TIME_BASE), (int64_t)(incr AV_TIME_BASE), 0);
关于stream_seek的代码如下,其实就是设置VideoState的相关变量,以控制read_tread中的快进或后退的流程:
/* seek in the stream */
static void stream_seek(VideoState *is, int64_t pos, int64_t rel, int seek_by_bytes)
{
if (!is->seek_req) {
is->seek_pos = pos;
is->seek_rel = rel;
is->seek_flags &= ~AVSEEK_FLAG_BYTE;
if (seek_by_bytes)
is->seek_flags |= AVSEEK_FLAG_BYTE;
is->seek_req = 1;
}
}
stream_seek中设置了seek_req标志,就直接进入前进/后退控制流程了,其原理是调用avformat_seek_file函数,根据时间戳控制索引点,从而控制需要显示的下一帧:
static int read_thread(void *arg){
//当调整播放进度以后
if (is->seek_req) {
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;
//根据时间抽查找索引点位置,定位到索引点之后,下一帧的读取直接从这里开始,就实现了快进/后退操作
ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);
if (ret < 0) {
fprintf(stderr, "s: error while seeking\n", is->ic->filename);
} else {
//查找成功之后,就需要清空当前的PAcket队列,包括音频、视频和字幕
if (is->audio_stream >= 0) {
packet_queue_flush(&is->audioq);
packet_queue_put(&is->audioq, &flush_pkt);
}
if (is->subtitle_stream >= 0) {//处理字幕stream
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);
}
}
is->seek_req = 0;
eof = 0;
}
}
另外从上面代码中发现,每次快进后退之后都会对audioq、videoq和subtitleq进行flush清零,也是相当于重新开始,保证缓冲队列中的数据的正确性。
对于音频,开始仍然有些困惑,因为在暂停的时候,没有看到对音频的控制,是如何控制的呢?
后来发现,其实暂停的时候设置了is->paused变量,解复用和音频解码和播放都依赖于is->paused变量,所以音频和视频播放都随之停止了。
六、 这次分析ffplay代码的反省总结:
1.基础概念和原理积累,最开始接触FFmpeg,因为其涉及的概念很多,看起来有种无从下手的感觉。这时候必须从基本模块入手,逐步理解更多,一定的量积累,就会产生一些质变,更好的理解视频编解码机制;
2.一定要首先看懂代码总体架构和流程,随后针对每个细节点进行深入分析,会极大提高看代码效率。会画一些框图是非常重要的,比如下面这张,所以简要的流程图要比注重细节的uml图要方便得多;
3.看FFmpeg代码,在PC端上调试,会快捷很多。假如要在Android上,调用jni来看代码,效率就会很低。
参考文章:
基于ffmpeg的跨平台播放器实现
雷神的文章(多媒体入门开发必看)
以上是关于零基础读懂视频播放器控制原理: ffplay 播放器源代码分析的主要内容,如果未能解决你的问题,请参考以下文章