关联博客

ExoPlayer播放器剖析(一)进入ExoPlayer的世界
ExoPlayer播放器剖析(二)编写exoplayer的demo
ExoPlayer播放器剖析(三)流程分析—从build到prepare看ExoPlayer的创建流程
ExoPlayer播放器剖析(四)从renderer.render函数分析至MediaCodec
ExoPlayer播放器剖析(五)ExoPlayer对AudioTrack的操作
ExoPlayer播放器剖析(六)ExoPlayer同步机制分析
ExoPlayer播放器剖析(七)ExoPlayer对音频时间戳的处理
ExoPlayer播放器扩展(一)DASH流与HLS流简介

一、引言:
在上篇博客中,分析了exoplayer对audiotrack的操作,包括创建过程,读取媒体流数据到codec,codec再将解码出来的pcm数据送到audiotrack等。这篇博客分析视频送显机制,实际上也是整个ExoPlayer的同步机制。

二、同步机制分析:
1.得到精确的音视频时间间隔:
exoplayer的同步原理是视频去追音频,音频pts的获取是通过调用audiotrack的api接口拿到的,然后经过了比较复杂的校准之后,作为最终的音频时间戳送去给视频同步的,本博客不讨论音频时间戳的校准原理,我们先跟踪同步入手点。
还是回到我们熟悉的地方:exoplayer的大循环doSomeWork:

doSomeWork@ExoPlayerImplInternal:private void doSomeWork() throws ExoPlaybackException, IOException {/* 1.更新音频时间戳 */updatePlaybackPositions();...if (playingPeriodHolder.prepared) {/* 2.获取系统当前时间 */long rendererPositionElapsedRealtimeUs = SystemClock.elapsedRealtime() * 1000;.../* 3.核心处理方法 */renderer.render(rendererPositionUs, rendererPositionElapsedRealtimeUs);...}...
}

updatePlaybackPositions()函数会去更新并校准当前的音频时间戳,得到的结果也就是renderer.render()方法的第一个入参rendererPositionUs,SystemClock.elapsedRealtime()是Android的系统方法,返回的是设备从boot开始到当前的毫秒时差,所以,理解为当前的系统时间,将这两个时间传入到
renderer.render()中去做同步处理,接下来,我们进入到renderer.render()函数:

drainOutputBuffer@MediaCodecRenderer:private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs)throws ExoPlaybackException {...try {processedOutputBuffer =/* 处理解码完成后的输出buffer */processOutputBuffer(positionUs,elapsedRealtimeUs,codec,outputBuffer,outputIndex,outputBufferInfo.flags,/* sampleCount= */ 1,outputBufferInfo.presentationTimeUs,isDecodeOnlyOutputBuffer,isLastOutputBuffer,outputFormat);} catch (IllegalStateException e) {processEndOfStream();if (outputStreamEnded) {// Release the codec, as it's in an error state.releaseCodec();}return false;}...
}

这次我们进入到MediaCodecVideoRenderer的processOutputBuffer函数:

  @Overrideprotected boolean processOutputBuffer(long positionUs,long elapsedRealtimeUs,@Nullable MediaCodec codec,@Nullable ByteBuffer buffer,int bufferIndex,int bufferFlags,int sampleCount,long bufferPresentationTimeUs,boolean isDecodeOnlyBuffer,boolean isLastBuffer,Format format)throws ExoPlaybackException {Assertions.checkNotNull(codec); // Can not render video without codecif (initialPositionUs == C.TIME_UNSET) {initialPositionUs = positionUs;}/* 1.当成基准时间戳,通常是0 */long outputStreamOffsetUs = getOutputStreamOffsetUs();/* 2.对视频buffer队列中的当前帧时间戳进行一个校准 */long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs;if (isDecodeOnlyBuffer && !isLastBuffer) {skipOutputBuffer(codec, bufferIndex, presentationTimeUs);return true;}/* 3.音视频时间戳间隔 */long earlyUs = bufferPresentationTimeUs - positionUs;if (surface == dummySurface) {// Skip frames in sync with playback, so we'll be at the right frame if the mode changes.if (isBufferLate(earlyUs)) {skipOutputBuffer(codec, bufferIndex, presentationTimeUs);updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}return false;}long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000;/* 4.距离上次渲染的时间差 = 系统当前时间 - 上一帧的渲染时间 */long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs;boolean isStarted = getState() == STATE_STARTED;boolean shouldRenderFirstFrame =!renderedFirstFrameAfterEnable? (isStarted || mayRenderFirstFrameAfterEnableIfNotStarted): !renderedFirstFrameAfterReset;// Don't force output until we joined and the position reached the current stream.boolean forceRenderOutputBuffer =joiningDeadlineMs == C.TIME_UNSET&& positionUs >= outputStreamOffsetUs&& (shouldRenderFirstFrame|| (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs)));if (forceRenderOutputBuffer) {long releaseTimeNs = System.nanoTime();notifyFrameMetadataListener(presentationTimeUs, releaseTimeNs, format);if (Util.SDK_INT >= 21) {renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, releaseTimeNs);} else {renderOutputBuffer(codec, bufferIndex, presentationTimeUs);}updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}if (!isStarted || positionUs == initialPositionUs) {return false;}// Fine-grained adjustment of earlyUs based on the elapsed time since the start of the current// iteration of the rendering loop./* 5.距离准备渲染时的系统运行时间差 = 系统当前时间 - 准备渲染时时间 */long elapsedSinceStartOfLoopUs = elapsedRealtimeNowUs - elapsedRealtimeUs;/* 6.对音视频时间戳间隔进行校准:当前系统时间 - 准备渲染时的系统时间 */earlyUs -= elapsedSinceStartOfLoopUs;// Compute the buffer's desired release time in nanoseconds.long systemTimeNs = System.nanoTime();/* 7.计算出未经校准的下次送显预计时间(纳秒) */long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);// Apply a timestamp adjustment, if there is one./* 8.计算出校准后的下次送显实际时间(纳秒) */long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs);/* 9.再次校准音视频时间戳间隔: 校准后的实际送显时间 - 系统当前时间 */earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000;boolean treatDroppedBuffersAsSkipped = joiningDeadlineMs != C.TIME_UNSET;/* 10.根据音视频时间戳间隔与门限值进行对比确认是否是视频晚来500ms以上,是则放弃送显 */if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs, isLastBuffer)&& maybeDropBuffersToKeyframe(codec, bufferIndex, presentationTimeUs, positionUs, treatDroppedBuffersAsSkipped)) {return false;}/* 11.视频帧是否比音频晚来30ms以上,则丢帧 */else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs, isLastBuffer)) {if (treatDroppedBuffersAsSkipped) {skipOutputBuffer(codec, bufferIndex, presentationTimeUs);} else {dropOutputBuffer(codec, bufferIndex, presentationTimeUs);}updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}if (Util.SDK_INT >= 21) {// Let the underlying framework time the release./* 12.视频帧来早50ms以内,则可送显,否则进行下次循环 */if (earlyUs < 50000) {notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs);updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}} else {// We need to time the release ourselves./* 13.视频帧早来的时间小于30ms则会送显,如果小于11ms,则直接送显,大于11ms,则让线程睡眠10ms再去送显 */if (earlyUs < 30000) {if (earlyUs > 11000) {// We're a little too early to render the frame. Sleep until the frame can be rendered.// Note: The 11ms threshold was chosen fairly arbitrarily.try {// Subtracting 10000 rather than 11000 ensures the sleep time will be at least 1ms.Thread.sleep((earlyUs - 10000) / 1000);} catch (InterruptedException e) {Thread.currentThread().interrupt();return false;}}notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);renderOutputBuffer(codec, bufferIndex, presentationTimeUs);updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}}// We're either not playing, or it's not time to render the frame yet.return false;}

这个函数非常复杂,充满了各种时间变量,需要耐心去看,我都做了注释,先看注释一:

    /* 1.当成基准时间戳,通常是0 */long outputStreamOffsetUs = getOutputStreamOffsetUs();

变量outputStreamOffsetUs,该值通常情况下为0,可以把它理解为基准时间戳,再看注释二:

    /* 2.对视频buffer队列中的当前帧时间戳进行一个校准 */long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs;

bufferPresentationTimeUs是codec中解码出来的当前视频帧的时间戳,二者相减就是一个校准,实际上就是解码出来的当前帧时间戳。
接下来是注释三:

    /* 3.音视频时间戳间隔 */long earlyUs = bufferPresentationTimeUs - positionUs;

在上面我们已经讲了positionUs就是校准过后的音频时间戳,这二者相减,我们得到了一个最初的音视频时间间隔,该值正负都有可能,如果为正,表示视频帧先来,视频超前音频,如果为负,表示视频帧来迟了,音频超前,实际上,这里就可以去做最简单的同步了,如果视频帧来早了超过一个阈值,就等下个循环再去比对时间送显视频帧,如果视频帧来迟了且超过一定阈值,则直接丢弃本帧进行下一帧的送显判断,同步的原理并不复杂,但是,如果你想要把同步的时间点做到精确,难度就大了,显然,exoplayer不会做的这么简单。
注释四很好理解:

    /* 4.距离上次渲染的时间差 = 系统当前时间 - 上一帧的渲染时间 */long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs;

这个值在后面会用到。
注释五:

    /* 5.距离准备渲染时的系统运行时间差 = 系统当前时间 - 准备渲染时时间 */long elapsedSinceStartOfLoopUs = elapsedRealtimeNowUs - elapsedRealtimeUs;

这个值的计算就很有意思了,足以看出exoplayer对同步要求很精确,通过调试可以看到这个值通常为10000以内(单位微秒),elapsedRealtimeUs的值就是在doSomeWork中获取的那个系统时间值。
注释六:

    /* 6.对音视频时间戳间隔进行校准:当前系统时间 - 准备渲染时的系统时间 */earlyUs -= elapsedSinceStartOfLoopUs;

这里会对音视频时间戳进行第一次校准,这里为什么是自减,我们可以做个简单的带入:
earlyUs = bufferPresentationTimeUs - positionUs - (elapsedRealtimeNowUs - elapsedRealtimeUs);
因为在代码执行到这的这段时间,音频还是一直在写入数据的,实际上就相当于音频的时间戳变大了!
注释七:

    /* 7.计算出未经校准的下次送显预计时间(纳秒) */long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);

这里会有一个新的时间概念出现,送显时间,所谓送显时间,永远是一个预估值,因为你的代码永远无法计算出在哪一个标准时间视频帧就正好渲染出来了,毕竟,不同的设备,机器运行的时间都是不一样的,但是,我们需要对这个送显时间尽可能的预估准确,可以看到上面代码中,exoplayer先预估了一个大概的送显时间,就是当前的系统时间(微秒)+ 音视频时间间隔,显然,这个时间是没有校准,exoplayer还要去做校准。
注释八:

    /* 8.计算出校准后的下次送显实际时间(纳秒) */long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs);

通过调用adjustReleaseTime函数,传入codec解码出来的视频帧时间戳和未经校准的送显时间之后,最终计算出来的就是exoplayer认为的真实送显时间,对于校准送显时间有兴趣的可以看后面的详细分析。
注释九:

    /* 9.再次校准音视频时间戳间隔: 校准后的实际送显时间 - 系统当前时间 */earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000;

因为我们在上面已经认为了adjustedReleaseTimeNs 就是我们实际的送显时间,所以,这里我们需要对音视频时间间隔再做一个校准,而这个值,就是最终经过精确计算得出的音视频时间间隔,有个这个值,接下来要做的,就是我前面说的去做同步了,看音视频到底谁超前滞后,该等待下次送显还是丢帧。

2.丢帧还是下次送显判断:
拿到了精确的音视频时间间隔,我们看下代码中如何判断下次送显还是丢帧。
注释十:

    /* 10.根据音视频时间戳间隔与门限值进行对比确认是否是视频晚来500ms以上,是则放弃送显 */if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs, isLastBuffer)&& maybeDropBuffersToKeyframe(codec, bufferIndex, presentationTimeUs, positionUs, treatDroppedBuffersAsSkipped)) {return false;}

