Android 音频框架概述(一)之 AudioTrack

1. Android 音频框架概述

在这里插入图片描述

Audio 是整个 Android 平台非常重要的一个组成部分,负责音频数据的采集和输出、音频流的控制、音频设备的管理、音量调节等,主要包括如下部分:

Audio Application Framework:音频应用框架

AudioTrack:负责回放数据的输出,属 Android 应用框架 API 类
AudioRecord:负责录音数据的采集,属 Android 应用框架 API 类
AudioSystem: 负责音频事务的综合管理,属 Android 应用框架 API 类

Audio Native Framework:音频本地框架

AudioTrack:负责回放数据的输出,属 Android 本地框架 API 类
AudioRecord:负责录音数据的采集,属 Android 本地框架 API 类
AudioSystem: 负责音频事务的综合管理,属 Android 本地框架 API 类

Audio Services:音频服务

AudioPolicyService:音频策略的制定者,负责音频设备切换的策略抉择、音量调节策略等
AudioFlinger:音频策略的执行者,负责输入输出流设备的管理及音频流数据的处理传输
Audio HAL:音频硬件抽象层,负责与音频硬件设备的交互,由 AudioFlinger 直接调用

与 Audio 强相关的有 MultiMedia,MultiMedia 负责音视频的编解码,

MultiMedia 将解码后的数据通过 AudioTrack 输出,而 AudioRecord 采集的录音数据交由 MultiMedia 进行编码。


2. AudioTrack API 概述

播放声音可以使用 MediaPlayer 和 AudioTrack,两者都提供 Java API 给应用开发者使用。两者的差别在于:
MediaPlayer 可以播放多种格式的音源,如 mp3、flac、wma、ogg、wav 等
而 AudioTrack 只能播放解码后的 PCM 数据流.

从上面 Android 音频系统架构图来看:
MediaPlayer 在 Native 层会创建对应的音频解码器和一个 AudioTrack,解码后的数据交由 AudioTrack 输出。
所以 MediaPlayer 的应用场景更广,一般情况下使用它也更方便;只有一些对声音时延要求非常苛刻的应用场景才需要用到 AudioTrack。

2.1. AudioTrack Java API

AudioTrack Java API 两种数据传输模式:

Transfer ModeDescription
MODE_STATIC应用进程将回放数据一次性付给 AudioTrack,适用于数据量小、时延要求高的场景
MODE_STREAM用进程需要持续调用 write() 写数据到 FIFO,写数据时有可能遭遇阻塞
(等待 AudioFlinger::PlaybackThread 消费之前的数据),基本适用所有的音频场景

AudioTrack Java API 音频流类型:

Stream TypeDescription
STREAM_VOICE_CALL电话语音
STREAM_SYSTEM系统声音
STREAM_RING铃声声音,如来电铃声、闹钟铃声等
STREAM_MUSIC音乐声音
STREAM_ALARM警告音
STREAM_NOTIFICATION通知音
STREAM_DTMFDTMF 音(拨号盘按键音)

Android 为什么要定义这么多的流类型?这与 Android 的音频管理策略有关,例如:
① 音频流的音量管理,调节一个类型的音频流音量,不会影响到其他类型的音频流
② 根据流类型选择合适的输出设备:比如插着有线耳机期间,音乐声(STREAM_MUSIC)只会输出到有线耳机,而铃声(STREAM_RING)会同时输出到有线耳机和外放

这些属于 AudioPolicyService 的内容, 应用开发者应该根据应用场景选择相应的流类型,以便系统为这道流选择合适的输出设备。

