NuPlayer源码分析二:解封装模块

NuPlayer解封装模块


系列文章分为如下几个模块:

解封装模块的重要作用,是将封装好的音视频源文件,通过不同的封装协议,解析成码流后,送到解码器解码。

NuPlayer中和解封装相关的类有:

  • NuPlayer::Source:解封装模块的基类,定义了解封装的基本接口。
  • GenericSource:本地文件相关。
  • HTTPLiveSource:HLS流媒体使用的解封装类。
  • RTSPSource:SDP协议媒体流使用的解封装类。

此外,还需要DataSource等配合操作。类图如下:
在这里插入图片描述

篇幅有限,本文主要介绍本地媒体文件的例子。也就是GenericSource播放本地文件为例。

Android播放器的一般步骤

一个Android播放器典型的播放步骤一般是:

  1. 播放器创建。
  2. 设置媒体源文件(本地文件路径、或者Uri)。
  3. 准备媒体数据。
  4. 播放视频。
  5. 停止播放。

为了方便分析解封装模块,也从该顺序逐步分析解封装过程。对应的播放器调用接口如下:

  1. GenericSource:创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:播放视频

  5. stop&pause:停止播放

GenericSource:创建

先来分析第一个步骤:GenericSource的创建,即播放器创建部分。在流程中的位置是:

  1. GenericSource的创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:启动

  5. stop&pause&resume:停止&暂停&恢复

上一篇文章中,我们提到,在NuPlayersetDataSourceAsync函数中创建了GenericSource对象。并调用了setDataSource函数。用一张图回忆一下:
在这里插入图片描述
再来看看对应代码:

void NuPlayer::setDataSourceAsync(int fd, int64_t offset, int64_t length) {
    sp<AMessage> msg = new AMessage(kWhatSetDataSource, this); // 新建消息,这属于常规操作了
    sp<AMessage> notify = new AMessage(kWhatSourceNotify, this); // 新建消息,用于和解封装模块通信,类似于一种listener的功能。

    sp<GenericSource> source = new GenericSource(notify, mUIDValid, mUID); // 创建解封装器
    status_t err = source->setDataSource(fd, offset, length);  // 为GenericSource设置媒体源
    msg->setObject("source", source);
    msg->post(); // 将创建并设置好的setDataSource,post给下一个流程处理
    mDataSourceType = DATA_SOURCE_TYPE_GENERIC_FD;
}

这段代码中,首次创建了一个GenericSource实例,先来看看实例化过程。

NuPlayer::GenericSource::GenericSource(
        const sp<AMessage> &notify,
        bool uidValid,
        uid_t uid)
    : Source(notify), // 将一个AMessage对象存放在父类Source的mNotify字段中,这是个通用操作,用来通知调用者,当前资源状态的。
      mAudioTimeUs(0),
      mAudioLastDequeueTimeUs(0),
      mVideoTimeUs(0),
      mVideoLastDequeueTimeUs(0),
      mFetchSubtitleDataGeneration(0),
      mFetchTimedTextDataGeneration(0),
      mDurationUs(-1ll),
      mAudioIsVorbis(false), // 音频是否为Vorbis压缩格式,默认为false
      mIsSecure(false),
      mIsStreaming(false),
      mFd(-1), // 文件句柄
      mBitrate(-1ll), // 比特率
      mPendingReadBufferTypes(0) {
    mBufferingMonitor = new BufferingMonitor(notify); // 新建一个BufferingMonitor实例
    resetDataSource(); // 重置一些DataSource数据到初始状态。
}

从构造函数默认初始化列表中的字段含义来看,GenericSource包含了除了Buffer以外几乎所有的解封装相关数据,如文件句柄(mFd)、媒体时长(mDurationUs)等。

而关于Buffer状态的管理和监听使用的是BufferingMonitor类来实现。

  • BufferingMonitor:协助监控Buffer的状态,每秒轮询一次,必要时会将Buffer的状态通过AMessage通知Player。

可见其重要性,来简单看一下该结构体和部分函数,间接感受一下它的功能:

struct BufferingMonitor : public AHandler {
    public:
    explicit BufferingMonitor(const sp<AMessage> &notify);
    // 重新启动监视任务。
    void restartPollBuffering();
    // 停止缓冲任务并发送相应的事件。
    void stopBufferingIfNecessary();
    // 确保数据源正在获取数据。
    void ensureCacheIsFetching();
    // 更新从DataSource刚刚提取的缓冲区的媒体时间。
    void updateQueuedTime(bool isAudio, int64_t timeUs);
    // 更新发送到解码器的最后出队缓冲区的媒体时间。
    void updateDequeuedBufferTime(int64_t mediaUs);
    protected:
    virtual ~BufferingMonitor();
    virtual void onMessageReceived(const sp<AMessage> &msg);
}

setDataSource:设置媒体源数据

setDataSource在播放流程中的位置为:

  1. GenericSource的创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:启动

  5. stop&pause&resume:停止&暂停&恢复

status_t NuPlayer::GenericSource::setDataSource(int fd, int64_t offset, int64_t length) {
    ALOGV("setDataSource %d/%lld/%lld", fd, (long long)offset, (long long)length);
    resetDataSource(); // 重置一些DataSource数据到初始状态。
    mFd = dup(fd); // 将文件的句柄复制一份给mFd字段
    mOffset = offset; // 数据的偏移量
    mLength = length; // 文件长度

    // delay data source creation to prepareAsync() to avoid blocking
    // the calling thread in setDataSource for any significant time.
    return OK;
}

dup(fd)是什么?该函数定义在/frameworks/base/core/java/android/os/ParcelFileDescriptor.java中,函数原型为:public static ParcelFileDescriptor dup(FileDescriptor orig)

作用:创建一个新的ParcelFileDescriptor,它是现有FileDescriptor的副本。 这遵循标准POSIX语义,其中新文件描述符共享状态,例如文件位置与原始文件描述符。

可以看到,setDataSource除了将媒体文件相关参数保存下来外,并没有做其他的工作。顺便看一看resetDataSource函数吧:

void NuPlayer::GenericSource::resetDataSource() {
   mUri.clear();
   mUriHeaders.clear();
   if (mFd >= 0) {
       close(mFd);
       mFd = -1;
   }
   mOffset = 0;
   mLength = 0;
   mStarted = false;
   mStopRead = true;

   if (mBufferingMonitorLooper != NULL) { // 让BufferingMonitor停止循环监听buffer
       mBufferingMonitorLooper->unregisterHandler(mBufferingMonitor->id());
       mBufferingMonitorLooper->stop();
       mBufferingMonitorLooper = NULL;
   }
   mBufferingMonitor->stop();
   mMimes.clear();
}

主要有两个方面作用:

  1. 将一些媒体资源文件相关索引(值),以及解析器状态重置为默认状态。
  2. 停止使用让BufferingMonitor停止循环监听buffer。

下面来看看如何准备资源的

prepareAsync:准备媒体数据

prepareAsync在播放流程中的位置为:

  1. GenericSource的创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:启动

  5. stop&pause&resume:停止&暂停&恢复

void NuPlayer::GenericSource::prepareAsync() {
    ALOGV("prepareAsync: (looper: %d)", (mLooper != NULL));
    if (mLooper == NULL) { // 创建looper并启动AHandler循环
        mLooper = new ALooper;
        mLooper->setName("generic");
        mLooper->start();
        mLooper->registerHandler(this);
    }
    sp<AMessage> msg = new AMessage(kWhatPrepareAsync, this);
    msg->post();
}

虽然代码少,但这是一个很重要的调用:创建ALooper并且让Looper 循环起来了。这个信息告诉我们,GenericSource本身组成了一个NativeHandler体系,用于传递自身消息。

GenericSource类通过继承NuPlayer::Source间接继承了AHandler,用于处理消息。

这些,都说明GenericSource的函数会有部分是异步的,函数名中prepareAsync中的Async也表明了这一点。

不熟悉的朋友可以翻一翻这篇文章:Android媒体底层通信框架Native Handler

启动了looper循环处理消息后,发送了一个kWhatPrepareAsync的消息,给looper线程来处理。

熟悉NativeHandler的朋友应该知道,GenericSource函数作为AHandler,必然要重写onMessageReceived函数,用于处理数据:

void NuPlayer::GenericSource::onMessageReceived(const sp<AMessage> &msg) {
    switch (msg->what()) {
      case kWhatPrepareAsync:
      {
          onPrepareAsync();
          break;
      }
      case kWhatStart:
      case kWhatResume:
      {
          mBufferingMonitor->restartPollBuffering();
          break;
      }
      // .......省略一万行.......
    }
}

AMessage的标志是kWhatPrepareAsync,在onMessageReceived并没有做什么处理,直接调用了onPrepareAsync函数。

void NuPlayer::GenericSource::onPrepareAsync() { // 该函数运行在looper所在的子线程中
    // delayed data source creation
    if (mDataSource == NULL) { // 第一次进来,mDataSource肯定为空
        mIsSecure = false; // 先设置为false,如果extractor返回为安全,再设置为true.
        if (!mUri.empty()) { // 因为是本地文件,所以mUri不用初始化,自然为空。
			// 略掉网络媒体源创建DataSource相关代码。
        } else { // 处理本地媒体文件源
            // media.stagefright.extractremote属性一般不会设置,
            if (property_get_bool("media.stagefright.extractremote", true) &&
                    !FileSource::requiresDrm(mFd, mOffset, mLength, nullptr /* mime */)) {
                sp<IBinder> binder =
                        defaultServiceManager()->getService(String16("media.extractor"));
                if (binder != nullptr) {
                    ALOGD("FileSource remote");
                    sp<IMediaExtractorService> mediaExService(
                            interface_cast<IMediaExtractorService>(binder));
                    sp<IDataSource> source =
                            mediaExService->makeIDataSource(mFd, mOffset, mLength);
                    ALOGV("IDataSource(FileSource): %p %d %lld %lld",
                            source.get(), mFd, (long long)mOffset, (long long)mLength);
                    if (source.get() != nullptr) {
                        mDataSource = DataSource::CreateFromIDataSource(source);
                        if (mDataSource != nullptr) { // 过河拆迁,初始化mDataSource成功后
                            // Close the local file descriptor as it is not needed anymore.
                            close(mFd);
                            mFd = -1;	
                        }
                    }
                }
            }
            if (mDataSource == nullptr) { // 如果没有从extractor服务中成功获取DataSource就自己创建
                ALOGD("FileSource local");
                mDataSource = new FileSource(mFd, mOffset, mLength);
            }
            mFd = -1;
        }

        if (mDataSource == NULL) { // 到这里基本上是不可能为NULL了
            ALOGE("Failed to create data source!");
            notifyPreparedAndCleanup(UNKNOWN_ERROR);
            return;
        }
    }

    if (mDataSource->flags() & DataSource::kIsCachingDataSource) {
        mCachedSource = static_cast<NuCachedSource2 *>(mDataSource.get());
    }

    // For cached streaming cases, we need to wait for enough
    // buffering before reporting prepared.
    mIsStreaming = (mCachedSource != NULL);

    // init extractor from data source
    status_t err = initFromDataSource();
	// ...
    finishPrepareAsync();
    ALOGV("onPrepareAsync: Done");
}

从函数代码中可以看出,该函数唯一的目的就是为了初始化mDataSource,主要的初始化方式有两个:

  1. 从MediaExtractorService服务中获取。
  2. 如果第一步未能初始化成功,直接自己创建一个new FileSource

这里没有想到的是,Android底层框架为了解封装的通用性,直接提供了一个解封装相关的服务:MediaExtractorService,服务名称为:“media.extractor”,NuPlayer作为众多播放器的一种,也是可以直接享受该服务的。在这里就通过该服务,创建了一个DataSource对象。

这里有个问题,最终NuPLayer使用的到底是通过ExtractorService获取DataSource对象,还是直接自己new FileSource呢。

我们当然可以通过日志来判断,但这会失去对播放逻辑的学习。所以,我一般都通过代码来判断。