这个函数的名字我个人认为比较误导,我尝试在代码中找到哪里识别到的关键帧,然而并没有,shouldDropBuffersToKeyframe函数着重处理音视频时间差:

  protected boolean shouldDropBuffersToKeyframe(long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) {return isBufferVeryLate(earlyUs) && !isLastBuffer;}

看一下isBufferVeryLate具体实现:

  private static boolean isBufferVeryLate(long earlyUs) {// Class a buffer as very late if it should have been presented more than 500 ms ago.return earlyUs < -500000;}

这里的意思就是如果视频帧来得太迟了,时间超过500ms,那么就有可能清空当前buffer中的所有缓存,并重新初始化解码器,决定权是由maybeDropBuffersToKeyframe来做的。这个函数的实现我不是看的很懂,对于exoplayer在这的跳帧依据我不是很清楚,因为测试的码流样本不多,所以我无法确定,如果当码流中的某个pts本身就是有问题时,那么这种情况是否会如这里描述的一样,会清空buffer且重新初始化解码器。
注释十一:

    /* 11.视频帧是否比音频晚来30ms以上,则丢帧 */else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs, isLastBuffer)) {if (treatDroppedBuffersAsSkipped) {skipOutputBuffer(codec, bufferIndex, presentationTimeUs);} else {dropOutputBuffer(codec, bufferIndex, presentationTimeUs);}updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}

这里就是丢帧的判断依据,看下判断条件shouldDropOutputBuffer:

  protected boolean shouldDropOutputBuffer(long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) {return isBufferLate(earlyUs) && !isLastBuffer;}

继续跟进isBufferLate:

  private static boolean isBufferLate(long earlyUs) {// Class a buffer as late if it should have been presented more than 30 ms ago.return earlyUs < -30000;}

即如果视频帧迟来30ms以上,则判定为丢帧,至于丢帧还是跳帧,其本质是一样的,都是调用MediaCodec.releaseOutputBuffer()来实现的,我们以dropOutputBuffer为例:

  protected void dropOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) {TraceUtil.beginSection("dropVideoBuffer");/* 入参二为false则为丢帧 */codec.releaseOutputBuffer(index, false);TraceUtil.endSection();updateDroppedBufferCounters(1);}