一个 AudioTrack Java API 的测试例子(MODE_STREAM 模式):

    //Test case 1: setStereoVolume() with max volume returns SUCCESS
    @LargeTest
    public void testSetStereoVolumeMax() throws Exception {
        // constants for test
        final String TEST_NAME = "testSetStereoVolumeMax";
        final int TEST_SR = 22050;
        final int TEST_CONF = AudioFormat.CHANNEL_OUT_STEREO;
        final int TEST_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
        final int TEST_MODE = AudioTrack.MODE_STREAM;
        final int TEST_STREAM_TYPE = AudioManager.STREAM_MUSIC;

        //-------- initialization --------------
        // 稍后详细分析 getMinBufferSize
        int minBuffSize = AudioTrack.getMinBufferSize(TEST_SR, TEST_CONF, TEST_FORMAT);
        // 创建一个 AudioTrack 实例
        AudioTrack track = new AudioTrack(TEST_STREAM_TYPE, TEST_SR, TEST_CONF, TEST_FORMAT, 
                minBuffSize, TEST_MODE);
        byte data[] = new byte[minBuffSize/2];
        //--------    test        --------------
        // 调用 write() 写入回放数据
        track.write(data, 0, data.length);
        track.write(data, 0, data.length);
        // 调用 play() 开始播放
        track.play();
        float maxVol = AudioTrack.getMaxVolume();
        assertTrue(TEST_NAME, track.setStereoVolume(maxVol, maxVol) == AudioTrack.SUCCESS);
        //-------- tear down      --------------
        // 播放完成后,调用 release() 释放 AudioTrack 实例
        track.release();
    }

详细说明下 getMinBufferSize() 接口: 字面意思是返回最小数据缓冲区的大小,它是声音能正常播放的最低保障,从函数参数来看,返回值取决于采样率、采样深度、声道数这三个属性。

MODE_STREAM 模式下,应用程序重点参考其返回值然后确定分配多大的数据缓冲区。
如果数据缓冲区分配得过小,那么播放声音会频繁遭遇 underrun,
underrun 是指生产者(AudioTrack)提供数据的速度跟不上消费者(AudioFlinger::PlaybackThread)消耗数据的速度,
反映到现实的后果就是声音断续卡顿,严重影响听觉体验。

如下是getMinBufferSize()的代码实现:

// AudioTrack.java
/**
     * Returns the estimated minimum buffer size required for an AudioTrack
     * object to be created in the {@link #MODE_STREAM} mode.
     * The size is an estimate because it does not consider either the route or the sink,
     * since neither is known yet.  Note that this size doesn't
     * guarantee a smooth playback under load, and higher values should be chosen according to
     * the expected frequency at which the buffer will be refilled with additional data to play.
     * For example, if you intend to dynamically set the source sample rate of an AudioTrack
     * to a higher value than the initial source sample rate, be sure to configure the buffer size
     * based on the highest planned sample rate.
     * @param sampleRateInHz the source sample rate expressed in Hz.
     *   {@link AudioFormat#SAMPLE_RATE_UNSPECIFIED} is not permitted.
     * @param channelConfig describes the configuration of the audio channels.
     *   See {@link AudioFormat#CHANNEL_OUT_MONO} and
     *   {@link AudioFormat#CHANNEL_OUT_STEREO}
     * @param audioFormat the format in which the audio data is represented.
     *   See {@link AudioFormat#ENCODING_PCM_16BIT} and
     *   {@link AudioFormat#ENCODING_PCM_8BIT},
     *   and {@link AudioFormat#ENCODING_PCM_FLOAT}.
     * @return {@link #ERROR_BAD_VALUE} if an invalid parameter was passed,
     *   or {@link #ERROR} if unable to query for output properties,
     *   or the minimum buffer size expressed in bytes.
     */
    static public int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat) {
        int channelCount = 0;
        switch(channelConfig) {
        case AudioFormat.CHANNEL_OUT_MONO:
        case AudioFormat.CHANNEL_CONFIGURATION_MONO:
            channelCount = 1; // 单声道
            break;
        case AudioFormat.CHANNEL_OUT_STEREO:
        case AudioFormat.CHANNEL_CONFIGURATION_STEREO:
            channelCount = 2; // 双声道
            break;
        default:
            if (!isMultichannelConfigSupported(channelConfig)) {
                loge("getMinBufferSize(): Invalid channel configuration.");
                return ERROR_BAD_VALUE;
            } else {
                channelCount = AudioFormat.channelCountFromOutChannelMask(channelConfig);
            }
        }

        if (!AudioFormat.isPublicEncoding(audioFormat)) {
            loge("getMinBufferSize(): Invalid audio format.");
            return ERROR_BAD_VALUE;
        }

        // sample rate, note these values are subject to change
        // Note: AudioFormat.SAMPLE_RATE_UNSPECIFIED is not allowed
        if ( (sampleRateInHz < AudioFormat.SAMPLE_RATE_HZ_MIN) ||
                (sampleRateInHz > AudioFormat.SAMPLE_RATE_HZ_MAX) ) {
            loge("getMinBufferSize(): " + sampleRateInHz + " Hz is not a supported sample rate.");
            return ERROR_BAD_VALUE; // 采样率支持:4KHz~192KHz
        }

        // 调用 JNI 方法,下面分析该函数
        int size = native_get_min_buff_size(sampleRateInHz, channelCount, audioFormat);
        if (size <= 0) {
            loge("getMinBufferSize(): error querying hardware");
            return ERROR;
        }
        else {
            return size;
        }
    }