因为代码调用的先后顺序,我们先来看通过服务获取的过程。

MediaExtractor服务获取DataSource

// media.stagefright.extractremote属性一般不会设置,
if (property_get_bool("media.stagefright.extractremote", true) &&
    !FileSource::requiresDrm(mFd, mOffset, mLength, nullptr /* mime */)) {
    // 通过Binder机制,获取"media.extractor"服务的远程代理
    sp<IBinder> binder =
        defaultServiceManager()->getService(String16("media.extractor"));
    if (binder != nullptr) { // 获取失败时为空指针
        ALOGD("FileSource remote");
        // 强转为IMediaExtractorService对象指针
        sp<IMediaExtractorService> mediaExService(
            interface_cast<IMediaExtractorService>(binder));
        // 调用服务的代理对象接口,获取IDataSource对象指针
        sp<IDataSource> source =
            mediaExService->makeIDataSource(mFd, mOffset, mLength);
        ALOGV("IDataSource(FileSource): %p %d %lld %lld",
              source.get(), mFd, (long long)mOffset, (long long)mLength);
        if (source.get() != nullptr) {
            // 通过获取IDataSource对象指针初始化mDataSource
            mDataSource = DataSource::CreateFromIDataSource(source);
            if (mDataSource != nullptr) { // 过河拆迁,初始化mDataSource成功后
                // Close the local file descriptor as it is not needed anymore.
                close(mFd);
                mFd = -1;	
            }
        }
    }
}

这一段代码,比较重要的函数调用,都加上了注释,这里再啰嗦得总结一下吧:

  • getService(String16("media.extractor")):熟悉binder机制的同学都知道,这是Binder远端获取指定服务的基本操作了。有时间整理一份文章出来,敬请期待吧。
  • mediaExService->makeIDataSource:调用服务接口,创建IDataSource对象。
  • DataSource::CreateFromIDataSource:调用CreateFromIDataSource通过前面创建的IDataSource初始化mDataSource。

基本上就这么回事儿。第一点就不说了,东西太多。这里稍微展开一下第二、第三点的调用。

makeIDataSource

该函数是通过Binder的远端调用,最终会调用到服务端的代码,也就是MediaExtractorService中:

代码路径:/frameworks/av/services/mediaextractor/MediaExtractorService.cpp

sp<IDataSource> MediaExtractorService::makeIDataSource(int fd, int64_t offset, int64_t length)
{
    sp<DataSource> source = DataSource::CreateFromFd(fd, offset, length);
    return source.get() != nullptr ? source->asIDataSource() : nullptr;
}

再看CreateFromFd干了啥:

sp<DataSource> DataSource::CreateFromFd(int fd, int64_t offset, int64_t length) {
    sp<FileSource> source = new FileSource(fd, offset, length); // 也是直接new了FileSource
    return source->initCheck() != OK ? nullptr : source; // 检查是否有sp时候为有效指针,有效把指针丢回去
}

咦~这代码看着耳熟啊!!!也是直接new FileSource

那个initCheck()函数,就不说了,说多了又是长篇大论。

CreateFromIDataSource

sp<DataSource> DataSource::CreateFromIDataSource(const sp<IDataSource> &source) {
    return new TinyCacheSource(new CallbackDataSource(source));
}

我去,又来了两个陌生的类,其实他们都和DataSource有千丝万缕的联系,看看一下类图:
在这里插入图片描述
有关CallbackDataSourceTinyCacheSource的定义,都在CallbackDataSource.h头文件中。它们的定义都比较简单,就不贴代码了,有兴趣自己去看,源码路径如下:

\frameworks\av\include\media\stagefright\CallbackDataSource.h

\frameworks\av\media\libstagefright\CallbackDataSource.cpp

下面来稍微总结一下前面的类图:

  • DataSource:该类规定了媒体源文件基本的操作接口。

  • IDataSource:它是实现远程调用stagefright DataSource的Binder接口。Android媒体相关的各种服务中,创建的DataSource对象,都通过这个client的远程接口句柄来调用。

  • CallbackDataSource:实现了DataSource接口(实现关系),但它的私有字段mIDataSource中,保留了IDataSource(服务端DataSource)的引用(组合关系),让Client端程序可以回调到server端的DataSource对象,从而具备了”回调“功能。

  • TinyCacheSource:该类实现了DataSource接口(实现关系),在私有字段mSource中可以持有DataSource的引用,这个引用通常是用来存放CallbackDataSource对象的,所以和CallbackDataSource形成了组合关系。另外,该类中还有一个用于缓存的数组mCache[kCacheSize],对于小于kCacheSize的读取,它将提前读取并缓存在mCache中,这不仅极大减少了Client端到Server端的数据读取操作,对提高数据类型嗅探和元数据(metadata)的提取也有较高效率。

回头来看代码:

return new TinyCacheSource(new CallbackDataSource(source));

也就稀松平常了,不过是将server端的FileSource对象,通过IDataSource接口传递到client端后,依次通过CallbackDataSourceTinyCacheSource对象包起来,已达到后续可以通过IDataSource对象调用远端FileSource对象的目的。

new FileSource

整个onPrepareAsync函数执行的前一部分,都在想法设法的通过"media.extractor"服务,获取初始化mDataSource字段,如果初始化失败,那么这个这个逻辑不会执行,如果失败,那么mDataSource的值为NULL

if (mDataSource == nullptr) { // 如果没有从extractor服务中成功获取DataSource就自己创建
    ALOGD("FileSource local");
    mDataSource = new FileSource(mFd, mOffset, mLength);
}
mFd = -1;

这段代码就比较简洁,直接创建一个FileSource,将文件句柄和偏移量,长度等信息作出构造参数传递过去。这里就先不展开FileSource源码的分析,后面涉及到的时候再聊。

小结

