MediaCodec学习笔记

MediaCodec类可用于访问低级媒体编解码器,即编码/解码组件。它是Android低级别多媒体支持基础设施的一部分(通常一起使用MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, Surface, and AudioTrack.)

从广义上讲,编解码器处理输入数据以生成输出数据。它异步处理数据,并使用一组输入和输出缓冲区。在一个简单的层次上,您请求(或接收)一个空的输入缓冲区,用数据填充它,并将其发送到编解码器进行处理。编解码器使用数据并将其转换为一个空的输出缓冲区。最后,您请求(或接收)一个填充的输出缓冲区,使用它的内容并将其释放回编解码器。

数据类型

Codecs操作三种数据:压缩数据、原始音频数据和原始视频数据。这三种数据都可以使用bytebuffer进行处理,但是您应该使用一个表面来进行原始的视频数据来提高编码的性能。Surface使用本地视频缓冲,而无需将其映射或复制到bytebuffer;因此,它的效率更高。通常,在使用Surface时,您无法访问原始的视频数据,但是您可以使用ImageReader类来访问未加密的解码(原始的)视频帧。这可能仍然比使用bytebuffer更有效,因为一些本机缓冲区可能被映射到直接bytebuffer中。当使用ByteBuffer模式,你可以使用Image类来访问原始的视频帧和MediaCodec的getInput/OutputImage(int)方法.

压缩缓冲区

输入缓冲区(用于解码器)和输出缓冲区(用于编码器)根据格式的类型包含压缩数据。对于视频类型来说,这通常是一个压缩的视频帧。对于音频数据来说,这通常是一个单一的访问单元(一个经过编码的音频段,通常包含由格式类型指定的几毫秒的音频),但是这个要求稍微放松,因为一个缓冲区可能包含多个编码的音频访问单元。在这两种情况下,缓冲区不会在任意的字节边界上开始或结束,而是在帧/访问单元边界上,除非它们被标记为BUFFER_FLAG_PARTIAL_FRAME.。

原始音频缓冲区

原始音频缓冲区包含了PCM音频数据的整个帧,这是通道顺序中的每个通道的一个示例。每个样例都是一个16位的本地字节顺序的整数。

  short[] getSamplesForChannel(MediaCodec codec, int bufferId, int channelIx) {
        //获取某个ByteBuffer,因为MediaCodec是保存了输出和输出的ByteBuffer[]
        ByteBuffer outputBuffer = codec.getOutputBuffer(bufferId);
        //返回特定输出缓冲区的输出格式。
        MediaFormat format = codec.getOutputFormat(bufferId);
        //设置这个缓冲区的字节顺序。ByteOrder.nativeOrder()是得到本地的字节序
        ShortBuffer samples = outputBuffer.order(ByteOrder.nativeOrder())
                //返回一个基于该bytebuffer剩余内容的Short缓冲区。
                .asShortBuffer();
        //返回通道数
        int numChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
        if (channelIx < 0 || channelIx >= numChannels) {
            return null;
        }
        //这个缓冲区中剩余的元素的数量。
        short[] res = new short[samples.remaining() / numChannels];
        for (int i = 0; i < res.length; ++i) {
            res[i] = samples.get(i * numChannels + channelIx);
        }
        return res;
    }

原始视频缓冲区

在ByteBuffer模式下,视频缓冲区是根据它们的color format.你可以从一个数组中获取支持的颜色格式getCodecInfo().getCapabilitiesForType(…).colorFormats.

视频编解码器可以支持三种颜色格式

native raw video format:这是被COLOR_FormatSurface 和它可以用于输入或输出Surface;

flexible YUV buffers(如COLOR_FormatYUV420Flexible):     这些都可以用在input/output的Surface,和ByteBuffer模式一样,通过使用MediaCodec的getInput/OutputImage(int)方法

其他特定格式这些通常只支持ByteBuffer模式。一些颜色格式是特定于供应商的。其他定义在MediaCodecInfo.CodecCapabilities.对于颜色格式来说,这相当于一种灵活的格式你仍然可以使用MediaCodec的getInput/OutputImage(int)方法;