// android_media_AudioTrack.cpp
// ----------------------------------------------------------------------------
// returns the minimum required size for the successful creation of a streaming AudioTrack
// returns -1 if there was an error querying the hardware.
static jint android_media_AudioTrack_get_min_buff_size(JNIEnv *env,  jobject thiz,
    jint sampleRateInHertz, jint channelCount, jint audioFormat) {

    size_t frameCount;
    // 调用 AudioTrack::getMinFrameCount,这里不深究,到 native 层再分析
    // 这个函数用于确定至少设置多少个 frame 才能保证声音正常播放,也就是最低帧数
    const status_t status = AudioTrack::getMinFrameCount(&frameCount, AUDIO_STREAM_DEFAULT,
            sampleRateInHertz);
    if (status != NO_ERROR) {
        ALOGE("AudioTrack::getMinFrameCount() for sample rate %d failed with status %d",
                sampleRateInHertz, status);
        return -1;
    }
    const audio_format_t format = audioFormatToNative(audioFormat);
    if (audio_has_proportional_frames(format)) {
        const size_t bytesPerSample = audio_bytes_per_sample(format);
        return frameCount * channelCount * bytesPerSample; // PCM 数据最小缓冲区大小
    } else {
        return frameCount;
    }
}

可见最小缓冲区的大小 = 最低帧数 * 声道数 * 采样深度,(采样深度以字节为单位)
到这里大家应该有所明悟了吧,在视频中,如果帧数过低,那么画面会有卡顿感,对于音频,道理也是一样的。最低帧数如何求得,我们到 native 层再解释。

关于 MediaPlayer、AudioTrack,更多更详细的 API 接口说明请参考 Android Developer:
MediaPlayer:https://developer.android.com/reference/android/media/MediaPlayer.html
AudioTrack:https://developer.android.com/reference/android/media/AudioTrack.html

2.2. AudioTrack Native API

AudioTrack Native API 四种数据传输模式:

Transfer ModeDescription
TRANSFER_CALLBACK在 AudioTrackThread 线程中通过 audioCallback 回调函数主动从应用进程那里索取数据,
ToneGenerator 采用这种模式
TRANSFER_OBTAIN应用进程需要调用 obtainBuffer()/releaseBuffer() 填充数据,目前我还没有见到实际的使用场景
TRANSFER_SYNC应用进程需要持续调用 write() 写数据到 FIFO,写数据时有可能遭遇阻塞
(等待 AudioFlinger::PlaybackThread 消费之前的数据),基本适用所有的音频场景;
对应于 AudioTrack Java API 的 MODE_STREAM 模式
TRANSFER_SHARED应用进程将回放数据一次性付给 AudioTrack,适用于数据量小、时延要求高的场景;
对应于 AudioTrack Java API 的 MODE_STATIC 模式

AudioTrack Native API 音频流类型:

Stream TypeDescription
AUDIO_STREAM_VOICE_CALL电话语音
AUDIO_STREAM_SYSTEM系统声音
AUDIO_STREAM_RING铃声声音,如来电铃声、闹钟铃声等
AUDIO_STREAM_MUSIC音乐声音
AUDIO_STREAM_ALARM警告音
AUDIO_STREAM_NOTIFICATION通知音
AUDIO_STREAM_DTMFDTMF 音(拨号盘按键音)

AudioTrack Native API 输出标识:

AUDIO_OUTPUT_FLAGDescription
AUDIO_OUTPUT_FLAG_DIRECT表示音频流直接输出到音频设备,不需要软件混音,
一般用于 HDMI 设备声音输出
AUDIO_OUTPUT_FLAG_PRIMARY表示音频流需要输出到主输出设备,
一般用于铃声类声音
AUDIO_OUTPUT_FLAG_FAST表示音频流需要快速输出到音频设备,
一般用于按键音、游戏背景音等对时延要求高的场景
AUDIO_OUTPUT_FLAG_DEEP_BUFFER表示音频流输出可以接受较大的时延,
一般用于音乐、视频播放等对时延要求不高的场景
AUDIO_OUTPUT_FLAG_COMPRESS_OFFLOAD表示音频流没有经过软件解码,
需要输出到硬件解码器,由硬件解码器进行解码

我们根据不同的播放场景,使用不同的输出标识,
如按键音、游戏背景音对输出时延要求很高,那么就需要置 AUDIO_OUTPUT_FLAG_FAST,
具体可以参考 ToneGenerator、SoundPool 和 OpenSL ES。

一个 AudioTrack Natvie API 的测试例子(MODE_STATIC/TRANSFER_SHARED 模式),
代码文件位置:frameworks/base/media/tests/audiotests/shared_mem_test.cpp:

int AudioTrackTest::Test01() {

    sp<MemoryDealer> heap;
    sp<IMemory> iMem;
    uint8_t* p;

    short smpBuf[BUF_SZ];
    long rate = 44100;
    unsigned long phi;
    unsigned long dPhi;
    long amplitude;
    long freq = 1237;
    float f0;

    f0 = pow(2., 32.) * freq / (float)rate;
    dPhi = (unsigned long)f0;
    amplitude = 1000;
    phi = 0;
    Generate(smpBuf, BUF_SZ, amplitude, phi, dPhi);  // fill buffer

    for (int i = 0; i < 1024; i++) {
        // 分配一块匿名共享内存
        heap = new MemoryDealer(1024*1024, "AudioTrack Heap Base");

        iMem = heap->allocate(BUF_SZ*sizeof(short));

        // 把音频数据拷贝到这块匿名共享内存上
        p = static_cast<uint8_t*>(iMem->pointer());
        memcpy(p, smpBuf, BUF_SZ*sizeof(short));

        // 构造一个 AudioTrack 实例,该 AudioTrack 的数据方式是 MODE_STATIC
        // 音频数据已经一次性拷贝到共享内存上了,不用再调用 track->write() 填充数据了
        sp<AudioTrack> track = new AudioTrack(AUDIO_STREAM_MUSIC,// stream type
               rate,
               AUDIO_FORMAT_PCM_16_BIT,// word length, PCM
               AUDIO_CHANNEL_OUT_MONO,
               iMem);

        // 检查 AudioTrack 实例是否构造成功饿了
        status_t status = track->initCheck();
        if(status != NO_ERROR) {
            track.clear();
            ALOGD("Failed for initCheck()");
            return -1;
        }

        // start play
        ALOGD("start");
        track->start(); // 开始播放

        usleep(20000);

        ALOGD("stop");
        track->stop(); // 停止播放
        iMem.clear();
        heap.clear();
        usleep(20000);
    }

    return 0;
}

上个小节还存在一个问题:AudioTrack::getMinFrameCount() 如何计算最低帧数呢?

首先要了解音频领域中,帧(frame)的概念:

帧表示一个完整的声音单元,所谓的声音单元是指一个采样样本;如果是双声道,那么一个完整的声音单元就是 2 个样本,如果是 5.1 声道,那么一个完整的声音单元就是 6 个样本了。

帧的大小(一个完整的声音单元的数据量)等于声道数乘以采样深度,即 frameSize = channelCount * bytesPerSample。
帧的概念非常重要,无论是框架层还是内核层,都是以帧为单位去管理音频数据缓冲区的。

其次还得了解音频领域中,传输延迟(latency)的概念:

传输延迟表示一个周期的音频数据的传输时间。可能有些读者一脸懵逼,一个周期的音频数据,这又是啥?
我们再引入周期(period)的概念:
Linux ALSA 把数据缓冲区划分为若干个块,dma 每传输完一个块上的数据即发出一个硬件中断,cpu 收到中断信号后,再配置 dma 去传输下一个块上的数据;一个块即是一个周期,周期大小(periodSize)即是一个数据块的帧数。
再回到传输延迟(latency),传输延迟等于周期大小除以采样率,即 latency = periodSize / sampleRate。

最后了解下音频重采样:**