所以,总结一下prepareAsync函数:

  • 该函数是异步执行的,整整的prepare动作,是在子线程执行的onPrepareAsync函数中。
  • onPrepareAsync函数主要的作用就是初始化mDataSource字段。共有两种方式,首相尝试通过"media.extractor"服务获取server端DataSource,失败后尝试直接自己new FileSource
  • 远端服务实例化mDataSource能否成功,主要看该服务在系统中是否启用(一般来说都是正常运行的)。
  • 如果无法通过"media.extractor"初始化mDataSource,就直接自己创建(new FileSource)。
  • 不管通过server端还是自己new的方式,mDataSource最终关联的对象都是FileSource的实例。

initFromDataSource

想方设法将mDataSource字段初始化后,接着往下看(不考虑初始化失败的场景)。

if (mDataSource->flags() & DataSource::kIsCachingDataSource) { // 16 & 4 = 0
   mCachedSource = static_cast<NuCachedSource2 *>(mDataSource.get());
}
mIsStreaming = (mCachedSource != NULL); // mIsStreaming = false

// 通过data source初始化extractorinit
status_t err = initFromDataSource();

mDataSource->flags()这段代码,会经历漫长的路程,大概是这样:

mDataSource->flags() ==>> TinyCacheSource::flags() ==>> CallbackDataSource::flags() ==>> FileSource::flags()。

virtual uint32_t flags() {
    return kIsLocalFileSource;
}

最终返回一个固定的值kIsLocalFileSource也就是16。该值定义在一个DataSource的结构体中:

enum Flags {
    kWantsPrefetching      = 1,
    kStreamedFromLocalHost = 2,
    kIsCachingDataSource   = 4,
    kIsHTTPBasedSource     = 8,
    kIsLocalFileSource     = 16,
};

DataSource::kIsCachingDataSource的值为4,16&4 = 0。结果可想而知,哎,走了步闲棋。

该干正事了,分析一下这段调用中最重要的函数之一:initFromDataSource

status_t NuPlayer::GenericSource::initFromDataSource() {
    sp<IMediaExtractor> extractor;
    extractor = MediaExtractor::Create(mDataSource, NULL); // 创建

    mFileMeta = extractor->getMetaData();
    if (mFileMeta != NULL) {
        int64_t duration;
        if (mFileMeta->findInt64(kKeyDuration, &duration)) {
            mDurationUs = duration;
        }
    }

    int32_t totalBitrate = 0;
    size_t numtracks = extractor->countTracks();

    mMimes.clear();
    for (size_t i = 0; i < numtracks; ++i) {
        sp<IMediaSource> track = extractor->getTrack(i);
        sp<MetaData> meta = extractor->getTrackMetaData(i);
        const char *mime;
        CHECK(meta->findCString(kKeyMIMEType, &mime));
        ALOGV("initFromDataSource track[%zu]: %s", i, mime);

        if (!strncasecmp(mime, "audio/", 6)) {
            if (mAudioTrack.mSource == NULL) {
                mAudioTrack.mIndex = i;
                mAudioTrack.mSource = track;
                mAudioTrack.mPackets =
                    new AnotherPacketSource(mAudioTrack.mSource->getFormat());

                if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_VORBIS)) {
                    mAudioIsVorbis = true;
                } else {
                    mAudioIsVorbis = false;
                }

                mMimes.add(String8(mime));
            }
        } else if (!strncasecmp(mime, "video/", 6)) {
            if (mVideoTrack.mSource == NULL) {
                mVideoTrack.mIndex = i;
                mVideoTrack.mSource = track;
                mVideoTrack.mPackets =
                    new AnotherPacketSource(mVideoTrack.mSource->getFormat());

                // video always at the beginning
                mMimes.insertAt(String8(mime), 0);
            }
        }

        mSources.push(track);
        int64_t durationUs;
        if (meta->findInt64(kKeyDuration, &durationUs)) {
            if (durationUs > mDurationUs) {
                mDurationUs = durationUs;
            }
        }

        int32_t bitrate;
        if (totalBitrate >= 0 && meta->findInt32(kKeyBitRate, &bitrate)) {
            totalBitrate += bitrate;
        } else {
            totalBitrate = -1;
        }
    }

    ALOGV("initFromDataSource mSources.size(): %zu  mIsSecure: %d  mime[0]: %s", mSources.size(),
            mIsSecure, (mMimes.isEmpty() ? "NONE" : mMimes[0].string()));

    mBitrate = totalBitrate;
    return OK;
}

IMediaExtractor创建

代码挺长,先来看看MediaExtractor::Create(mDataSource, NULL):

// static
sp<IMediaExtractor> MediaExtractor::Create(
        const sp<DataSource> &source, const char *mime) {
    if (!property_get_bool("media.stagefright.extractremote", true)) {
        // 本地 extractor
        ALOGW("creating media extractor in calling process");
        return CreateFromService(source, mime);
    } else { // 使用远程extractor
        ALOGV("get service manager");
        sp<IBinder> binder = defaultServiceManager()->getService(String16("media.extractor"));
        if (binder != 0) {
            sp<IMediaExtractorService> mediaExService(interface_cast<IMediaExtractorService>(binder));
            sp<IMediaExtractor> ex = mediaExService->makeExtractor(source->asIDataSource(), mime);
            return ex;
        } else {
            ALOGE("extractor service not running");
            return NULL;
        }
    }
    return NULL;
}

第一个条件判断,获取系统属性“media.stagefright.extractremote”,通常该属性都是默认未设置的。

media.stagefright.extractremote系统属性,用于判断是否之处远程extractor服务。

所以property_get_bool调用返回默认值true!true则条件不成立。所以通过远程服务“media.extractor”创建一个MediaExtractor返回。

MediaExtractorService::makeExtractor
sp<IMediaExtractor> MediaExtractorService::makeExtractor(
        const sp<IDataSource> &remoteSource, const char *mime) {
    ALOGV("@@@ MediaExtractorService::makeExtractor for %s", mime);
    sp<DataSource> localSource = DataSource::CreateFromIDataSource(remoteSource);
    sp<IMediaExtractor> ret = MediaExtractor::CreateFromService(localSource, mime);
    ALOGV("extractor service created %p (%s)", ret.get(), ret == NULL ? "" : ret->name());
    if (ret != NULL) {
        registerMediaExtractor(ret, localSource, mime);
    }
    return ret;
}

