当前位置: 首页 > news >正文

Android 13 - Media框架(9)- NuPlayer::Decoder

这一节我们将了解 NuPlayer::Decoder,学习如何将 MediaCodec wrap 成一个强大的 Decoder。这一节会提前讲到 MediaCodec 相关的内容,如果看不大懂可以先跳过此篇。原先觉得 Decoder 部分简单,越读越发现自己的无知,Android 源码真是一个巨大的宝库!
ps:本文中大写的 Decoder 指代的是 NuPlayer::Decoder,小写的 decoder指代 mediacodec 以及底层的真正的解码器。

1、DecoderBase

首先看 NuPlayer::Decoder 的基类 DecoderBase

struct NuPlayer::DecoderBase : public AHandler {explicit DecoderBase(const sp<AMessage> &notify);void configure(const sp<AMessage> &format);void init();void setParameters(const sp<AMessage> &params);// Synchronous call to ensure decoder will not request or send out data.void pause();void setRenderer(const sp<Renderer> &renderer);virtual status_t setVideoSurface(const sp<Surface> &) { return INVALID_OPERATION; }void signalFlush();void signalResume(bool notifyComplete);void initiateShutdown();virtual sp<AMessage> getStats() {return mStats;}
protected:virtual void onMessageReceived(const sp<AMessage> &msg);virtual void onConfigure(const sp<AMessage> &format) = 0;virtual void onSetParameters(const sp<AMessage> &params) = 0;virtual void onSetRenderer(const sp<Renderer> &renderer) = 0;virtual void onResume(bool notifyComplete) = 0;virtual void onFlush() = 0;virtual void onShutdown(bool notifyComplete) = 0;void onRequestInputBuffers();virtual bool doRequestBuffers() = 0;
}

DecoderBase 定义了 NuPlayer 可以调用 Decoder 的所有接口,可以看到接口数量相当稀少,并没有 start、stop、reset、seek 等等方法,这时候可能就会有人有疑问了,我上层调用的这些接口为什么底层却没有了呢?其实在之前的文章中我们已经对部分接口做了解释,这里就不再赘述。

来了解下这些接口都是用来干什么、怎么用的:

  • 构造函数:传入一个 AMessage 对象,用于上抛事件于状态;
  • configure:传入 Source 在 prepare 过程中 parse 出的 format 信息,format 信息包括 mime type、surface、secure、width、height、crypto、csd 等等信息;创建 MediaCodec 实例,配置并启动;
  • init:将自身注册到 ALooper 当中;
  • setParameters:给 decoder 设定上层传下的参数;
  • pause:这个方法其实并没有用,后面详细了解它为什么没有用;
  • setRenderer:设定 render,decoder 解出的数据将送到 render 中做 avsync,如果是 audio 数据将直接写入到 AudioTrack;
  • setVideoSurface:重新设定 surface,audio decoder 并不需要这个方法;
  • signalFlush:flush,刷新 decoder 的 input/ouput 缓冲区;
  • signalResume:恢复 decoder 的解码流程;
  • initiateShutdown:停止解码流程并释放相关资源;
  • getStats:获取当前 Decoder 的状态,例如 format 信息,当前解出的帧数,丢弃的帧数等等信息。
  • 其他:onConfigure 等等方法将由具体的 Decoder 来实现,如果 audio 不走 offload, audio / video decoder 会走相同的流程。

2、Decoder 创建与启动

从 NuPlayer 源码中我们可以知道,调用 start 方法后会创建 Decoder,这里的 Decoder 是继承于 DecoderBase 的,接着调用 Decoder.init 和 Decoder.configure,这里Decoder 就完成启动了:

status_t NuPlayer::instantiateDecoder(bool audio, sp<DecoderBase> *decoder, bool checkAudioModeChange) {sp<AMessage> format = mSource->getFormat(audio);*decoder = new Decoder(notify, mSource, mPID, mUID, mRenderer, mSurface, mCCDecoder);(*decoder)->init();(*decoder)->configure(format);
}

init 方法很简单,就做了把 AHandler 注册到 ALooper 中这一件事。在这里我要抛出2个问题, registerHandler 注册的 this 指代的是谁?

void NuPlayer::DecoderBase::init() {mDecoderLooper->registerHandler(this);
}

后面 onRequestInputBuffers 中这个消息会先经 Decoder::onMessageReceived 处理还是 先经 DecoderBase::onMessageReceived 来处理呢?如果不太确定答案是什么可以搜索多态。

void NuPlayer::DecoderBase::onRequestInputBuffers() {....sp<AMessage> msg = new AMessage(kWhatRequestInputBuffers, this);msg->post(10 * 1000LL);
}

继续往下看,configure 最终会调用到 onConfigure 方法中:

void NuPlayer::Decoder::onConfigure(const sp<AMessage> &format) {++mBufferGeneration;AString mime;CHECK(format->findString("mime", &mime));mIsAudio = !strncasecmp("audio/", mime.c_str(), 6);mComponentName = mime;mComponentName.append(" decoder");mCodec = MediaCodec::CreateByType(mCodecLooper, mime.c_str(), false /* encoder */, NULL /* err */, mPid, mUid, format);int32_t secure = 0;if (format->findInt32("secure", &secure) && secure != 0) {if (mCodec != NULL) {mCodec->getName(&mComponentName);mComponentName.append(".secure");mCodec->release();mCodec = MediaCodec::CreateByComponentName(mCodecLooper, mComponentName.c_str(), NULL /* err */, mPid, mUid);}}err = mCodec->configure(format, mSurface, crypto, 0 /* flags */);rememberCodecSpecificData(format);sp<AMessage> reply = new AMessage(kWhatCodecNotify, this);mCodec->setCallback(reply);err = mCodec->start();
}
  1. 查找 format 中的 mime,必须要有这个;
  2. 调用 CreateByType 创建 MediaCodec 实例,如果 format 中带有 secure 字段,那么就调用 CreateByComponentName 创建 Secure Component;
  3. 调用 configure 配置 MediaCodec,需要传入码流的 format,format 中需要有什么我们后面再看,这里传入的 surface 不为 NULL,因为 NuPlayer 中有判断,如果 surface 为 NULL 就不会为 Video 创建 Codec;
  4. 存储 format 中的 codec specific data(csd buffer),这些 buffer 记录了码流的信息,例如 h264,h265码流中的sps pps 等信息,对于有些 decoder 一定需要传入该信息,而有些 decoder 可以自己从码流中 parse 出来这些信息,具体要看各家的 decoder 实现;
  5. 给 MediaCodec 注册 callback,让它以异步的方式工作;
  6. 调用 start 方法启动 decoder,开启整个数据读取、数据解码 以及 数据渲染流程。