在旧设备上访问原始视频

在LOLLIPOP和Image支持之前,你需要使用KEY_STRIDE 和KEY_SLICE_HEIGHT输出格式的值推断出原始输出缓冲区。

KEY_STRIDE:描述视频bytebuffer的一个关键字。跨步(或行增量)是像素的索引和直接在下面的像素之间的区别。因为YUV 420格式,stride对应于Y平面。这stride的U和V面位可以根据颜色的格式来计算,但是通常没有定义,并且依赖于设备和发布。关联的值是一个整数,表示字节数。

KEY_SLICE_HEIGHT:描述一个关键的多平面(YUV)平面高度的视频bytebuffer。Slice 的高度(或面的高度或垂直的跨步)必须跳过的行数从Y平面的顶端到U平面的顶端得到bytebuffer

在一些设备上slice-height被设置为0的,这可能意味着slice-height和frame高度一样,或者指出了slice-height帧高度与某些值对齐(通常是2的幂)。不幸的是,在这种情况下,没有标准和简单的方法来判断实际的slice height。此外,平面格式的U平面的垂直跨步也没有被指定或定义,尽管通常它是slice height的一半

KEY_WIDTH 和KEY_HEIGHT 关键字指定了帧视频的大小,然而,对于大多数人来说,这段视频(图片)只占帧视频的一部分。这是由“crop rectangle”表示的。

您需要使用以下关键字得到切割的矩形得到输出格式的原始输出图像,如果这些关键字找不到对应的矩形,那么视频就占据了整个video frame。,这个切割矩形是知道在应用旋转之前的背景下的输出帧。


crop-left:切割矩形左边的坐标值 x
crop-top切割矩形顶部的坐标值y

crop-right切割矩形右边的坐标值x