如果视频来早了呢,看下exoplayer是如何处理的:

    if (Util.SDK_INT >= 21) {// Let the underlying framework time the release./* 12.视频帧来早50ms以内,则可送显,否则进行下次循环 */if (earlyUs < 50000) {notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs);updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}} else {// We need to time the release ourselves./* 视频帧早来的时间小于30ms则会送显,如果小于11ms,则直接送显,大于11ms,则让线程睡眠10ms再去送显 */if (earlyUs < 30000) {if (earlyUs > 11000) {// We're a little too early to render the frame. Sleep until the frame can be rendered.// Note: The 11ms threshold was chosen fairly arbitrarily.try {// Subtracting 10000 rather than 11000 ensures the sleep time will be at least 1ms.Thread.sleep((earlyUs - 10000) / 1000);} catch (InterruptedException e) {Thread.currentThread().interrupt();return false;}}notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);renderOutputBuffer(codec, bufferIndex, presentationTimeUs);updateVideoFrameProcessingOffsetCounters(earlyUs);return true;}}

根据不同的SDK版本操作会不一样,我们先看注释十二,如果视频来早了在50个ms以内,那么就去送显,调用的函数是renderOutputBufferV21:

  protected void renderOutputBufferV21(MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) {maybeNotifyVideoSizeChanged();TraceUtil.beginSection("releaseOutputBuffer");/* 送显:在releaseTimeNs这个时间点去送显 */codec.releaseOutputBuffer(index, releaseTimeNs);TraceUtil.endSection();lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000;decoderCounters.renderedOutputBufferCount++;consecutiveDroppedFrameCount = 0;maybeNotifyRenderedFirstFrame();}

如果视频早来了50个ms以上,则什么都不做,进入下次循环之后再来判断是否要送显。如果SDK的版本低于Android5.0,视频帧早来30个ms以内才考虑送显,如果小于11ms,则立即送显,如果大于11ms,则让线程休眠10ms后再去送显。最后需要注意的是,送显的时间是exoplayer校准过后的那个最终时间,也就是说我们希望在那个时间点surface上将显示那一帧图像,至于是否真的就一定在那个时间点去显示,那就是需要看运行的设备平台及Android系统了,不是exoplayer能管到的了。

同步总结:
Exoplayer的同步机制如下:首先根据音视频的时间戳得出一个初步的时间间隔,然后对这个时间间隔进行两次校准,第一次校准在代码的运行时间上做一个校准,第二次校准是基于确定的送显时间上进行,经过两次校准后将得到最终的音视频时间间隔,由这个值再决定是丢帧(视频帧太晚),还是下次送显(视频帧太早):

校准音视频时间间隔I----校准送显时间----校准音视频时间间隔II----送显/丢帧;

三、校准送显时间分析:
在上一节的同步分析中,没有去看送显时间的校准,这一节单独分析一下:

    /* 7.计算出未经校准的下次送显预计时间(纳秒) */long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);// Apply a timestamp adjustment, if there is one./* 8.计算出校准后的下次送显实际时间(纳秒) */long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs);