DataSource::CreateFromIDataSource前面已经详细说明了,这里就不赘述了。来看看更重要的函数

MediaExtractor::CreateFromService
sp<MediaExtractor> MediaExtractor::CreateFromService(
        const sp<DataSource> &source, const char *mime) {
    ALOGV("MediaExtractor::CreateFromService %s", mime);
    RegisterDefaultSniffers();

    sp<AMessage> meta;
    String8 tmp;
    if (mime == NULL) {
        float confidence;
        if (!sniff(source, &tmp, &confidence, &meta)) {
            ALOGW("FAILED to autodetect media content.");
            return NULL;
        }
        mime = tmp.string();
    }

    MediaExtractor *ret = NULL;
    if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG4)
            || !strcasecmp(mime, "audio/mp4")) {
        ret = new MPEG4Extractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_MPEG)) {
        ret = new MP3Extractor(source, meta);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AMR_NB)
            || !strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AMR_WB)) {
        ret = new AMRExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_FLAC)) {
        ret = new FLACExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_WAV)) {
        ret = new WAVExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_OGG)) {
        ret = new OggExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MATROSKA)) {
        ret = new MatroskaExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG2TS)) {
        ret = new MPEG2TSExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AAC_ADTS)) {
        ret = new AACExtractor(source, meta);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG2PS)) {
        ret = new MPEG2PSExtractor(source);
    } else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_MIDI)) {
        ret = new MidiExtractor(source);
    }
	// 略掉了一些跟踪信息的代码
    return ret;
}

天哪噜,感觉又捅了一个马蜂窝,哎,源码真是轻易看不得啊啊啊啊啊啊。内容是真的多,感觉又可以单独拎出来,另起一篇了。

来看看MediaExtractor的类图吧。
在这里插入图片描述

MediaExtractor::RegisterDefaultSniffers
// static
void MediaExtractor::RegisterDefaultSniffers() {
    Mutex::Autolock autoLock(gSnifferMutex);
    if (gSniffersRegistered) { // 只注册一次
        return;
    }
    RegisterSniffer_l(SniffMPEG4);
    RegisterSniffer_l(SniffMatroska);
    RegisterSniffer_l(SniffOgg);
    RegisterSniffer_l(SniffWAV);
    RegisterSniffer_l(SniffFLAC);
    RegisterSniffer_l(SniffAMR);
    RegisterSniffer_l(SniffMPEG2TS);
    RegisterSniffer_l(SniffMP3);
    RegisterSniffer_l(SniffAAC);
    RegisterSniffer_l(SniffMPEG2PS);
    RegisterSniffer_l(SniffMidi);
    gSniffersRegistered = true;
}

sniff的意思是“嗅、用鼻子吸“,sniffer可以翻译为”嗅探器“。所以,该函数是注册默认嗅探器的意思。

在媒体框架中,嗅探器又是个什么概念呢?

这里的嗅探,实际上是对媒体输入源进行文件头的读取,根据文件头内容嗅探出需要什么样的解封装组件,也就是不同的MediaExtractor实现。通过类图,我们也可以知道MediaExtractor有大量的实现,分别针对MP3、AAC、OGG、WAV、MPEG4等格式输入的解封装操作。

回到代码上,函数体中,通过RegisterSniffer_l函数,将不同解封装器的嗅探函数指针保存到了列表gSniffers中。显然,针对于不同封装格式的解封装器,嗅探函数也是不一样的,当然需要对应的解封装器自己实现。

具体注册嗅探器的代码如下:

List<MediaExtractor::SnifferFunc> MediaExtractor::gSniffers;
// static
void MediaExtractor::RegisterSniffer_l(SnifferFunc func) {
    for (List<SnifferFunc>::iterator it = gSniffers.begin();
         it != gSniffers.end(); ++it) {
        if (*it == func) {
            return;
        }
    }
    gSniffers.push_back(func);
}
MediaExtractor::sniff
// static
bool MediaExtractor::sniff(
        const sp<DataSource> &source, String8 *mimeType, float *confidence, sp<AMessage> *meta) {
    *mimeType = "";
    *confidence = 0.0f;
    meta->clear();
    {
        Mutex::Autolock autoLock(gSnifferMutex);
        if (!gSniffersRegistered) { // 在“嗅探”之前必须已经注册了嗅探器
            return false;
        }
    }
    for (List<SnifferFunc>::iterator it = gSniffers.begin();
         it != gSniffers.end(); ++it) { // 遍历所有嗅探器
        String8 newMimeType;
        float newConfidence;
        sp<AMessage> newMeta;
        if ((*it)(source, &newMimeType, &newConfidence, &newMeta)) { // 调用嗅探器的嗅探函数
            if (newConfidence > *confidence) {
                *mimeType = newMimeType;
                *confidence = newConfidence;
                *meta = newMeta;
            }
        }
    }
    return *confidence > 0.0;
}

嗅探器的实现原理基本上都是读取媒体源文件的头信息,不同格式都会有自己的特征,嗅探器就是根据这些特征,来判断是否是需要找的类型。

嗅探函数的目的,就是判断源文件(码流)类型是否和当前格式匹配。

说到嗅探函数的实现,必然会涉及到各种编码格式的特征,鉴于这部分内容实在太多,就不进一步详细分析了。