crop-bottom切割矩形底部坐标值y
按照本人理解就是(crop-leftcrop-topcrop-righcrop-bottom

这个right和 bottom可以理解为right大部分有效的列/bottom大部分有效的行裁剪出来的输出图像

视频帧的大小(在旋转之前)可以这样计算:

 MediaFormat format = decoder.getOutputFormat(…);
 int width = format.getInteger(MediaFormat.KEY_WIDTH);
 if (format.containsKey("crop-left") && format.containsKey("crop-right")) {
     width = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
 }
 int height = format.getInteger(MediaFormat.KEY_HEIGHT);
 if (format.containsKey("crop-top") && format.containsKey("crop-bottom")) {
     height = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
 }
 

还要注意BufferInfo.offset 。在不同的设备上,偏移量不是一致的。在某些设备上,偏移量指向了作物矩形的左上角像素,而在大多数设备上,它指向了整个框架的左上角像素。

States

在编码器的生命周期里概念上是存在三个状态的:停止、执行和释放。而停止其实又是有三个状态的未初始化、配置和错误。执行在概念上会有三个状态进度:刷新,运行和流结束


当您使用一个工厂方法创建一个编解码器时,编解码器处于未初始化状态。首先,您需要通过configure(.)来配置它,它将它带到配置的状态,然后调用start()将其移动到执行状态。在这个状态下你可以处理数据通过上面描述的缓冲区队列操作。

执行状态有三个:刷新,运行和流结束。在start()之后,codec会处于刷新状态,其中包含所有的缓冲区。当第一个输入缓冲区在队列移除时,codec会移动到正在运行的子状态,而它大部分都是处于这个状态的。当你的输入缓冲区被标记为end-of-stream,编解码器就处于End-of-Stream状态。在这种状态下,编解码器不再接受进一步的输入缓冲区,但是仍然会生成输出缓冲区直到遇到end-of-stream。你可以回到Flushed,在任何时候通过调用flush()。
调用stop()将编解码器返回到未初始化状态,这样就可以重新配置它了。当您使用一个编解码器完成时,您必须通过调用release()来释放它。

在极少数情况下,编解码器可能会遇到错误并转移到错误状态。这是队列操作遇到一个无效的返回值的通知或者是某些例外需要手动调用错误来进行通知。调用reset()使编解码器再次可用。您可以从任何状态调用它,将编解码器移回未初始化的状态。否则,调用release()移动到最终释放状态。

创建

使用MediaCodecList创建一个特定MediaFormat的MediaCodec,在解码文件或流时,您可以从MediaExtractor.getTrackFormat得到所需的格式。通过MediaFormat.setFeatureEnabled方法添加你想要的特性。然后调用MediaCodecList.findDecoderForFormat 以获得可处理特定媒体格式的编解码器的名称。最后,使用createByCodecName(String)创建编解码器。

setFeatureEnabled (String feature, boolean enabled)

设置一个特性是否启用(true)或禁用(false).如果启用是true的,则该特性是有效的。否则,该特性将被取消掉。

String findDecoderForFormat (MediaFormat format)

注意:在棒棒糖上,MediaCodecList.findDecoder/EncoderForFormat的格式必须不包含帧速率。使用format.setString(MediaFormat.KEY_FRAME_RATE,null)以清除格式中任何现存的帧速率设置。


你也可以使用一个特定的MIME类型创建一个特别的编解码器,通过使用createDecoder/EncoderByType(String).但是这样子就不能设置一些注入特性,并且创建的编解码器可能不能处理某些特定的媒体格式。

创建安全的解码器

在版本4.4和更早之前,安全的编解码器可能不会在MediaCodecList中列出,但是在系统中仍然可以使用,可以通过名称来实例化。可以通过“.secure”来获得一个安全编解码器,通过这个方法 createByCodecName(String) (所有的完全编解码器都是以“.secure”结束),如果在系统上不存在这样子的编解码器,则会抛出一个IOException。

从棒棒糖开始,你应该使用FEATURE_SecurePlayback媒体格式的特性,以创建一个安全的解码器。

初始化

在创建了编解码器之后,你可以通过一个回调setcallback,监听异步处理数据。然后,用configure配置编解码器的特定的媒体格式。这时候可以指定视频的输出面,通过视频制造着编解码器生成原始的视频数据。这也是你可以设置安全编解码器的解密参数的时候(详见MediaCrypto)。

最后,由于一些编解码器可以在多种模式下工作,所以必须指定是否希望用作解码器或编码器。

由于LOLLIPOP,您可以在配置的状态中查询所得到的输入和输出格式。在启动编解码器之前,您可以使用它来验证生成的配置(例如颜色格式)。

如果您想要处理原始的输入视频缓冲区,可以使用一个视频消费程序来处理原始的视频输入,比如视频编码器在配置后使用createInputSurface()为您的输入数据创建一个目标Surface。另一种情况是,通过调用setInputSurface(surface),设置codec来使用之前创建的持久输入Surface。

codec-特殊的数据

一些格式,特别是AAC音频和MPEG4,h。264和H。265视频格式要求实际数据由包含设置数据的多个缓冲区预先设置,或者是编解码的特定数据。在处理这种压缩格式时,必须在start()和任何帧数据之前将这些数据提交给编解码器。在对queueInputBuffer的调用中,必须使用标记BUFFER_FLAG_CODEC_CONFIG 来标记这些数据。

Codec-specific数据也可以通过configure 配置格式包含在ByteBuffer中,通过key"csd-0", "csd-1",等等。这些key总是包括音轨的MediaFormat从MediaExtractor中获得。在start()之前,Codec-specific格式中的数据自动提交。您不能显式地提交这些数据。如果格式不包含Codec-specific的数据,您可以选择按照正确的顺序使用指定数量的缓冲区来提交它,根据格式要求。对于H.264 AVC,你也可以连接所有的codec-specific数据并将其作为单一的数据提交codec-config缓冲区

Android使用以下codec-specific数据缓冲区,这些也需要被设定轨道格式为了特有的MediaMuxer轨道配置。每个参数设置和带有(*)标记的codec-必须从 "\x00\x00\x00\x01"开始代码开始。


  • 注意:请必须小心谨慎,如果codec被立即刷新或者开始后不久立即刷新,必须在任何输出缓冲区或者输出格式改变已经返回之前,因为codec specific数据可能在刷新吧期间丢失。在这样子的刷新之后,你必须重新提交数据,使用缓冲区标志BUFFER_FLAG_CODEC_CONFIG ,确保正确的编解码器操作。
  • 编码器(或者是编码器生成的压缩数据)将会在创建和返回codec specific data之前在输出缓存区中用BUFFER_FLAG_CODEC_CONFIG标记任何有效的输出缓存区。缓存区包含codec-specific-data没有任何意义的时间戳
  • 数据处理

每个编解码器维护一组输入和输出缓冲区,这是有api通过buffer-ID来调用得到的。在成功调用start()之后客户端“拥有”既不输入也不输出缓冲区。再同步模式下,调用dequeueInput/OutputBuffer(…) 从编解码器获得(获得)一个输入或输出缓冲区。在异步模式下,你会自动接收缓冲区可用通过MediaCodec.Callback.onInput / OutputBufferAvailable(…)回调。

获得输入缓冲区后,使用解密时codec使用queueInputBuffer – or queueSecureInputBuffer填充数据和提交它。不要使用相同的时间戳提交多个输入缓冲区(除非它是codec-specific data标记为这样的 )。

接着,codec将通过异步模式中的onoutputbuffer可用回调返回一个只读输出缓冲区,或者响应同步模式下的dequeuOutputBuffer调用。在处理完输出缓冲区之后,调用一个释放-outputbuffer方法来将缓冲区返回到编解码器。

当您不需要立即向编解码器提交/释放缓冲区时,保留输入/或输出缓冲区可能会使编解码器陷入停顿,而这种行为依赖于设备。具体地说,在所有未完成的缓冲区被发布/重新提交之前,一个编解码器可能会推迟生成输出缓冲区。因此,尽可能少地保留可用的缓冲区。

根据API版本的不同,您可以用以下三种方式处理数据:


异步处理使用缓冲区

自从LOLLIPOP以来,首选方法是在调用configure之前通过设置一个回调来异步处理数据。异步模式稍微改变状态转换,因为您必须在flush()
之后调用start(),以便将编解码器转换为正在运行的子状态,并开始接收输入缓冲区。类似地,在启动编解码器的初始调用时,将直接移动到正在运行的子状态,并开始通过回调传递可用的输入缓冲区。


MediaCodec通常在异步模式下使用:

MediaCodec codec = MediaCodec.createByCodecName(name);
    MediaFormat mOutputFormat; // member variable
    codec.setCallback(new MediaCodec.Callback() {
        @Override
        void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {
            ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
            // fill inputBuffer with valid data
     
            codec.queueInputBuffer(inputBufferId, …);
        }

        @Override
        void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, …) {
            ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
            MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
            // bufferFormat is equivalent to mOutputFormat
            // outputBuffer is ready to be processed or rendered.
     
            codec.releaseOutputBuffer(outputBufferId, …);
        }

        @Override
        void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
            // Subsequent data will conform to new format.
            // Can ignore if using getOutputFormat(outputBufferId)
            mOutputFormat = format; // option B
        }

        @Override
        void onError(…) {
     
        }
    });
 codec.configure(format, …);
    mOutputFormat = codec.getOutputFormat(); // option B
 codec.start();
    // wait for processing to complete
 codec.stop();
 codec.release();