音频重采样是指这样的一个过程——把一个采样率的数据转换为另一个采样率的数据。
Android 原生系统上,音频硬件设备一般都工作在一个固定的采样率上(如 48 KHz),因此所有音轨数据都需要重采样到这个固定的采样率上,然后再输出。

为什么这么做?
系统中可能存在多个音轨同时播放,而每个音轨的采样率可能是不一致的;比如在播放音乐的过程中,来了一个提示音,这时需要把音乐和提示音混音并输出到硬件设备,而音乐的采样率和提示音的采样率不一致,问题来了,如果硬件设备工作的采样率设置为音乐的采样率的话,那么提示音就会失真;
因此最简单见效的解决方法是:
硬件设备工作的采样率固定一个值,所有音轨在 AudioFlinger 都重采样到这个采样率上,混音后输出到硬件设备,保证所有音轨听起来都不失真。

sample、frame、period、latency 这些概念与 Linux ALSA 及硬件设备的关系非常密切,这里点到即止,如有兴趣深入了解的话,可参考:Linux ALSA 音频系统:逻辑设备篇

了解这些前置知识后,我们再分析 AudioTrack::getMinFrameCount() 这个函数:

status_t AudioTrack::getMinFrameCount(
        size_t* frameCount,
        audio_stream_type_t streamType,
        uint32_t sampleRate)
{
    if (frameCount == NULL) {
        return BAD_VALUE;
    }

    // 通过 binder 调用到 AudioFlinger::sampleRate(),取得硬件设备的采样率
    uint32_t afSampleRate;
    status_t status;
    status = AudioSystem::getOutputSamplingRate(&afSampleRate, streamType);
    if (status != NO_ERROR) {
        ALOGE("Unable to query output sample rate for stream type %d; status %d",
                streamType, status);
        return status;
    }
    // 通过 binder 调用到 AudioFlinger::frameCount(),取得硬件设备的周期大小
    size_t afFrameCount;
    status = AudioSystem::getOutputFrameCount(&afFrameCount, streamType);
    if (status != NO_ERROR) {
        ALOGE("Unable to query output frame count for stream type %d; status %d",
                streamType, status);
        return status;
    }
    // 通过 binder 调用到 AudioFlinger::latency(),取得硬件设备的传输延迟
    uint32_t afLatency;
    status = AudioSystem::getOutputLatency(&afLatency, streamType);
    if (status != NO_ERROR) {
        ALOGE("Unable to query output latency for stream type %d; status %d",
                streamType, status);
        return status;
    }

    // When called from createTrack, speed is 1.0f (normal speed).
    // This is rechecked again on setting playback rate (TODO: on setting sample rate, too).
    // 根据 afSampleRate、afFrameCount、afLatency 计算出一个最低帧数
    *frameCount = calculateMinFrameCount(afLatency, afFrameCount, afSampleRate, sampleRate, 1.0f);

    // The formula above should always produce a non-zero value under normal circumstances:
    // AudioTrack.SAMPLE_RATE_HZ_MIN <= sampleRate <= AudioTrack.SAMPLE_RATE_HZ_MAX.
    // Return error in the unlikely event that it does not, as that's part of the API contract.
    if (*frameCount == 0) {
        ALOGE("AudioTrack::getMinFrameCount failed for streamType %d, sampleRate %u",
                streamType, sampleRate);
        return BAD_VALUE;
    }
    ALOGV("getMinFrameCount=%zu: afFrameCount=%zu, afSampleRate=%u, afLatency=%u",
            *frameCount, afFrameCount, afSampleRate, afLatency);
    return NO_ERROR;
}

// 有兴趣的可以研究 calculateMinFrameCount() 的实现,需大致了解重采样算法原理
static size_t calculateMinFrameCount(
        uint32_t afLatencyMs, uint32_t afFrameCount, uint32_t afSampleRate,
        uint32_t sampleRate, float speed)
{
    // Ensure that buffer depth covers at least audio hardware latency
    uint32_t minBufCount = afLatencyMs / ((1000 * afFrameCount) / afSampleRate);
    if (minBufCount < 2) {
        minBufCount = 2;
    }
    ALOGV("calculateMinFrameCount afLatency %u  afFrameCount %u  afSampleRate %u  "
            "sampleRate %u  speed %f  minBufCount: %u",
            afLatencyMs, afFrameCount, afSampleRate, sampleRate, speed, minBufCount);
    return minBufCount * sourceFramesNeededWithTimestretch(
            sampleRate, afFrameCount, afSampleRate, speed);
}