这里就简单的说一下几个参数的意义:

  • source:这是个DataSource类型的指针,该类型通过层层包裹包含了一系列读取媒体源文件的功能。嗅探函数通过该指针通源文件中读取头信息,来判断源文件的类型。

  • newMimeType:String8类型的指针,一旦嗅探函数通过头信息探测出源文件属于当前类型,该变量会通过指针赋值。这些类型定义在MediaDefs.cpp中如:

    const char *MEDIA_MIMETYPE_IMAGE_JPEG = "image/jpeg";
    const char *MEDIA_MIMETYPE_VIDEO_HEVC = "video/hevc";
    const char *MEDIA_MIMETYPE_VIDEO_MPEG2 = "video/mpeg2";
    
    const char *MEDIA_MIMETYPE_AUDIO_MPEG = "audio/mpeg";
    const char *MEDIA_MIMETYPE_AUDIO_FLAC = "audio/flac";
    const char *MEDIA_MIMETYPE_AUDIO_AC3 = "audio/ac3";
    const char *MEDIA_MIMETYPE_AUDIO_EAC3 = "audio/eac3";
    
    const char *MEDIA_MIMETYPE_CONTAINER_MPEG4 = "video/mp4";
    const char *MEDIA_MIMETYPE_CONTAINER_AVI = "video/avi";
    // ......................此处略去一万字
    
  • newConfidence:float类型指针,一旦嗅探函数通过头信息探测出源文件属于当前类型,该变量会通过指针赋值。该值的意思是**“信心”,每个判断了是自己类型的函数,都会给出对于源文件类型的判断的信心值**,然后通过比较,信心值最大的类型判断获胜,该源文件便会被判定为该类型。例如:SniffAAC对自己的信心值为:0.2、SniffMPEG4:0.4、SniffMatroska:0.6、SniffOgg:0.2等。

  • newMeta:这是一个AMessage对象,用于将嗅探结果(一些和格式相关的头信息)传递给调用者。对于不同格式传递的类型会不一样。

    SniffMPEG4传递的是:文件头结束的偏移量

    if (moovAtomEndOffset >= 0) { mpeg4
        *meta = new AMessage;
        (*meta)->setInt64("meta-data-size", moovAtomEndOffset);
    }
    

    SniffAAC传递:也是文件头结束的偏移位置。

    *meta = new AMessage; aac
    (*meta)->setInt64("offset", pos);
    

    SniffMP3:有文件头结束的偏移量,也有特有的格式位置信息。

    *meta = new AMessage;
    (*meta)->setInt64("offset", pos);
    (*meta)->setInt32("header", header);
    (*meta)->setInt64("post-id3-offset", post_id3_pos);
    

最后说一下整个嗅探函数的返回值return *confidence > 0.0;只需要信息之大于0.0就返回true,说明已经嗅探到格式信息。

一般来说,支持嗅探的格式越多,失败的可能越小。Android默认支持的类型已经足够普通使用了,所以,分析的时候我就当它返回true了。

回到MediaExtractor::CreateFromService中,经过嗅探函数对源文件进行嗅探后,基本能够确定源文件的类型,并把嗅探出来的newMimeType字符串的指针赋值给mime,最终通过在CreateFromService对该类型进行比较,创建对应类型的XXXExtractor。代码如下:

创建指定格式的Extractor
MediaExtractor *ret = NULL;
if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG4)
    || !strcasecmp(mime, "audio/mp4")) {
    ret = new MPEG4Extractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_MPEG)) {
    ret = new MP3Extractor(source, meta);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AMR_NB)
           || !strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AMR_WB)) {
    ret = new AMRExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_FLAC)) {
    ret = new FLACExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_WAV)) {
    ret = new WAVExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_OGG)) {
    ret = new OggExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MATROSKA)) {
    ret = new MatroskaExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG2TS)) {
    ret = new MPEG2TSExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_AAC_ADTS)) {
    ret = new AACExtractor(source, meta);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_CONTAINER_MPEG2PS)) {
    ret = new MPEG2PSExtractor(source);
} else if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_MIDI)) {
    ret = new MidiExtractor(source);
}

创建完指定的MediaExtractor之后,还需要将刚刚创建的XXXExtractor注册一下:

if (ret != NULL) {
    registerMediaExtractor(ret, localSource, mime);
}
registerMediaExtractor
void IMediaExtractor::registerMediaExtractor(
        const sp<IMediaExtractor> &extractor,
        const sp<DataSource> &source,
        const char *mime) {
    ExtractorInstance ex;
    ex.mime = mime == NULL ? "NULL" : mime;
    ex.name = extractor->name();
    ex.sourceDescription = source->toString();
    ex.owner = IPCThreadState::self()->getCallingPid();
    ex.extractor = extractor;
    // ...
        sExtractors.push_front(ex);
    // ...
}

该函数很短,作用也很简单,直接将闯入的XXXExtractor实例用ExtractorInstance包装一下,存放在sExtractors(一个vector)队首中。方便以后查询。

至此IMediaExtractor创建工作才算完成。

小结

简单小结一下创建过程中都做了那些值得注意的事:

  1. 将之前流程中创建的FileSource对象,通过包装成了一个DataSource对象。
  2. 注册各种格式Extractor的嗅探函数。
  3. 通过调用嗅探函数,利用DataSource读取媒体文件头,并分析媒体文件是何种格式。
  4. 根据媒体文件格式,创建对应格式的XXXExtractor

####初始化媒体源基本参数:mFileMeta、mDurationUs

initFromDataSource函数通过千辛万苦创建了Extractor后,任务其实已经完成了一大半了。后面都是一些从Extractor实例后的对象中拿数据进行填充的过程。

接着看看后面初始化mFileMetamDurationUs的调用

mFileMeta = extractor->getMetaData();
if (mFileMeta != NULL) {
    int64_t duration;
    if (mFileMeta->findInt64(kKeyDuration, &duration)) {
        mDurationUs = duration;
    }
}

第一行代码,通过extractor调用getMetaData获取文件的元数据(metadata)。对于getMetaData函数的实现,不同类型的Extractor会有不同的实现手段,例如:

sp<MetaData> MPEG4Extractor::getMetaData() {
    status_t err;
    if ((err = readMetaData()) != OK) { // 从源文件中读取MetaData信息,初始化mFileMetaData
        return new MetaData;
    }
    return mFileMetaData; // 将MetaData 返回给调用者
}
sp<MetaData> MP3Extractor::getMetaData() {
    sp<MetaData> meta = new MetaData; // 直接new
    if (mInitCheck != OK) {
        return meta;
    }
	// ...
    meta->setCString(kKeyMIMEType, "audio/mpeg");
    // ...
        meta->setCString(kMap[i].key, s);
    // ...
        meta->setData(kKeyAlbumArt, MetaData::TYPE_NONE, data, dataSize);
        meta->setCString(kKeyAlbumArtMIME, mime.string());
    return meta;
}