同步处理使用缓冲区

由于LOLLIPOP,您应该使用getinput/outputbuffer(int)或getinput/outputimage(int)来检索输入和输出缓冲区,即使在同步模式下使用编码时也是这样。这允许框架进行某些优化,例如处理动态内容时。如果您调用getinput/outputbuffer(),将禁用此优化。
注意:不要同时混合使用缓冲区和缓冲区数组的方法。具体地说,只有在start()之后才会直接调用getinput/outputbuffer,或者在使用INFO_OUTPUT_FORMAT_CHANGED的值将输出缓冲区ID放入队列之后。

MediaCodec 通常在同步模式下使用像这样:

 MediaCodec codec = MediaCodec.createByCodecName(name);
 codec.configure(format, …);
 MediaFormat outputFormat = codec.getOutputFormat(); // option B
 codec.start();
 for (;;) {
   int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
   if (inputBufferId >= 0) {
     ByteBuffer inputBuffer = codec.getInputBuffer(…);
     // fill inputBuffer with valid data
     
     codec.queueInputBuffer(inputBufferId, …);
   }
   int outputBufferId = codec.dequeueOutputBuffer(…);
   if (outputBufferId >= 0) {
     ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
     MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
     // bufferFormat is identical to outputFormat
     // outputBuffer is ready to be processed or rendered.
     
     codec.releaseOutputBuffer(outputBufferId, …);
   } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
     // Subsequent data will conform to new format.
     // Can ignore if using getOutputFormat(outputBufferId)
     outputFormat = codec.getOutputFormat(); // option B
   }
 }
 codec.stop();
 codec.release();