以下内容是个人拙见,讲的比较啰嗦,如不喜欢直接跳过就好。

除了这些,我们还要看一个成员 mBufferGeneration,这个东西是干什么的呢?其实我们之前已经说过 Media 这边用了跟多 generation 的思想或者说是trick,那这里的 generation 是用来干什么的呢?

我们搜索代码中的 mBufferGeneration,发现它的值会在 onConfiguredoFlushonShutdownhandleError 中做修改,这四个方法会有一个共同点,它们都会去操作 MediaCodec,改变 MediaCodec 状态,从而影响到 MediaCodec Buffer 的状态。

我们再看 mBufferGeneration 会在哪里使用:

bool NuPlayer::Decoder::isStaleReply(const sp<AMessage> &msg) {int32_t generation;CHECK(msg->findInt32("generation", &generation));return generation != mBufferGeneration;
}void NuPlayer::Decoder::onMessageReceived(const sp<AMessage> &msg) {switch (msg->what()) {case kWhatRenderBuffer:{if (!isStaleReply(msg)) {onRenderBuffer(msg);}break;}}
}

video output buffer 在送入 Renderer 做完 avsync 后送回来做渲染时会判断当前的 mBufferGeneration 是否有发生变化,这里这么做有什么用意呢?

我的理解是这样:output buffer 送到 Renderer 中处理完成后,Renderer 会调用渲染相关的方法,但是这个时候 buffer 的状态可能已经发生了变化,例如做了 flush、或者是 shutdown,buffer 不需要再被处理,用 mBufferGeneration 来判断就可以跳过处理步骤。

将一件事情交由其他组件处理时,记录当前的generation ,当事件处理结束并=返回到当前组件时,根据当前的 generation 决定内容是否需要丢弃。Android 在 ACodec、NuPlayer::Source、Renderer 等实现中都用了 generation 技巧来处理状态转换时的事务。

以上是我看第一遍代码时对 generation 的理解,再次翻阅又有了些新的感悟:

NuPlayer 中大量使用了 ALooper、AHandler 异步消息机制,这里的异步是相对与调用者而言的,譬如 NuPlayer 调用 Decoder 的 configure 方法,NuPlayer 调用完就结束了,这时候 Decoder 内的 MediaCodec 对象可能还没有创建,这就是异步。但是对于 Decoder 来说,所有的调用(发送来的消息)都是由 Looper 中的线程一条一条处理,所以 Decoder 内部是同步处理的。

为什么要说这些呢?我们来看看都有谁会给 Decoder 发送消息:NuPlayer、Renderer、MediaCodec 它们都可能同时,或者先后向 Decoder 发送消息,这会引发什么问题呢?以 Renderer 为例子:

    sp<AMessage> reply = new AMessage(kWhatRenderBuffer, this);reply->setSize("buffer-ix", index);reply->setInt32("generation", mBufferGeneration);reply->setSize("size", size);

Decoder 收到 MediaCodec 发送的 CB_OUTPUT_AVAILABLE 事件后,会将 mBufferGeneration 存到 msg 中,并且传递给 Renderer,Renderer 做完同步后会将消息重新发送到 Decoder。但是如果同步的过程中,上层调用了 reset,Decoder 也对事件做了处理,那么 Decoder 将不能再去处理 Renderer 发过来的渲染消息(整个流程已经停止,component 已经被释放了)。

请添加图片描述

generation 起着状态记录的作用,当状态发生改变后,依赖该状态的消息将会不再处理。它和一些具体的状态,例如 MediaPlayer.cpp 中的状态使用方法类似,但是 generation 的使用更为简单,它不关注具体是什么状态,只关注影响改变 Decoder 状态的方法是否被调用。

3、Start

我们引用上一小节中关于 start 的描述: 开启整个数据读取、数据解码 以及 数据渲染流程,start 不仅仅是启动了 MediaCodec,还驱动了所有组件的运行,一起来看看吧。

先来看关键的 MediaCodec Callback:

void NuPlayer::Decoder::onMessageReceived(const sp<AMessage> &msg) {switch (msg->what()) {case kWhatCodecNotify:{int32_t cbID;CHECK(msg->findInt32("callbackID", &cbID));switch (cbID) {case MediaCodec::CB_INPUT_AVAILABLE:{int32_t index;CHECK(msg->findInt32("index", &index));handleAnInputBuffer(index);break;}case MediaCodec::CB_OUTPUT_AVAILABLE:{int32_t index;size_t offset;size_t size;int64_t timeUs;int32_t flags;CHECK(msg->findInt32("index", &index));CHECK(msg->findSize("offset", &offset));CHECK(msg->findSize("size", &size));CHECK(msg->findInt64("timeUs", &timeUs));CHECK(msg->findInt32("flags", &flags));handleAnOutputBuffer(index, offset, size, timeUs, flags);break;}}}}
}

MediaCodec 通过 kWhatCodecNotify 将消息发送到 Decoder 来处理,用 callbackID 来区分发送过来的内容,常用的有 CB_INPUT_AVAILABLECB_OUTPUT_AVAILABLECB_ERRORCB_OUTPUT_FORMAT_CHANGED 这四个,我们这里只看 input 和 output。

3.1、CB_INPUT_AVAILABLE

收到 MediaCodec 上抛的 input 事件后,会调用 handleAnInputBuffer 方法,传入参数为 input buffer id。