MPEG4ExtractorMP3ExtractorgetMetaData函数实现就大为不同,不能再展开了。这里只顺便提及一下什么是元数据(MetaData):

对于媒体文件而言,元数据一般有:音频采样率、视频帧率、视频尺寸、比特率、编解码、播放时长等基本信息,此外也可能含有其它杂七杂八的信息:名称、版权、专辑、时间、艺术家等。

初始化媒体源基本参数:mMimes、mSources、mBitrate

下面这些代码就不一一解读了,啥都不用说,都在注释里。

int32_t totalBitrate = 0;
size_t numtracks = extractor->countTracks(); // 获取媒体源中的轨道数量,通常为三个,音频、视频、字幕各一个
mMimes.clear(); // 清理掉mMime信息,准备装新的。
for (size_t i = 0; i < numtracks; ++i) { // 遍历轨道,将音视频轨道信息的mime添加到mMimes中
    sp<IMediaSource> track = extractor->getTrack(i); // 获取各轨道
    sp<MetaData> meta = extractor->getTrackMetaData(i); // 获取各轨道的元数据
    const char *mime;
    CHECK(meta->findCString(kKeyMIMEType, &mime));
    ALOGV("initFromDataSource track[%zu]: %s", i, mime);
    if (!strncasecmp(mime, "audio/", 6)) { // 音频轨道
        if (mAudioTrack.mSource == NULL) { // 初始化各种音频轨道信息
            mAudioTrack.mIndex = i;
            mAudioTrack.mSource = track;
            mAudioTrack.mPackets =
                new AnotherPacketSource(mAudioTrack.mSource->getFormat());
            if (!strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_VORBIS)) {
                mAudioIsVorbis = true;
            } else {
                mAudioIsVorbis = false;
            }
            mMimes.add(String8(mime)); // 将音频轨道mime信息,添加到mMimes中
        }
    } else if (!strncasecmp(mime, "video/", 6)) { // 视频轨道
        if (mVideoTrack.mSource == NULL) { // 初始化各种视频轨道信息
            mVideoTrack.mIndex = i;
            mVideoTrack.mSource = track;
            mVideoTrack.mPackets =
                new AnotherPacketSource(mVideoTrack.mSource->getFormat());
            // video always at the beginning
            mMimes.insertAt(String8(mime), 0); // 将视频轨道mime信息,添加到mMimes队首
        }
    }
    mSources.push(track); // 将各轨道信息统一保存在保存在mSources中
    int64_t durationUs;
    if (meta->findInt64(kKeyDuration, &durationUs)) { // 获取媒体播放时长
        if (durationUs > mDurationUs) { // 将个轨道中最大的播放时长作为媒体文件的播放时长
            mDurationUs = durationUs;
        }
    }
	// 通比特率为各轨道比特率之和
    int32_t bitrate;
    if (totalBitrate >= 0 && meta->findInt32(kKeyBitRate, &bitrate)) {
        totalBitrate += bitrate;
    } else {
        totalBitrate = -1;
    }
}
mBitrate = totalBitrate; // 初始化比特率

扯了这么多,才把initFromDataSource搞完,赶紧看一下最后一个函数:

finishPrepareAsync

void NuPlayer::GenericSource::finishPrepareAsync() {
    ALOGV("finishPrepareAsync");
    status_t err = startSources(); // 启动各资源对象
    // ....
    if (mIsStreaming) { // 通常为false
		// ....
    } else {
        notifyPrepared(); // 该函数几乎啥都没做。
    }
}
status_t NuPlayer::GenericSource::startSources() {
    // 在我们开始缓冲之前,立即启动所选的A / V曲目。
    // 如果我们将它延迟到start(),那么在准备期间缓冲的所有数据都将被浪费。
    // (并不是在start()开始执行后,才开始读取数据)
    if (mAudioTrack.mSource != NULL && mAudioTrack.mSource->start() != OK) { // 启动音频
        ALOGE("failed to start audio track!");
        return UNKNOWN_ERROR;
    }

    if (mVideoTrack.mSource != NULL && mVideoTrack.mSource->start() != OK) { // 启动视频
        ALOGE("failed to start video track!");
        return UNKNOWN_ERROR;
    }
    return OK;
}

除了注释部分的信息,关于mSource->start()我也没什么准备在本文补充了,如果以后有机会,我会写n篇来尽未尽之事。

小结prepareSync

好了,prepareSync函数算是告一段落。花了巨大的篇幅来描述,总要总结一波的。

  1. 不管是通过直接创建,还是通过服务创建,总之拐弯抹角的创建了一个FileSource对象。并通过各种封装,达到不一样的用途。
  2. 通过各种封装好的FileSource对象,读取并嗅探源文件的头信息,判断文件格式,并创建对应格式的XXXExtractor
  3. 通过创建好的XXXExtractor,初始化各种媒体相关字段,如:mFileMetamDurationUsmMimesmSourcesmBitrate
  4. mSources中包含所有track信息,track中又包含了对应的流信息,通过这些信息,启动了音视频数据的读取。

start:启动

start在播放流程中的位置为:

  1. GenericSource的创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:启动

  5. stop&pause&resume:停止&暂停&恢复

现在我们来看看GenericSource是怎么参与到播放流程中的。

void NuPlayer::GenericSource::start() {
    ALOGI("start");
    mStopRead = false; // 启动播放时,自然要不暂停读取数据false掉
    if (mAudioTrack.mSource != NULL) { // 在prepareAsync中,已经赋值,自然不能为空
        postReadBuffer(MEDIA_TRACK_TYPE_AUDIO); 
    }
    if (mVideoTrack.mSource != NULL) { // 在prepareAsync中,已经赋值,自然不能为空
        postReadBuffer(MEDIA_TRACK_TYPE_VIDEO);
    }
    mStarted = true;
    (new AMessage(kWhatStart, this))->post();
}