unadjustedFrameReleaseTimeNs 是一个最初的送显时间预计,adjustedReleaseTimeNs 是经过adjustReleaseTime函数处理之后的,来看adjustReleaseTime函数:

  /* framePresentationTimeUs:buffer队列中当前视频帧的时间戳, unadjustedReleaseTimeNs:待校准的送显预计时间(纳秒) */public long adjustReleaseTime(long framePresentationTimeUs, long unadjustedReleaseTimeNs) {long framePresentationTimeNs = framePresentationTimeUs * 1000;// Until we know better, the adjustment will be a no-op./* 校准后的buffer中当前视频帧时间戳 */long adjustedFrameTimeNs = framePresentationTimeNs;/* 校准后的下次视频送显时间 */long adjustedReleaseTimeNs = unadjustedReleaseTimeNs;/* 代码一开始进来不会走这里 */if (haveSync) {// See if we've advanced to the next frame.if (framePresentationTimeUs != lastFramePresentationTimeUs) {frameCount++;adjustedLastFrameTimeNs = pendingAdjustedFrameTimeNs;}/* 1.渲染至少6帧才考虑去校准送显时间 */if (frameCount >= MIN_FRAMES_FOR_ADJUSTMENT) {// We're synced and have waited the required number of frames to apply an adjustment.// Calculate the average frame time across all the frames we've seen since the last sync.// This will typically give us a frame rate at a finer granularity than the frame times// themselves (which often only have millisecond granularity)./* 2.自同步起,平均每帧持续时间 = (当前帧时间戳 - 开始做同步时的第一帧时间戳) / 目前校准过的同步总帧数 */long averageFrameDurationNs = (framePresentationTimeNs - syncFramePresentationTimeNs)/ frameCount;// Project the adjusted frame time forward using the average./* 3.当前帧送显时间 = 上一帧送显的时间(已确定) + 平均每帧的持续时间 */long candidateAdjustedFrameTimeNs = adjustedLastFrameTimeNs + averageFrameDurationNs;/* 4.将校准过后的送显时间替换掉buffer队列中给出的视频帧时间戳,看是否会出现偏差大于20ms的情况 */if (isDriftTooLarge(candidateAdjustedFrameTimeNs, unadjustedReleaseTimeNs)) {haveSync = false;} else {/* 5.校准后的buffer中当前视频帧时间戳 = 上一帧已显示的时间戳 + 平均每帧的持续时间 */adjustedFrameTimeNs = candidateAdjustedFrameTimeNs;/* 6.校准后的下次视频送显时间 = 开启同步时未经校准的第一帧预计送显时间 + 校准后的buffer中当前视频帧时间戳 - 开启同步校准时第一帧视频时间戳(即队列中读取来的) */adjustedReleaseTimeNs = syncUnadjustedReleaseTimeNs + adjustedFrameTimeNs- syncFramePresentationTimeNs;}} else {// We're synced but haven't waited the required number of frames to apply an adjustment.// Check drift anyway./* 校验是否偏移过大 */if (isDriftTooLarge(framePresentationTimeNs, unadjustedReleaseTimeNs)) {haveSync = false;}}}// If we need to sync, do so now./* 代码一开始是走这里的 */if (!haveSync) {syncFramePresentationTimeNs = framePresentationTimeNs;syncUnadjustedReleaseTimeNs = unadjustedReleaseTimeNs;frameCount = 0;haveSync = true;}/* 更新上一帧的视频时间戳(来自buffer队列)*/lastFramePresentationTimeUs = framePresentationTimeUs;/* 更新本次送显的视频帧时间戳(已校准) */pendingAdjustedFrameTimeNs = adjustedFrameTimeNs;if (vsyncSampler == null || vsyncDurationNs == C.TIME_UNSET) {return adjustedReleaseTimeNs;}long sampledVsyncTimeNs = vsyncSampler.sampledVsyncTimeNs;if (sampledVsyncTimeNs == C.TIME_UNSET) {return adjustedReleaseTimeNs;}// Find the timestamp of the closest vsync. This is the vsync that we're targeting./* 根据视频刷新率寻找最近的送显时间点 */long snappedTimeNs = closestVsync(adjustedReleaseTimeNs, sampledVsyncTimeNs, vsyncDurationNs);// Apply an offset so that we release before the target vsync, but after the previous one./* 提前送显:MediaCodec给的建议是最好提前两个vsync,但实际上exoplayer仅仅提前了0.8个vsync,原因不明 */return snappedTimeNs - vsyncOffsetNs;}

先要记住两个入参变量,framePresentationTimeUs为buffer队列中解码器解出来的视频帧时间戳,unadjustedReleaseTimeNs为待校准的送显时间。函数进来首先定义了两个校准后的变量用于后面做返回:

    /* 校准后的buffer中当前视频帧时间戳 */long adjustedFrameTimeNs = framePresentationTimeNs;/* 校准后的下次视频送显时间 */long adjustedReleaseTimeNs = unadjustedReleaseTimeNs;

代码一开始进来是不会走入第一个if的,而是下面这段:

    // If we need to sync, do so now./* 代码一开始是走这里的 */if (!haveSync) {syncFramePresentationTimeNs = framePresentationTimeNs;syncUnadjustedReleaseTimeNs = unadjustedReleaseTimeNs;frameCount = 0;haveSync = true;}

做一些变量的赋值并打开同步校验。看下同步校验里面的代码:

 /* 1.渲染至少6帧才考虑去校准同步时间 */if (frameCount >= MIN_FRAMES_FOR_ADJUSTMENT) {...
}

exoplayer要确保至少连续6帧都是在合理范围内才会去校准送显时间,那么如何判断是否在合理范围内,根据isDriftTooLarge函数来完成:

/* frameTimeNs:buffer队列中当前视频帧的时间戳 */
/* unadjustedReleaseTimeNs:待校准的送显时间 */private boolean isDriftTooLarge(long frameTimeNs, long releaseTimeNs) {/* buffer队列中视频帧总间隔 = buffer队列中当前帧时间戳 - 开启同步校准时第一帧视频时间戳(即队列中读取来的)*/long elapsedFrameTimeNs = frameTimeNs - syncFramePresentationTimeNs;/* 送显视频帧总间隔 = 当前帧待校准的送显时间 - 开启同步时待校准的第一帧送显时间 */long elapsedReleaseTimeNs = releaseTimeNs - syncUnadjustedReleaseTimeNs;/* MAX_ALLOWED_DRIFT_NS == 20_000_000 */return Math.abs(elapsedReleaseTimeNs - elapsedFrameTimeNs) > MAX_ALLOWED_DRIFT_NS;}

这个函数看起来很难理解,elapsedFrameTimeNs记录的是codec给过来的当前视频帧与开启同步校验时的第一帧的总时间间隔,elapsedReleaseTimeNs记录的是待校准的送显时间总间隔,两者做个差值的绝对值对比,如果超过20ms,那么exoplayer认为偏差波动太大,失去了做送显时间校准的意义,因此,至少要连续6帧都是稳定状态后,才会去考虑做校准,看下校准是如何做的.
注释二:

/* 2.自同步送显校准起起,平均每帧持续时间 = (当前帧时间戳 - 开始做同步时的第一帧时间戳) / 目前校准过的同步总帧数 */
long averageFrameDurationNs = (framePresentationTimeNs - syncFramePresentationTimeNs)
/ frameCount;

先推算出一个从同步校准算起,平均每帧的持续时间,有了这个值之后,就可以算出上一帧显示完之后的理论时间,也就是当前帧显示的一个时间:
注释三:

/* 3.当前帧送显时间 = 上一帧送显的时间(已确定) + 平均每帧的持续时间 */
long candidateAdjustedFrameTimeNs = adjustedLastFrameTimeNs + averageFrameDurationNs;

推理出了一个当前帧的送显时间之后,还需要去做一次偏差波动的检测,看送检校准是否有意义:

/* 4.将校准过后的送显时间替换掉buffer队列中给出的视频帧时间戳,看是否会出现偏差大于20ms的情况 */
if (isDriftTooLarge(candidateAdjustedFrameTimeNs, unadjustedReleaseTimeNs)) {haveSync = false;
}

注释五和注释六:

/* 5.校准后的buffer中当前视频帧时间戳 = 上一帧已显示的时间戳 + 平均每帧的持续时间 */
adjustedFrameTimeNs = candidateAdjustedFrameTimeNs;
/* 6.校准后的下次视频送显时间 = 开启同步时未经校准的第一帧预计送显时间 + 校准后的buffer中当前视频帧时间戳 - 开启同步校准时第一帧视频时间戳(即队列中读取来的) */
adjustedReleaseTimeNs = syncUnadjustedReleaseTimeNs + adjustedFrameTimeNs
- syncFramePresentationTimeNs;

将函数开头的两个校准值重新赋值与计算之后,还需要去做最后一个操作,就是寻找最近的送显点。
这里又出现了了一个新的概念,送显点,是的,物理设备在显示的时候,并不是等你有数据送过去了才会给你显示,它是自动刷新的,也就是我们经常听到的刷新率,比如显示器60Hz,144Hz,我们可以计算一下对应的刷新间隔:

vsyncDuration_60Hz = 1000 / 60 = 16.7ms
vsyncDuration_144Hz = 1000 / 144 = 6.94ms

有了这个概念之后,我们就知道,计算出来的送显时间终究是一个理想值,我们需要找一个最近的物理设备刷新时间点去渲染视频,先看下exoplayer是如何计算刷新时间间隔的:

updateDefaultDisplayRefreshRateParams@VideoFrameReleaseTimeHelper:private void updateDefaultDisplayRefreshRateParams() {// Note: If we fail to update the parameters, we leave them set to their previous values.Display defaultDisplay = windowManager.getDefaultDisplay();if (defaultDisplay != null) {double defaultDisplayRefreshRate = defaultDisplay.getRefreshRate();/* 垂直同步时间间隔 = 1秒钟 / 刷新率(60.0)*/vsyncDurationNs = (long) (C.NANOS_PER_SECOND / defaultDisplayRefreshRate);/* 提前送显的时间:VSYNC_OFFSET_PERCENTAGE = 80 */vsyncOffsetNs = (vsyncDurationNs * VSYNC_OFFSET_PERCENTAGE) / 100;}}

我们看下这两个变量的计算,第一个大家都很好理解了,每次刷新间隔大约16.7ms,这里为了精确计算的是纳秒。第二个变量vsyncOffsetNs 大家比较疑惑,为什么要提前送显,这是因为为了保证送显的准时和高质量,Google建议提前送显,送显的函数是MediaCodec.releaseOutputBuffer(),看下Android官方文档对这个接口的描述:

Android官方文档对该接口的描述:
for best performance and quality, call this method when you are about two VSYNCs’ time before the desired render time. For 60Hz displays, this is about 33 msec.

Google的建议是提前两个刷新点(即两个垂直同步信号)就调用这个接口去送显。显然exoplayer并没有这么做,而是自己设定了一个固定值,0.8个vsync。至于这么设定的原因,不得而至,估计是做了大量测试得出的一个最优值吧。
回到代码,我们看下代码中是如何找到最近的送显时间点的:

/* 根据视频刷新率寻找最近的送显时间点 */
long snappedTimeNs = closestVsync(adjustedReleaseTimeNs, sampledVsyncTimeNs, vsyncDurationNs);

adjustedReleaseTimeNs我们已经知道了,是最终计算出来的理论送显时间点,vsyncDurationNs我们也知道了,垂直同步信号的时间间隔,sampledVsyncTimeNs这个值是什么?如何来的?
在代码中追一下这个值:

long sampledVsyncTimeNs = vsyncSampler.sampledVsyncTimeNs;

vsyncSampler.sampledVsyncTimeNs的更新地方:

    @Overridepublic void doFrame(long vsyncTimeNs) {/* 记录物理设备渲染本帧的开始时间 */sampledVsyncTimeNs = vsyncTimeNs;choreographer.postFrameCallbackDelayed(this, CHOREOGRAPHER_SAMPLE_DELAY_MILLIS);}

如果再去追doFrame,exoplayer的代码中是找不到的,因为这是个复写的方法,其来自接口FrameCallback,看一下Google官方对这个接口及方法的描述:

总之,就是在view开始绘制新的一帧视频时会记录该时间,然后回调给程序,那么,对应代码中的入参,我们也就清楚了,sampledVsyncTimeNs就是记录的物理设备在绘制上一帧时的开始时间。下面跟进到代码closestVsync中:

  private static long closestVsync(long releaseTime, long sampledVsyncTime, long vsyncDuration) {/* 1.计算需要刷新几次 */long vsyncCount = (releaseTime - sampledVsyncTime) / vsyncDuration;/* 2.计算出一个真实刷新时间 */long snappedTimeNs = sampledVsyncTime + (vsyncDuration * vsyncCount);long snappedBeforeNs;long snappedAfterNs;/* 3.计算出两种情况下距离送显时间最近的前后两个刷新点 */if (releaseTime <= snappedTimeNs) {snappedBeforeNs = snappedTimeNs - vsyncDuration;snappedAfterNs = snappedTimeNs;} else {snappedBeforeNs = snappedTimeNs;snappedAfterNs = snappedTimeNs + vsyncDuration;}/* 4.计算送显时间与前后两个刷新点的时间之差 */long snappedAfterDiff = snappedAfterNs - releaseTime;long snappedBeforeDiff = releaseTime - snappedBeforeNs;/* 5.哪个刷新点近就选哪个作为最终的送显时间 */return snappedAfterDiff < snappedBeforeDiff ? snappedAfterNs : snappedBeforeNs;}

再描述一遍三个入参:

releaseTime:校准过后的理论送显时间
sampledVsyncTime:物理设备在绘制上一帧的开始时间
vsyncDuration:垂直同步信号时间间隔

通过计算理论送显时间与上一帧绘制时间的差值可算出会经历几个刷新点,然后在上一帧物理设备绘制时间点的基础上计算出当前帧的物理设备绘制时间,但是需要注意的是,这个物理设备绘制时间可能不是距离我们理想送显时间最近的垂直同步信号点,所以我们需要找到理论送显时间前后的两个垂直同步时间点,找到之后,再对比理想的送显时间,谁近就选谁。这样,就完成了最终送显时间的确定:

别忘了还有最后一步事情要做,那就是提前送,也就是adjustReleaseTime函数的最后一行代码:

/* 提前送显:MediaCodec给的建议是最好提前两个vsync,但实际上exoplayer仅仅提前了0.8个vsync,原因不明 */
return snappedTimeNs - vsyncOffsetNs;

至此,校准送显时间的分析就全部做完了。

总结:
校准送显时间的原理分三步:
1.校准理论送显时间;
2.根据刷新率计算出距离理论送显时间最近的垂直同步信号时间点,作为最终的送显时间;
3.提前若干个垂直同步信号来送显;

四、总结:

exoplayer的同步机制总体比较复杂,需要掌握以下几点:

1.如何校准音视频时间间隔,这个是作为丢帧还是送显的最终依据;
2.如何校准理论送显时间;
3.如何确定最终送显时间;

ExoPlayer播放器剖析(六)ExoPlayer同步机制分析相关推荐

  1. ExoPlayer播放器剖析(五)ExoPlayer对AudioTrack的操作

    关联博客 ExoPlayer播放器剖析(一)进入ExoPlayer的世界 ExoPlayer播放器剖析(二)编写exoplayer的demo ExoPlayer播放器剖析(三)流程分析-从build到 ...

  2. ijkplayer播放器剖析(四)音频解码与音频输出机制分析

    ijkplayer播放器剖析系列文章: ijkplayer播放器剖析(一)从应用层分析至Jni层的流程分析 ijkplayer播放器剖析(二)消息机制分析 ijkplayer播放器剖析(三)音频解码与 ...

  3. ExoPlayer播放器 开发者指南(官方权威指南译文)

    前言   因为公司项目原因,目前开始研究ExoPlayer的原理及实现.其中对DRM更是有所涉及,因此自己也好借此机会扩展自己的音视频知识,同时写出一些自己的技术总结与分享,希望对其他学习此播放器的朋 ...

  4. android vr播放器 开发,Android应用开发之Android VR Player(全景视频播放器)- ExoPlayer播放器MPEG-DASH视频播放...

    本文将带你了解Android应用开发之Android VR Player(全景视频播放器)- ExoPlayer播放器MPEG-DASH视频播放,希望本文对大家学Android有所帮助. Androi ...

  5. 基于exoplayer播放器的高斯模糊视频滤镜

    最近项目需求,视频滤镜要用高斯模糊.奈何网上全是图片高斯模糊,且模糊的强度不够,效果并不是自己需要的. 于是,打算自己写一个. exoPlayer播放器自带滤镜,所以用这个播放器来做. 滤镜的话,用到 ...

  6. ExoPlayer播放器在瑞芯微rk3228CPU播放H264编码格式1080P媒体资源编解码器解码失败问题

    华为E6108V9E部分机顶盒播放H264编码格式1920*1080分辨率媒体资源编解码器解码失败问题总结** 设备信息:华为E6108V9E cpu:rk3228 arm-v7 API19 在使用E ...

  7. 基于exoplayer播放器的高斯模糊视频滤镜,整合aar文件,给伸手党

    接入步骤如下: 接入步骤 1.aar文件拷贝至app下libs文件夹内 2.在app下的build.gradle中添加(最外层) repositories {flatDir {dirs 'libs'} ...

  8. 音视频从入门到精通——FFmpeg 播放器实现音视频同步的三种方式

    老人们经常说,播放器对音频和视频的播放没有绝对的静态的同步,只有相对的动态的同步,实际上音视频同步就是一个"你追我赶"的过程. 音视频的同步方式有 3 种,即:音视频分别向系统时钟 ...

  9. 数字媒体播放器行业调研报告 - 市场现状分析与发展前景预测

    数字媒体播放器市场的企业竞争态势 该报告涉及的主要国际市场参与者有Google.Roku.Sony.Asus.Microsoft.Samsung Electronics.Amazon.Apple.Ph ...

最新文章

  1. 菲律宾政府网站被黑!
  2. 现在的桥都会做仰卧起坐了!中国首座3D打印桥亮相上海
  3. DIY Ruby CPU 分析——Part III
  4. UVA 12166 Equilibrium Mobile
  5. Wave-Share -无服务器,点对点,通过声音共享本地文件
  6. MySQL group_concat()函数
  7. 查询工资最低的3名员工的职工工号、姓名和收入_工资条6个常识必须掌握,事关你的权益!...
  8. mysql5.7的存储过程_MySql5.7命令笔记(三)mysql存储过程命令
  9. 小米9全面现货还降价,米粉却心情复杂?
  10. 【李宏毅2020 ML/DL】P3 Regression - Case Study
  11. eclipse主题改变
  12. 宏病毒专杀软件测试大乐,推荐几个宏病毒专杀工具
  13. 2022年11月(下半年)信息系统项目管理师考试-综合知识真题及解析
  14. 《炬丰科技-半导体工艺》激光增强湿法蚀刻制造的大规模高质量玻璃微透镜阵列
  15. radix在Character.MIN_RADIX与Character.MAX_RADIX之间
  16. 硬核!教你三种方法,实现微信自定义修改地区!
  17. 利用LSTM 做文本分类
  18. 浪涌电流和浪涌电压解析
  19. 2021年中国压力-容积回路系统市场趋势报告、技术动态创新及2027年市场预测
  20. CSS中颜色、样式规则(字体样式、列表样式、表格样式)

热门文章

  1. 给高特键轴开盖的操作技巧
  2. Dev-c++怎么设置背景色
  3. 我喜欢这首歌......
  4. 快手本地生活可以入场吗
  5. Android--- Drawer and Tab Navigation with ViewPager
  6. SpringBoot集成onlyoffice实现word文档编辑保存 [ 转载 ]
  7. C语言简介及进制换算
  8. 高并发解决方案——Redis(一)
  9. SQL Server 2005 版本介绍及所谓“企业管理器”问题
  10. winsxs探索之组件的本质:文件与注册表