使用缓冲区数组的同步处理(弃用)

在kitkatwatch和以前的版本中,输入和输出缓冲区由ByteBuffer数组表示。在成功调用start()之后,使用getinput/outputbuffer()检索缓冲区数组。在以下示例中演示的是,使用缓冲区-id作为索引(当非负时)。注意,数组的大小与系统使用的输入和输出缓冲区之间没有内在的关联,尽管数组大小提供了一个上限。

MediaCodec codec = MediaCodec.createByCodecName(name);
 codec.configure(format, …);
 codec.start();
 ByteBuffer[] inputBuffers = codec.getInputBuffers();
 ByteBuffer[] outputBuffers = codec.getOutputBuffers();
 for (;;) {
   int inputBufferId = codec.dequeueInputBuffer(…);
   if (inputBufferId >= 0) {
     // fill inputBuffers[inputBufferId] with valid data
     
     codec.queueInputBuffer(inputBufferId, …);
   }
   int outputBufferId = codec.dequeueOutputBuffer(…);
   if (outputBufferId >= 0) {
     // outputBuffers[outputBufferId] is ready to be processed or rendered.
     
     codec.releaseOutputBuffer(outputBufferId, …);
   } else if (outputBufferId == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
     outputBuffers = codec.getOutputBuffers();
   } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
     // Subsequent data will conform to new format.
     MediaFormat format = codec.getOutputFormat();
   }
 }
 codec.stop();
 codec.release();
End-of-stream

当您到达输入数据的末尾时,您必须通过在调用到queueInputBuffer的调用中指定bufferflagendofstream标志来将它发送到编解码器。您可以在最后一个有效的输入缓冲区中完成此工作,或者通过向流标志集提交一个额外的空输入缓冲区,如果使用一个空的缓冲区,那么时间戳将被忽略。

编解码器将继续返回输出缓冲区,直到它通过在MediaCodec中指定相同的尾流标志来结束输出流的结束。BufferInfo设置在dequeueOutputBuffer或通过onoutputbuffer有空返回。这可以设置在最后一个有效的输出缓冲区,或者在最后一个有效的输出缓冲区之后的空缓冲区上。这样空缓冲区的时间戳应该被忽略。

在输入流结束后,不要提交额外的输入缓冲区,除非编解码器已经被刷新,或者停止并重新启动。

使用一个输出表面

当使用输出表时,数据处理与ByteBuffer模式几乎相同;但是,输出缓冲区是不可访问的,并且被表示为null值。getoutputbuff/image(int)将返回null,getoutputbuffer()将返回一个仅包含null-s的数组。

在使用输出面时,您可以选择是否在表面上呈现每个输出缓冲区。你有三个选择:

不要渲染缓冲区:调用释放-outputbuffer(bufferId,false)。
使用默认的时间戳来呈现缓冲区:调用释放-outputbuffer(bufferId,true)。
使用特定的时间戳来呈现缓冲区:调用释放-outputbuffer(bufferId、timestamp)。

从M开始,默认的时间戳是缓冲区的表示时间戳(转换为纳秒)。在此之前还没有定义。
同样,从M开始,您可以使用setOutputSurface动态地更改输出表面。

当渲染到表面时的转换

如果将codec配置为地表模式,任何作物矩形、旋转和视频缩放模式都将自动应用于一个异常