static inline size_t sourceFramesNeededWithTimestretch(
        uint32_t srcSampleRate, size_t dstFramesRequired, uint32_t dstSampleRate,
        float speed) {
    // required is the number of input frames the resampler needs
    size_t required = sourceFramesNeeded(srcSampleRate, dstFramesRequired, dstSampleRate);
    // to deliver this, the time stretcher requires:
    return required * (double)speed + 1 + 1; // accounting for rounding dependencies
}

// Returns the source frames needed to resample to destination frames.  This is not a precise
// value and depends on the resampler (and possibly how it handles rounding internally).
// Nevertheless, this should be an upper bound on the requirements of the resampler.
// If srcSampleRate and dstSampleRate are equal, then it returns destination frames, which
// may not be true if the resampler is asynchronous.
static inline size_t sourceFramesNeeded(
        uint32_t srcSampleRate, size_t dstFramesRequired, uint32_t dstSampleRate) {
    // +1 for rounding - always do this even if matched ratio (resampler may use phases not ratio)
    // +1 for additional sample needed for interpolation
    return srcSampleRate == dstSampleRate ? dstFramesRequired :
            size_t((uint64_t)dstFramesRequired * srcSampleRate / dstSampleRate + 1 + 1);
}

我们不深入分析 calculateMinFrameCount() 函数了,并不是说这个函数的流程有多复杂,而是它涉及到音频重采样的背景原理,说清楚 how 很容易,但说清楚 why 就很困难了。
目前我们只需要知道:这个函数根据硬件设备的配置信息(采样率、周期大小、传输延迟)和音轨的采样率,计算出一个最低帧数(应用程序至少设置多少个帧才能保证声音正常播放)

说点题外话,Anroid 2.2 时,AudioTrack::getMinFrameCount() 的处理很简单:

status_t AudioTrack::getMinFrameCount(
        int* frameCount,
        int streamType,
        uint32_t sampleRate)
{
    int afSampleRate;
    if (AudioSystem::getOutputSamplingRate(&afSampleRate, streamType) != NO_ERROR) {
        return NO_INIT;
    }
    int afFrameCount;
    if (AudioSystem::getOutputFrameCount(&afFrameCount, streamType) != NO_ERROR) {
        return NO_INIT;
    }
    uint32_t afLatency;
    if (AudioSystem::getOutputLatency(&afLatency, streamType) != NO_ERROR) {
        return NO_INIT;
    }

    // Ensure that buffer depth covers at least audio hardware latency
    uint32_t minBufCount = afLatency / ((1000 * afFrameCount) / afSampleRate);
    if (minBufCount < 2) minBufCount = 2;

    *frameCount = (sampleRate == 0) ? afFrameCount * minBufCount :
              afFrameCount * minBufCount * sampleRate / afSampleRate;
    return NO_ERROR;
}

从这段来看,最低帧数也是基于重采样来计算的,只不过这里的处理很粗糙:
afFrameCount 是硬件设备处理单个数据块的帧数,afSampleRate 是硬件设备配置的采样率,sampleRate 是音轨的采样率,
如果要把音轨数据重采样到 afSampleRate 上,那么反推算出应用程序最少传入的帧数为 afFrameCount * sampleRate / afSampleRate,
而为了播放流畅,实际上还要大一点,所以再乘以一个系数(可参照 framebuffer 双缓冲,一个缓冲缓存当前的图像,一个缓冲准备下一幅的图像,这样图像切换更流畅),然后就得出一个可以保证播放流畅的最低帧数 minFrameCount = (afFrameCount * sampleRate / afSampleRate) * minBufCount。

为什么 Android 7.0 这方面的处理比 Android 2.2 复杂那么多呢?我想是两个原因:

Android 7.0 充分考虑了边界处理
Android 2.2 只支持采样率 4~48 KHz 的音轨,但 Android 7.0 支持采样率 4~192 KHz 的音轨,因此现在对重采样处理提出更严格的要求

本文学习自: https://blog.csdn.net/zyuanyun/article/details/60890534

  • 8
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值