bool NuPlayer::Decoder::handleAnInputBuffer(size_t index) {// 判断是否处在 处理不连续码流 的状态if (isDiscontinuityPending()) {return false;}sp<MediaCodecBuffer> buffer;mCodec->getInputBuffer(index, &buffer);if (index >= mInputBuffers.size()) {for (size_t i = mInputBuffers.size(); i <= index; ++i) {mInputBuffers.add();mInputBufferIsDequeued.add();mMediaBuffers.editItemAt(i) = NULL;mInputBufferIsDequeued.editItemAt(i) = false;}}mInputBuffers.editItemAt(index) = buffer;mInputBufferIsDequeued.editItemAt(index) = true;// 如果有码流不连续的情况,恢复播放后重新发送csd bufferif (!mCSDsToSubmit.isEmpty()) {sp<AMessage> msg = new AMessage();msg->setSize("buffer-ix", index);sp<ABuffer> buffer = mCSDsToSubmit.itemAt(0);msg->setBuffer("buffer", buffer);mCSDsToSubmit.removeAt(0);if (!onInputBufferFetched(msg)) {handleError(UNKNOWN_ERROR);return false;}return true;}// 如果有 buffer 没有成功写入 mediacodec 的情况,尝试重新写入while (!mPendingInputMessages.empty()) {sp<AMessage> msg = *mPendingInputMessages.begin();if (!onInputBufferFetched(msg)) {break;}mPendingInputMessages.erase(mPendingInputMessages.begin());}// 如果在 尝试重新写入的过程中,把当前 buffer 也顺带处理了,那么就直接返回if (!mInputBufferIsDequeued.editItemAt(index)) {return true;}// 将 buffer 记录到 mDequeuedInputBuffers 中mDequeuedInputBuffers.push_back(index);// 尝试从 source 获取数据,填充数据,并送回 decoderonRequestInputBuffers();return true;
}

由于这里涉及到 Source 数据的获取 和 input buffer 写入两部分内容,同时考虑了数据获取失败和数据写入失败的问题,所以 input buffer 的处理流程看起来会比较复杂,不过我们不要着急,我们一步步解析。

首先来看 handleAnInputBuffer 做的事情(省略了部分注释的内容):

  1. 判断当前是否正在处理码流不连续的情况;
  2. 从 MediaCodec 获取对应索引的 MediaCodecBuffer
  3. 将获取的到的 MediaCodecBuffer 按照索引记录到 mInputBuffers 列表当中;
  4. 创建一个列表 mInputBufferIsDequeued,记录索引对应的 input buffer 是否有出队列;
  5. 每次进行了 flush,需要送 csd buffer 给 decoder,记住是每次!其实不是所有的 decoder flush 之后都要 csd buffer 的,之前被坑过!
  6. 先处理被延迟的 input buffer,为什么延迟后面再说;
  7. 处理延迟 buffer 时可能会把当前的 input buffer 处理掉,如果记录出队列的列表中对应位置为 false 说明已经被处理过了;
  8. 如果 input buffer 没有处理,那么再把它加入到一个没有处理的列表当中 mDequeuedInputBuffers
  9. 调用 onRequestInputBuffers 从 Source 读取数据;

这里涉及四个方法,名字长得比较像,先来介绍下它们是做什么用的:

  • onRequestInputBuffers:从 Source 请求 input data;
  • doRequestBuffers:onRequestInputBuffers 的 内部实现;
  • onInputBufferFetched:成功从Source 获取到数据,填充到 input buffer 并返回给 MediaCodec;
  • fetchInputData:onInputBufferFetched 的内部实现;

我们从 onRequestInputBuffers 看起,这个方法实现在 DecoderBase 中,权限为 protected:

void NuPlayer::DecoderBase::onRequestInputBuffers() {// 判断是否处在 处理不连续码流 的状态if (mRequestInputBuffersPending) {return;}// doRequestBuffers() return true if we should request more data// 从 Source 请求数据,如果失败返回 true,发送一条延时消息,retryif (doRequestBuffers()) {// retry 时不会继续处理 获取数据的调用mRequestInputBuffersPending = true;sp<AMessage> msg = new AMessage(kWhatRequestInputBuffers, this);msg->post(10 * 1000LL);}
}

可以看到 onRequestInputBuffers 就是封装了 doRequestBuffers,所以它们的作用是相同的,但是一个是基类的方法,一个是子类的方法。这么设计有什么用呢?我的理解是这样:每个 Decoder 都需要从 Source 获取数据,所以把获取数据的方法 onRequestInputBuffers 定义在基类当中,但是每个 Decoder 获取数据的方式或者流程又不一样,所以把 doRequestBuffers 放到子类中实现。子类调用父类的 onRequestInputBuffers 方法使用父类定义的数据读取流程,流程中调用子类的数据读取实现,这样就一举两得,既统一了读取流程又区分了读取方式。

另外这里还有 mRequestInputBuffersPending 的用法值得学习,如果从 Source 获取数据失败了,那么需要做延时等待,并且重新尝试获取,等待的过程中我们并不希望外部能够再调用到 doRequestBuffers 获取数据,所以将 mRequestInputBuffersPending 置为 true,表示等待的状态,这个状态只有处理 retry 消息时才能够解除。