在M发布之前,软件解码器在渲染到表面时可能不会应用旋转。不幸的是,没有一种标准和简单的方法来识别软件解码者,或者如果他们应用轮转,而不是去尝试。

也有一些警告

注意,当将输出显示在表面时,不考虑像素方面的比率。这意味着,如果您使用videoscalingmodescaletofit模式,您必须定位输出的表面,使其具有适当的最终显示方面比。相反,你只能使用视频缩放的modescaletofitwith裁剪模式来满足正方形像素的要求(像素宽比或1:1)。

还要注意的是,在N个版本中,视频缩放modescaletofitwith裁剪模式可能无法正确地处理90或270度旋转的视频。

在设置视频缩放模式时,请注意,每次输出缓冲区发生更改后,必须重新设置它。由于已经弃用了infooutputbufferschanged事件,所以您可以在每次输出格式更改后执行此工作。

使用一个输入表面

当使用输入表面时,没有可访问的输入缓冲区,因为缓冲区会自动从输入面传递到编解码器。调用dequeueInputBuffer将抛出一个非法状态异常,而getinputbuffer()将返回一个不可写入的伪ByteBuffer数组。

调用signal内生finputstream()以显示结束流。输入表面将在此调用后立即停止向编解码器提交数据。

寻找和自适应回放支持

视频解码器(通常是使用压缩视频数据的codecs)在查找和格式更改方面的行为不同,无论它们是否支持并配置为自适应回放。您可以检查一个译码器是否支持自适应通过CodecCapabilities.isFeatureSupported回放(字符串)。当你将编解码器解码到一个表面上时,视频解码器的自适应回放支持才会被激活。

流边界和关键帧

重要的是,在start()或刷新()之后的输入数据开始于一个合适的流边界:第一个帧必须是一个关键帧。一个关键帧可以被完全解码(对于大多数编解码器来说,这意味着一个i-frame),并且在关键帧之前,没有任何帧会显示在关键帧之前的帧。

下表总结了适合各种视频格式的关键帧。


对于不支持自适应回放的解码器(包括不解码到表面)

为了开始解码与之前提交的数据不相邻的数据(例如在查找之后),不需要刷新译码器;但是,不连续的输入数据必须从一个合适的流边界/关键帧开始。

对于一些视频格式,也就是h。264年,H。VP8和VP9-也可以改变图片的大小或配置中流。要做到这一点,您必须将整个新的特定于codec特异性的配置数据连同关键帧一起打包成一个缓冲区(包括任何启动代码),并将其作为一个常规的输入缓冲区提交。

您将从dequeueOutputBuffer或onoutputformat更改回调中获得一个infooutputformatchange返回值,这是在图片大小的更改发生后,在任何具有新大小的帧都被返回之前。

注意:就像特定于codec特定数据的情况一样,在您更改了图片大小之后,在调用刷新()时要小心。如果您还没有收到图片大小更改的确认,您将需要对新的图片大小重复请求。

错误处理

工厂方法createByCodecName createDecoder / EncoderByType抛出IOException失败,你必须抓住或声明。MediaCodec方法在不允许的情况下,从一个不允许的编解码状态调用方法,抛出非法状态异常;这通常是由于应用程序API的不正确使用造成的。涉及安全缓冲区的方法可能会抛出MediaCodec。隐异常,它可以从getErrorCode()中获得更多的错误信息。

内部编解码器错误导致了一个MediaCodec。CodecException,可能是由于媒体内容的腐败、硬件故障、资源耗尽等等,甚至在应用程序正确使用API时也是如此。当接收到一个CodecException时,推荐的操作可以通过调用is恢复性()和i绞()来确定。

可恢复的错误:如果isRecoverable()返回true,则调用stop()、configure(.)和start()来恢复。
暂时错误:如果isTransient() 返回true,则资源暂时不可用,该方法可能会在稍后重试。
致命错误:如果两个isRecoverable()isTransient()返回false,则CodecException是致命的,codec必须被重置或释放。

iisRecoverable()isTransient()都不会在同一时间返回true。


发布了50 篇原创文章 · 获赞 19 · 访问量 10万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览