postReadBuffer函数其实做的事情不多,就是把trackType一路向下异步传递,最后让NuPlayer::GenericSource::readBuffer摘桃子,调用链如下:

postReadBuffer ==> onMessageReceived ==> onReadBuffer ==> readBuffer

基本上没啥看头,跳过,直接来readBuffer

NuPlayer::GenericSource::readBuffer

void NuPlayer::GenericSource::readBuffer(
        media_track_type trackType, int64_t seekTimeUs, MediaPlayerSeekMode mode,
        int64_t *actualTimeUs, bool formatChange) 
    if (mStopRead) {
        return;
    }
    Track *track;
    size_t maxBuffers = 1;
    switch (trackType) { // 根据track类型分配最大buffer,并初始化track
        case MEDIA_TRACK_TYPE_VIDEO: // 音频
            track = &mVideoTrack;
            maxBuffers = 8;  // 最大buffer值为64,太大的buffer值会导致不能流畅的执行seek操作。
            break;
        case MEDIA_TRACK_TYPE_AUDIO: // 视频
            track = &mAudioTrack;
            maxBuffers = 64; // 最大buffer值为64
            break;
        case MEDIA_TRACK_TYPE_SUBTITLE: // 字幕
            track = &mSubtitleTrack;
            break;
        // 篇幅有限,能省一行是一行
    }
	// 篇幅有限,能省一行是一行
    for (size_t numBuffers = 0; numBuffers < maxBuffers; ) {
        Vector<MediaBuffer *> mediaBuffers;
        status_t err = NO_ERROR;
		// 从文件中读取媒体数据,用于填充mediaBuffers
        if (couldReadMultiple) { // 这个值一般为true
            err = track->mSource->readMultiple(
                    &mediaBuffers, maxBuffers - numBuffers, &options);
        } else { // read函数其实最终也是调用了readMultiple,只是read的最大buffer数为1
            MediaBuffer *mbuf = NULL;
            err = track->mSource->read(&mbuf, &options); 
            if (err == OK && mbuf != NULL) {
                mediaBuffers.push_back(mbuf);
            }
        }
        size_t id = 0;
        size_t count = mediaBuffers.size();
        for (; id < count; ++id) { // 将所有刚才读到的MediaBuffer中的数据摘出来封装到mPackets中
            int64_t timeUs;
            MediaBuffer *mbuf = mediaBuffers[id];
            // 根据类型,通过mBufferingMonitor监视器更新状态
            if (trackType == MEDIA_TRACK_TYPE_AUDIO) { 
                mAudioTimeUs = timeUs;
                mBufferingMonitor->updateQueuedTime(true /* isAudio */, timeUs);
            } else if (trackType == MEDIA_TRACK_TYPE_VIDEO) {
                mVideoTimeUs = timeUs;
                mBufferingMonitor->updateQueuedTime(false /* isAudio */, timeUs);
            }
            // 根据类型,将MediaBuffer转换为ABuffer
            sp<ABuffer> buffer = mediaBufferToABuffer(mbuf, trackType);
			// 篇幅有限,能省一行是一行
            track->mPackets->queueAccessUnit(buffer); // 将buffer入队,等待播放
            formatChange = false;
            seeking = false;
            ++numBuffers;
        }
    }
}

除了trackType参数外,其它都是有默认参数的,在start调用链中,readBuffer只传入了这个参数。其它参数可以控制seek功能。代码其实比这个长多了,我删掉了些暂时不重要的seek、异常中断等逻辑。

能说的代码注释里说了,继续看一下start函数接下来发送的kWhatResume消息干了啥

case kWhatResume:
{
    mBufferingMonitor->restartPollBuffering(); // 只是让buffer监视器重新循环起来
    break;
}

start小结

  • start函数调用链中,最终的的就是readBuffer函数,该函数最终要的功能就是将各种类型的数据读取并解析到track的buffer队列中,等待播放。
  • 需要注意的是:解封装模块的start函数和NuPlayer的start功能并不相同,NuPlayer的start函数是播放,而解封装模块的start函数则是加载数据,后者是前者的子集。

stop&pause&resume:停止&暂停&恢复

start在播放流程中的位置为:

  1. GenericSource的创建

  2. setDataSource:设置媒体源数据

  3. prepareAsync:准备媒体数据

  4. start:播放视频

  5. stop&pause&resume:停止&暂停&恢复

void NuPlayer::GenericSource::stop() { // 停止
    mStarted = false;
}

void NuPlayer::GenericSource::pause() { // 暂停
    mStarted = false;
}

void NuPlayer::GenericSource::resume() { // 恢复
    mStarted = true;
    (new AMessage(kWhatResume, this))->post();
}

停止、暂停、恢复几个动作,相关函数中仅是改变mStarted,其它几乎什么事情都没做。

这有提醒了我解封装模块和播放器的区别:

  • 播放器的暂停:表示的是暂停播放
  • 解封装模块的暂停:表示暂停将读取并缓存好的数据提供给播放器,这一点同样适用于停止,回复和start则相反。

所以,不管是停止、暂停还是回复的函数,关键都不在函数本身,而在于mStarted变量对于向外提供数据的函数的影响,也就是dequeueAccessUnit

status_t NuPlayer::GenericSource::dequeueAccessUnit(
        bool audio, sp<ABuffer> *accessUnit) {
    if (audio && !mStarted) { // 如果是音频,并且mStarted为false,则不提供数据,返回block
        return -EWOULDBLOCK;
    }
    // ...
}

该函数用于为播放器提供原始媒体数据,audio表示是否为音频,accessUnit则是需要填充的buffer指针。

可以看到,如果GenericSource::stop()或者GenericSource::pause()函数调用后,mStarted变为了false,那么播放器将无法得到媒体数据,也就无法播放了。

那么,有人问,如果是视频不就可以了么。是的,视频还是可以从该函数中获取数据,但对于播放器而言,视频和音频肯定是同时播放,如果没了音频,视频也不会独活的。

好了,解封装模块终于搞定了。妈呀!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章