bool NuPlayer::Decoder::doRequestBuffers() {if (isDiscontinuityPending()) {return false;}status_t err = OK;while (err == OK && !mDequeuedInputBuffers.empty()) {size_t bufferIx = *mDequeuedInputBuffers.begin();sp<AMessage> msg = new AMessage();msg->setSize("buffer-ix", bufferIx);err = fetchInputData(msg);if (err != OK && err != ERROR_END_OF_STREAM) {// if EOS, need to queue EOS bufferbreak;}mDequeuedInputBuffers.erase(mDequeuedInputBuffers.begin());if (!mPendingInputMessages.empty()|| !onInputBufferFetched(msg)) {mPendingInputMessages.push_back(msg);}}return err == -EWOULDBLOCK&& mSource->feedMoreTSData() == OK;
}

doRequestBuffers 里有个循环,会把当前 mDequeuedInputBuffers 中的所有 input buffer 都处理掉。这里有个问题,什么时候 mDequeuedInputBuffers 中 buffer 的数量会大于 1 呢?从 source 读取数据失败时会直接返回,没能调用 erase 方法,这时候 mDequeuedInputBuffers 的数量会大于1。

status_t NuPlayer::Decoder::fetchInputData(sp<AMessage> &reply) {sp<ABuffer> accessUnit;bool dropAccessUnit = true;do {// 从 Source 获取数据status_t err = mSource->dequeueAccessUnit(mIsAudio, &accessUnit);// 判断返回值,如果是 EWOULDBLOCK 那么说明没读到数据,如果是其他的返回值则说名读到数据if (err == -EWOULDBLOCK) {return err;} else if (err != OK) {// 如果 error 不等于 OK,说明码流出现了一些情况if (err == INFO_DISCONTINUITY) {int32_t type;// 获取码流不连续的原因CHECK(accessUnit->meta()->findInt32("discontinuity", &type));bool formatChange =(mIsAudio &&(type & ATSParser::DISCONTINUITY_AUDIO_FORMAT))|| (!mIsAudio &&(type & ATSParser::DISCONTINUITY_VIDEO_FORMAT));bool timeChange = (type & ATSParser::DISCONTINUITY_TIME) != 0;ALOGI("%s discontinuity (format=%d, time=%d)",mIsAudio ? "audio" : "video", formatChange, timeChange);bool seamlessFormatChange = false;sp<AMessage> newFormat = mSource->getFormat(mIsAudio);// 如果是格式变化if (formatChange) {// 判断当前播放的码流格式是否支持无缝切换seamlessFormatChange =supportsSeamlessFormatChange(newFormat);// treat seamless format change separatelyformatChange = !seamlessFormatChange;}// For format or time change, return EOS to queue EOS input,// then wait for EOS on output.// 如果不支持无缝切换,那么就要向 decoder 填充 eosif (formatChange /* not seamless */) {mFormatChangePending = true;err = ERROR_END_OF_STREAM;} else if (timeChange) {// 如果pts不连续,那么就要向 decoder 填充 eos,恢复播放后要 发送 csd bufferrememberCodecSpecificData(newFormat);mTimeChangePending = true;err = ERROR_END_OF_STREAM;} else if (seamlessFormatChange) {// reuse existing decoder and don't flush// 如果是无缝切换,那么仍要发送 csd bufferrememberCodecSpecificData(newFormat);continue;} else {// This stream is unaffected by the discontinuityreturn -EWOULDBLOCK;}}// reply should only be returned without a buffer set// when there is an error (including EOS)CHECK(err != OK);reply->setInt32("err", err);return ERROR_END_OF_STREAM;}// 以下是 drop 机制dropAccessUnit = false;if (!mIsAudio && !mIsEncrypted) {// 如果视频流慢了 100ms,视频为avc,并且不是参考帧,那么就drop掉当前读取的内容if (mRenderer->getVideoLateByUs() > 100000LL&& mIsVideoAVC&& !IsAVCReferenceFrame(accessUnit)) {dropAccessUnit = true;} if (dropAccessUnit) {++mNumInputFramesDropped;}}} while (dropAccessUnit);reply->setBuffer("buffer", accessUnit);return OK;
}

fetchInputData 不仅仅是获取了数据,还对码流的异常情况做了处理。dequeueAccessUnit 有 四种返回值:

  • OK:获取到有效数据;
  • -EWOULDBLOCK:未能读取到数据;
  • INFO_DISCONTINUITY:码流不连续;
  • ERROR_END_OF_STREAM:读到文件末尾;

返回值为 OK 和 ERROR_END_OF_STREAM 属于正常情况;-EWOULDBLOCK 会直接返回并尝试 retry;INFO_DISCONTINUITY 说明码流出现了不连续的情况,可能是调用了 selectTrack 或者是 seek。

码流不连续分为两种情况:

  • 码流的格式发生变化,error 为 DISCONTINUITY_VIDEO_FORMAT,格式变化分别宽高变化和mime type变化两种,可能出现在 selectTrack 调用之后;
  • 码流的pts不连续,error 为 DISCONTINUITY_TIME,可能出现在 seek 调用后,或者是码流播放结束 pts 回跳时。

一是码流的格式发生变化 DISCONTINUITY_VIDEO_FORMAT引发的 flush,可能出现在 selectTrack 时;另外一种是码流 pts 回绕 DISCONTINUITY_TIME,这种情况出现在直播的回播中比较多。

如果是格式发生变化,那么会判断当前播放的码流是否支持 无缝切换(adaptive-playback),如果支持则不对该事件做处理,如果不支持把返回值设置为 ERROR_END_OF_STREAM。

如果是 pts不连续 则会直接将返回值设置为 ERROR_END_OF_STREAM。由于设置了 ERROR_END_OF_STREAM,那么重新开始播放之后需要先填充 csd buffer。

fetchInputData 还为 AVC 格式的码流设计了一套 drop 机制,如果视频流慢于音频100ms,并且当前帧不是参考帧,那么就 drop 掉该帧。

fetchInputData 调用成功后就该调用 onInputBufferFetched,把获取到的数据填充到 input buffer 中并且送回到 MediaCodec,这里比较简单,就是做了数据拷贝而已,要看的只有 EOS 一点。EOS 有两种情况,一种是 buffer 为空,说明当前已经收到 ERROR_END_OF_STREAM;另一种是 buffer 不为空,返回值为 OK,但是 bufferMeta 中有 eos 信息。

如果是码流结束,eos 信息送出后 fetchInputData 将不会读到任何数据。

如果是因为码流不连续发送了 eos,input buffer 处理流程将会被 isDiscontinuityPending 中断,等到前面的数据都解码渲染完成,再处理 Discontinuity 事件,处理完成后才会写入下一个序列的数据,这部分我们放到下一小节来看。

bool NuPlayer::Decoder::isDiscontinuityPending() const {return mFormatChangePending || mTimeChangePending;
}

3.2、CB_OUTPUT_AVAILABLE

output buffer 的处理流程相对 input 来说会简单很多,主要是调用了 handleAnOutputBuffer 方法:

bool NuPlayer::Decoder::handleAnOutputBuffer(size_t index,size_t offset,size_t size,int64_t timeUs,int32_t flags) {sp<MediaCodecBuffer> buffer;// 获取 output buffermCodec->getOutputBuffer(index, &buffer);int64_t frameIndex;bool frameIndexFound = buffer->meta()->findInt64("frameIndex", &frameIndex);buffer->setRange(offset, size);// 设置 ptsbuffer->meta()->clear();buffer->meta()->setInt64("timeUs", timeUs);if (frameIndexFound) {buffer->meta()->setInt64("frameIndex", frameIndex);}// 判断 output buffer 是否到达 eosbool eos = flags & MediaCodec::BUFFER_FLAG_EOS;// we do not expect CODECCONFIG or SYNCFRAME for decoder// 创建 reply,设置 generation,avsync完成后 renderer 通过该消息 callback 回来sp<AMessage> reply = new AMessage(kWhatRenderBuffer, this);reply->setSize("buffer-ix", index);reply->setInt32("generation", mBufferGeneration);reply->setSize("size", size);// 如果出现 eos 则在 reply 中也进行标记if (eos) {ALOGV("[%s] saw output EOS", mIsAudio ? "audio" : "video");buffer->meta()->setInt32("eos", true);reply->setInt32("eos", true);}mNumFramesTotal += !mIsAudio;// 判断 input buffer 有没有设定起播时间if (mSkipRenderingUntilMediaTimeUs >= 0) {if (timeUs < mSkipRenderingUntilMediaTimeUs) {ALOGV("[%s] dropping buffer at time %lld as requested.",mComponentName.c_str(), (long long)timeUs);reply->post();if (eos) {notifyResumeCompleteIfNecessary();if (mRenderer != NULL && !isDiscontinuityPending()) {mRenderer->queueEOS(mIsAudio, ERROR_END_OF_STREAM);}}return true;}mSkipRenderingUntilMediaTimeUs = -1;}// wait until 1st frame comes out to signal resume complete// 播放停止后重新恢复播放,等待第一帧到达后上抛消息,在seek时用到notifyResumeCompleteIfNecessary();if (mRenderer != NULL) {// send the buffer to renderer.// 将 ouput buffer 送到 renderer 做 avsyncmRenderer->queueBuffer(mIsAudio, buffer, reply);// 如果到达 eos,并且不是因为码流中断,调用queueEOSif (eos && !isDiscontinuityPending()) {mRenderer->queueEOS(mIsAudio, ERROR_END_OF_STREAM);}}return true;
}
  1. 获取 output buffer;
  2. 创建 reply,设置 generation,avsync 完成后 Renderer 通过该消息 callback 到 Decoder;
  3. 判断 output buffer flag 是否是 eos,如果是则在 reply 中进行标记;
  4. queue input buffer 时可能设有开始渲染的 pts,output buffer pts 小于该 pts 时直接 drop;
  5. 将 output buffer 和 reply message 一起送到 Renderer,如果到达 eos,且不是因为码流不连续,还要给 Renderer 送一个 EOS;

3.3、CB_OUTPUT_FORMAT_CHANGED

虽然我们使用 decoder 时都会传 input format 下去,但是 decoder 收到数据后仍会自己解析格式,并且上抛 output format change 事件,上层收到事件后需要做对应的处理。

void NuPlayer::Decoder::handleOutputFormatChange(const sp<AMessage> &format) {if (!mIsAudio) {int32_t width, height;if (format->findInt32("width", &width)&& format->findInt32("height", &height)) {Mutex::Autolock autolock(mStatsLock);mStats->setInt32("width", width);mStats->setInt32("height", height);}sp<AMessage> notify = mNotify->dup();notify->setInt32("what", kWhatVideoSizeChanged);notify->setMessage("format", format);notify->post();} else if (mRenderer != NULL) {uint32_t flags;int64_t durationUs;bool hasVideo = (mSource->getFormat(false /* audio */) != NULL);if (getAudioDeepBufferSetting() // override regardless of source duration|| (mSource->getDuration(&durationUs) == OK&& durationUs > AUDIO_SINK_MIN_DEEP_BUFFER_DURATION_US)) {flags = AUDIO_OUTPUT_FLAG_DEEP_BUFFER;} else {flags = AUDIO_OUTPUT_FLAG_NONE;}sp<AMessage> reply = new AMessage(kWhatAudioOutputFormatChanged, this);reply->setInt32("generation", mBufferGeneration);mRenderer->changeAudioFormat(format, false /* offloadOnly */, hasVideo,flags, mSource->isStreaming(), reply);}
}

如果是 Video Format 发生改变,继续上抛事件即可。如果是 Audio Format 发生改变,Decoder 需要调用 Renderer.changeAudioFormat 来重新打开 AudioTrack,具体如何处理在 Renderer 篇中会简单介绍。

3.4、kWhatRenderBuffer

上一节我们讲到 Renderer 做完 avsync 后会以消息的形式 callback 给 Decoder:

        case kWhatRenderBuffer:{if (!isStaleReply(msg)) {onRenderBuffer(msg);}break;}

isStaleReply 我们在上面已经做过解释了,这里不再赘述,主要来看 onRenderBuffer:

void NuPlayer::Decoder::onRenderBuffer(const sp<AMessage> &msg) {status_t err;int32_t render;size_t bufferIx;int32_t eos;size_t size;// 查找要渲染的output buffer indexCHECK(msg->findSize("buffer-ix", &bufferIx));if (mCodec == NULL) {err = NO_INIT;} else if (msg->findInt32("render", &render) && render) { // 判断是否renderint64_t timestampNs;CHECK(msg->findInt64("timestampNs", &timestampNs));	// 获取render时间err = mCodec->renderOutputBufferAndRelease(bufferIx, timestampNs);} else {// 如果是 eos 或者 不render 则直接 dropif (!msg->findInt32("eos", &eos) || !eos ||!msg->findSize("size", &size) || size) {mNumOutputFramesDropped += !mIsAudio;}err = mCodec->releaseOutputBuffer(bufferIx);}// 如果是因为码流不连续造成的eos,则处理不连续事件if (msg->findInt32("eos", &eos) && eos&& isDiscontinuityPending()) {finishHandleDiscontinuity(true /* flushOnTimeChange */);}
}

onRenderBuffer 主要是用来处理 Video 的,Renderer 确定该帧要渲染,那么就调用 renderOutputBufferAndRelease,否则调用 releaseOutputBuffer。

如果 reply message 中包含有 eos,那么会判断是否因为码流不连续而造成的 eos。我们要注意的是,Renderer 真正执行到 EOS 时,事件并不会发送到 Decoder中,Decoder 只处理 buffer 事件。

这里我们回过头来看 finishHandleDiscontinuity 是如何处理码流异常的:

void NuPlayer::Decoder::finishHandleDiscontinuity(bool flushOnTimeChange) {ALOGV("finishHandleDiscontinuity: format %d, time %d, flush %d",mFormatChangePending, mTimeChangePending, flushOnTimeChange);// If we have format change, pause and wait to be killed;// If we have time change only, flush and restart fetching.if (mFormatChangePending) {mPaused = true;} else if (mTimeChangePending) {if (flushOnTimeChange) {doFlush(false /* notifyComplete */);signalResume(false /* notifyComplete */);}}// Notify NuPlayer to either shutdown decoder, or rescan sourcessp<AMessage> msg = mNotify->dup();msg->setInt32("what", kWhatInputDiscontinuity);msg->setInt32("formatChange", mFormatChangePending);msg->post();mFormatChangePending = false;mTimeChangePending = false;
}

从注释中我们可以看到针对 format change 和 time change 处理方式是不一样的:

  • format change:暂停 buffer 处理流程,等待重启 decoder;
  • time change:flush 然后调用 resume 恢复;

format change 中提到一个暂停,将 mPaused 置为 true,onMessageReceived 将不会再处理送来的 buffer,要注意的是,这个 pause 并不是用于播放暂停。format change 的事件要送到 NuPlayer 中:

            if (what == DecoderBase::kWhatInputDiscontinuity) {int32_t formatChange;CHECK(msg->findInt32("formatChange", &formatChange));ALOGV("%s discontinuity: formatChange %d",audio ? "audio" : "video", formatChange);if (formatChange) {mDeferredActions.push_back(new FlushDecoderAction(audio ? FLUSH_CMD_SHUTDOWN : FLUSH_CMD_NONE,audio ? FLUSH_CMD_NONE : FLUSH_CMD_SHUTDOWN));}mDeferredActions.push_back(new SimpleAction(&NuPlayer::performScanSources));processDeferredActions();} 

NuPlayer 会执行 FlushDecoderAction,并且进行 shutdown 释放当前 decoder,然后再重新调用 performScanSources 为新的 format 创建 decoder。

4、signalFlush

void NuPlayer::Decoder::doFlush(bool notifyComplete) {if (mCCDecoder != NULL) {mCCDecoder->flush();}if (mRenderer != NULL) {mRenderer->flush(mIsAudio, notifyComplete);mRenderer->signalTimeDiscontinuity();}status_t err = OK;if (mCodec != NULL) {err = mCodec->flush();mCSDsToSubmit = mCSDsForCurrentFormat; // copy operator++mBufferGeneration;}if (err != OK) {ALOGE("failed to flush [%s] (err=%d)", mComponentName.c_str(), err);handleError(err);// finish with posting kWhatFlushCompleted.// we attempt to release the buffers even if flush fails.}releaseAndResetMediaBuffers();mPaused = true;
}void NuPlayer::Decoder::onFlush() {doFlush(true);if (isDiscontinuityPending()) {// This could happen if the client starts seeking/shutdown// after we queued an EOS for discontinuities.// We can consider discontinuity handled.finishHandleDiscontinuity(false /* flushOnTimeChange */);}sp<AMessage> notify = mNotify->dup();notify->setInt32("what", kWhatFlushCompleted);notify->post();
}

flush 比较简单,就不多说废话啦。主要工作是调用 Renderer 的 flush,重置 Renderer 的状态,调用 MediaCodec 的 flush,刷新 input buffer 和 output buffer 缓冲区,注意这个方法调用会修改 mBufferGeneration,最后将 Decoder 存储的 buffer 列表都清空。

我们在上一节看到 finishHandleDiscontinuity 中调用的是 doFlush,所以是不会有 kWhatFlushCompleted 事件发送到 NuPlayer 的。

5、initiateShutdown

void NuPlayer::Decoder::onShutdown(bool notifyComplete) {status_t err = OK;// if there is a pending resume request, notify complete nownotifyResumeCompleteIfNecessary();if (mCodec != NULL) {// 释放decodererr = mCodec->release();// 释放 MediaCodecmCodec = NULL;// 修改 generation 阻止渲染++mBufferGeneration;if (mSurface != NULL) {// reconnect to surface as MediaCodec disconnected from itstatus_t error = nativeWindowConnect(mSurface.get(), "onShutdown");ALOGW_IF(error != NO_ERROR,"[%s] failed to connect to native window, error=%d",mComponentName.c_str(), error);}mComponentName = "decoder";}// 释放 buffer listreleaseAndResetMediaBuffers();if (err != OK) {ALOGE("failed to release [%s] (err=%d)", mComponentName.c_str(), err);handleError(err);// finish with posting kWhatShutdownCompleted.}if (notifyComplete) {sp<AMessage> notify = mNotify->dup();notify->setInt32("what", kWhatShutdownCompleted);notify->post();// 停止处理 buffer 事件mPaused = true;}
}

shutdown 也很简单:

  1. 调用 MediaCodec 的 release 方法,释放 decoder;
  2. 释放掉 MediaCodec 对象;
  3. 修改 generation,停止处理 render 事件;
  4. 释放 buffer list;
  5. 将 mPaused 置为 true,停止处理 buffer 事件;

6、signalResume

void NuPlayer::Decoder::onResume(bool notifyComplete) {mPaused = false;if (notifyComplete) {mResumePending = true;}if (mCodec == NULL) {ALOGE("[%s] onResume without a valid codec", mComponentName.c_str());handleError(NO_INIT);return;}mCodec->start();
}

flush 之后要调用 signalResume 才能启动 MediaCodec 恢复解码流程,核心就是调用 MediaCodec 的 start 方法。这里的 mResumePending 是在 decoder 送过来第一帧 ouput buffer 时来判断是否需要发送 kWhatResumeCompleted 给 NuPlayer 的。

7、总结

没有其他的,感悟是 Android ALooper 机制领悟的还不够深刻,设计模式也不会,接下来会继续加强这部分的学习!

以上内容如果有错误请不要吝啬指导。

如果觉得对您有帮助,还请不要吝啬点赞、收藏与关注哦,您的支持是我更新的最大动力。

如需阅读其他 Android Media 框架内容,还请移步 https://blog.csdn.net/qq_41828351?spm=1000.2115.3001.5343

相关文章:

Android 13 - Media框架(9)- NuPlayer::Decoder

这一节我们将了解 NuPlayer::Decoder&#xff0c;学习如何将 MediaCodec wrap 成一个强大的 Decoder。这一节会提前讲到 MediaCodec 相关的内容&#xff0c;如果看不大懂可以先跳过此篇。原先觉得 Decoder 部分简单&#xff0c;越读越发现自己的无知&#xff0c;Android 源码真…...

23.09.5 《CLR via C#》 笔记5

第六章 类型和成员基础 类型可以定义0或多个以下成员&#xff1a;常量、字段、实例构造器、类型构造器、方法、操作符重载、转换操作符、属性、事件、类型类型的可见性分为public和internal(默认)C#中&#xff0c;成员的可访问性分为private、protected、internal、protected …...

laravel部署api项目遇到问题总结

laravel线上部署问题 一、Ubuntu远程Mysql 61“Connection refused”二、Ubuntu更新php8三、线上部署Permission denied3.1、部署完之后访问域名出现报错&#xff1a;3.2、The /bootstrap/cache directory must be present and writable. 四、图片访问404五、git部署线上文件 一…...

lintcode 1646 · 合法组合【字符串DFS, vip 中等 好题】

题目 https://www.lintcode.com/problem/1646 给一个单词s,和一个字符串集合str。这个单词每次去掉一个字母&#xff0c;直到剩下最后一个字母。求验证是否存在一种删除的顺序&#xff0c;这个顺序下所有的单词都在str中。例如单词是’abc’&#xff0c;字符串集合是{‘a’,’…...

【多线程】线程安全 问题

线程安全 问题 一. 线程不安全的典型例子二. 线程安全的概念三. 线程不安全的原因1. 线程调度的抢占式执行2. 修改共享数据3. 原子性4. 内存可见性5. 指令重排序 一. 线程不安全的典型例子 class ThreadDemo {static class Counter {public int count 0;void increase() {cou…...

【用unity实现100个游戏之11】复刻经典消消乐游戏

文章目录 前言开始项目开始一、方块网格生成二、方块交换三、添加交换的动画效果四、水平消除检测五、垂直消除检测六、完善删除功能七、效果优化&#xff08;移动方块后再进行消除检测&#xff09;八、方块下落十、方块填充十一、后续 源码参考完结 前言 欢迎来到经典消消乐游…...

若依cloud 修改包名等

一、项目的项目名。 先改pom 然后在重命名文件 1、 修改主pom.xml <artifactId>ruoyi-api</artifactId> 缓存 <artifactId>zxf-api</artifactId> <groupId>com.ruoyi</groupId> <groupId>com.zhixiaofeng</groupId> 2、…...

健康系统练习

健康系统 项目建构&#xff1a; 前后端分离&#xff0c;前端vue3&#xff0c;后端Java&#xff0c;springboot做跨域处理&#xff0c;前端将在vscode中 的tomcat下部署&#xff0c;后端将在ideal中集成的tomcat中部署 创建项目工程在ideal中直接选用springi…创建&#xff0c…...

网络协议从入门到底层原理学习(一)—— 简介及基本概念

文章目录 网络协议从入门到底层原理学习&#xff08;一&#xff09;—— 简介及基本概念一、简介1、网络协议的定义2、网络协议组成要素3、广泛的网络协议类型网络通信协议网络安全协议网络管理协议 4、网络协议模型对比图 二、基本概念1、网络互连模型2、计算机之间的通信基础…...

centos密码过期导致navicat无法通过SSH登录阿里云RDS问题

具体错误提示&#xff1a;2013 - Lost connection to server at "hand hake: reading initial communication packet, system error: 0 解决办法&#xff1a;更新SSH服务器密码...

对于pytorch和对应pytorch网站的探索

一、关于网站上面的那个教程: 适合PyTorch小白的官网教程&#xff1a;Learning PyTorch With Examples - 知乎 (zhihu.com) 这个链接也是一样的&#xff0c; 总的来说&#xff0c;里面讲了这么一件事: 如果没有pytorch的分装好的nn.module用来继承的话&#xff0c;需要设计…...

和AI聊天:动态规划

动态规划 动态规划&#xff08;Dynamic Programming&#xff0c;简称 DP&#xff09;是一种常用于优化问题的算法。它解决的问题通常具有重叠子问题和最优子结构性质&#xff0c;可以通过将问题分解成相互依赖的子问题来求解整个问题的最优解。 动态规划算法主要分为以下几个步…...

微信小程序——使用插槽slot快捷开发

微信小程序的插槽&#xff08;slot&#xff09;是一种组件化的技术&#xff0c;用于在父组件中插入子组件的内容。通过插槽&#xff0c;可以将父组件中的一部分内容替换为子组件的内容&#xff0c;实现更灵活的组件复用和定制。 插槽的使用步骤如下&#xff1a; 在父组件的wx…...

大数据技术之Hadoop:使用命令操作HDFS(四)

目录 一、创建文件夹 二、查看指定目录下的内容 三、上传文件到HDFS指定目录下 四、查看HDFS文件内容 五、下载HDFS文件 六、拷贝HDFS文件 七、HDFS数据移动操作 八、HDFS数据删除操作 九、HDFS的其他命令 十、hdfs web查看目录 十一、HDFS客户端工具 11.1 下载插件…...

静态路由配置实验:构建多路由器网络拓扑实现不同业务网段互通

文章目录 一、实验背景与目的二、实验拓扑三、实验需求四、实验解法1. 配置 IP 地址2. 按照需求配置静态路由&#xff0c;实现连接 PC 的业务网段互通 摘要&#xff1a; 本实验旨在通过配置网络设备的IP地址和静态路由&#xff0c;实现不同业务网段之间的互通。通过构建一组具有…...

Python函数的概念以及定义方式

一. 前言 嗨喽~大家好呀&#xff0c;这里是魔王呐 ❤ ~! python更多源码/资料/解答/教程等 点击此处跳转文末名片免费获取 二. 什么是函数&#xff1f; 假设你现在是一个工人&#xff0c;如果你实现就准备好了工具&#xff0c;等你接收到任务的时候&#xff0c; 直接带上工…...

【数学建模竞赛】超详细Matlab二维三维图形绘制

二维图像绘制 绘制曲线图 g 是表示绿色 b--o是表示蓝色/虚线/o标记 c*是表示蓝绿色(cyan)/*标记 ‘MakerIndices,1:5:length(y) 每五个点取点&#xff08;设置标记密度&#xff09; 特殊符号的输入 序号 需求 函数字符结构 示例 1 上角标 ^{ } title( $ a…...

2023国赛数学建模E题思路代码 黄河水沙监测数据分析

E题最大的难度是数据处理&#xff0c;可以做一个假设&#xff0c;假设一定时间内流量跟含沙量不变&#xff0c;那么我们可以对数据进行向下填充&#xff0c;把所有的数据进行合并之后可以对其进行展开特性分析&#xff0c;在研究调水调沙的实际效果时&#xff0c;可以先通过分析…...

窗口延时、侧输出流数据处理

一 、 AllowedLateness API 延时关闭窗口 AllowedLateness 方法需要基于 WindowedStream 调用。AllowedLateness 需要设置一个延时时间&#xff0c;注意这个时间决定了窗口真正关闭的时间&#xff0c;而且是加上WaterMark的时间&#xff0c;例如 WaterMark的延时时间为2s&…...

发送HTTP请求

HTTP请求是一种客户端向服务器发送请求的协议。它是基于TCP/IP协议的应用层协议&#xff0c;用于在Web浏览器和Web服务器之间传输数据。 HTTP请求由以下几个部分组成&#xff1a; 请求行&#xff1a;包含请求方法、请求的URL和HTTP协议的版本。常见的请求方法有GET、POST、PUT、…...

高等工程数学张韵华版第四章课后题答案

下面答案仅供参考&#xff01; 章节目录 第4章 欧氏空间和二次型 4.1内积和欧氏空间 4.1.1内积的定义 4.1.2欧氏空间的性质 4.1.3 正交投影 4.1.4 施密特正交化 4.2 正交变换和对称变换 4.2.1 正交变换 4.2.2 正交矩阵 4.2.3 对称变换 4.2.4 对称矩阵 4.3 二…...

wpf C# 用USB虚拟串口最高速下载大文件 每包400万字节 平均0.7s/M,支持批量多设备同时下载。自动识别串口。源码示例可自由定制。

C# 用USB虚拟串口下载大文件 每包400万字节 平均0.7s/M。支持批量多设备同时下载。自动识别串口。可自由定制。 int 32位有符号整数 -2147483648~2147483647 但500万字节时 write时报端口IO异常。可能是驱动限制的。 之前用这个助手发文件&#xff0c;连续发送&#xff0…...

代码随想录二刷day20

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、力扣654. 最大二叉树二、力扣617. 合并二叉树三、力扣700. 二叉搜索树中的搜索四、力扣98. 验证二叉搜索树 前言 一、力扣654. 最大二叉树 /*** Definitio…...

Yolov5如何训练自定义的数据集,以及使用GPU训练,涵盖报错解决

本文主要讲述了Yolov5如何训练自定义的数据集&#xff0c;以及使用GPU训练&#xff0c;涵盖报错解决&#xff0c;案例是检测图片中是否有救生圈。 最后的效果图大致如下&#xff1a; 效果图1效果图2 前言 系列文章 1、详细讲述Yolov5从下载、配置及如何使用GPU运行 2、…...

设计模式之单列模式

单列模式是一种经典的设计模式&#xff0c;在校招中最乐意考的设计模式之一~ 设计模式就是软件开发中的棋谱&#xff0c;大佬们针对一些常见的场景&#xff0c;总结出来的代码的编写套路&#xff0c;按照套路来写&#xff0c;不说你写的多好&#xff0c;至少不会太差~ 在校招中…...

linux内核模块编译方法详解

文章目录 前言一、静态加载法1.1 编写驱动程序1.2 将新功能配置在内核中1.3为新功能代码改写Makefile1.4 make menuconfig界面里将新功能对应的那项选择为<*> 二、动态加载法2.1 新功能源码与Linux内核源码在同一目录结构下2.2 新功能源码与Linux内核源码不在同一目录结构…...

简介shell的关联数组与普通数组

本文首先介绍shell的关联数组&#xff0c;然后介绍shell的普通数组&#xff0c;最后总结它们的共同语法。 shell的关联数组 定义一个关联数组&#xff0c;并打印它的key-value对 #!/bin/sh# 声明一个关联数组 declare -A HASH_MAP# 给关联数组赋值 HASH_MAP["Tom"…...

玩转Mysql系列 - 第17篇:存储过程自定义函数详解

这是Mysql系列第17篇。 环境&#xff1a;mysql5.7.25&#xff0c;cmd命令中进行演示。 代码中被[]包含的表示可选&#xff0c;|符号分开的表示可选其一。 需求背景介绍 线上程序有时候出现问题导致数据错误的时候&#xff0c;如果比较紧急&#xff0c;我们可以写一个存储来…...

自动驾驶:轨迹预测综述

自动驾驶&#xff1a;轨迹预测综述 轨迹预测的定义轨迹预测的分类基于物理的方法&#xff08;Physics-based&#xff09;基于机器学习的方法&#xff08;Classic Machine Learning-based&#xff09;基于深度学习的方法&#xff08;Deep Learning-based&#xff09;基于强化学习…...

【uniapp/uview】u-datetime-picker 选择器的过滤器用法

引入&#xff1a;要求日期选择的下拉框在分钟显示时&#xff0c;只显示 0 和 30 分钟&#xff1b; <u-datetime-picker :show"dateShow" :filter"timeFilter" confirm"selDateConfirm" cancel"dateCancel" v-model"value1&qu…...

有哪个网站是做水果批发的/网络推广seo是什么

局部反应归一化 \[b^{i}_{x,y} a^{i}_{x,y}/ \left( k \alpha \sum_{jmax(0, i-n/2)}^{min(N-1,in/2)}(a^j_{x,y})^2\right)\] 数据预处理 这篇论文中&#xff0c;提到的对数据的预处理包含了三个部分&#xff0c;第一部分是图片的均值归零处理&#xff0c;第二部分是图片的cr…...

网站建设公司怎么做好/推广排名

1.下载第一个阿里云仓库文件wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo-O 参数 &#xff0c;指定一个下载地址&#xff0c;且改名2.配置第二个仓库文件 epel 额外仓库&#xff08;redis&#xff0c;nginx&#xff0c;mongo&…...

网站建设需求书模板/网络营销是指

有时候&#xff0c;在我们用动态php页面调用html的时候会出现html缓存现象&#xff0c;所以可以在html头部加入以下代码&#xff0c;html就不会出现缓存了。 <meta HTTP-EQUIV"pragma" CONTENT"no-cache"> <meta HTTP-EQUIV"Cache-Control&q…...

商城网站如何建设/小说百度风云榜

本故事纯属改编 如有共鸣 实属意外-说到“家”&#xff0c;一下想起了小时候学的儿歌&#xff1a;“小毛虫的摇篮是树叶&#xff0c;住在花瓣上的是蝴蝶……我最幸福了&#xff0c;一生下来爸爸妈妈就准备好了家&#xff0c;让我安安稳稳地在家里长大。”从那时起&#xff0c;我…...

福田附近做网站公司/北京seo课程培训

2019独角兽企业重金招聘Python工程师标准>>> 最近看了一个Google日本的企宣视频, 感觉超级好玩. 真是叹服Google日本的奇思妙想. 于是驱猫寻图, 找到了Google存放图片的地址. 然后编写了一个超级简单的脚本重现了该tabplay(前提是chrome为默认浏览器). 真是华丽... …...

做写真网站的限度/推广营销是什么

ANDROID-底部虚拟导航的高度获取 现在许多手机都引入了虚拟导航&#xff0c;所以在android开发的时候&#xff0c;经常需要对底部的虚拟导航进行适配。这篇文章主要是获取底部导航的高度&#xff0c;以及对导航是否显示进行判断。 如何获取手机底部虚拟导航的高度 本文所使用的…...