Android_P_Audio_系统(2) — AudioTrack

1 AudioTrack 用例介绍

AudioTrack 用于 Android 平台音频数据输出,属于 Audio 系统对外提供的 API 类,因此它在 Java 与 Native 层均有对应的源码实现。先从 Java 层的一个用例了解下 AudioTrack 工作流程。

// 1 根据音频数据特征确定所要分配的缓存区的最小 size
int bufsize = getMinBufferSize
        AudioTrack.getMinBufferSize(8000, // 采样率:每秒 8000 个采样点
        AudioFormat.CHANNEL_OUT_STEREO, // 声道数:双声道
        AudioFormat.ENCODING_PCM_16BIT // 采样精度:一个采样点 16 比特,相当于  2个字节
);

// 2 创建 AudioTrack
AudioTrack track = new AudioTrack(  
                AudioManager.STREAM_MUSIC, // streamType 指定流的类型
                8000, AudioFormat.CHANNEL_OUT_STEREO, 
                AudioFormat.ENCODING_PCM_16BIT, // audioFormat: 采样精度
                bufsize,  // bufferSizeInBytes:内部音频数据缓存区的大小
                AudioTrack.MODE_STREAM // mode:数据加载模式);

// 3 开始播放
track.play();

while (true) {
    // 4 调用 write 开始往缓存区中写数据
    track.write(bytes_pkg, 0, bytes_pkg.length);
    if(stream_end) break;
}

// 5 停止播放和释放资源
track.stop();
track.release();

上面的用例中引入了两个新的概念,一个数据加载模式,另一个是音频流类型。下面进行详细介绍:

1. AudioTrack 的数据加载模式

AudioTrack 有两种数据加载模式:MODE_STREAM 和 MODE_STREAM,他们对应两种完全不同的使用场景。

  • MODE_STREAM:在这种模式下,通过 write 一次次将音频数据写到 AudioTrack 中,这和平常通过 write 系统调用往文件中写数据类似,但这种工作方式每次都需要把数据用用户提供的 Buffer 中拷贝到 AudioTrack 内部的缓存区 Buffer 中,这在一定程序上会引起延迟。为了解决这一问题,AudioTrack 就引入了第二种模式。
  • MODE_STATIC:这种模式下,在 play 之前只需要把所有的数据一次 write 到 AudioTrack 的内部缓存区中,后续就不必再传递数据。这种模式适用于像铃声这种内存占用量较小,延时要求较高的文件。

这两种模式中 MODE_STREAM 模式更为常见也并复杂,后续分析将以它为主。

2. 音频流的类型

在 AudioTrack 的构造函数中,会接触到 AudioManager.STREAM_MUSIC 这个参数。它的含义与 Android 系统对音频流的管理和分类有关。

Android 将系统声音分为好几种流类型,以下是几种常见的:

  • STREAM_ALARM:警告声
  • STREAM_MUSIC:音乐声
  • STREAM_RING:铃声
  • STREAM_SYSTEM:系统声音,如锁屏音、低电提示音
  • STREAM_VOICE_CALL:通话声

上面这些类型的划分与音频数据本身并没有关系,例如 MUSIC 和 RING 类型都可以是某首 MP3 歌曲。

音频流类型的划分和 Audio 系统对音频的管理策略有关,因此其真正使用在 AudioPolicyService 中。在目前的用例分析中,暂时将它当作一个普通数值即可。

3. 缓存区 Buffer 分配和 Frame 的概念

在用例中碰到的第一个重要函数就是 getMinBufferSize,这个函数对于确定应用层分配多大的缓存区数据 Buffer 具有重要的知道意义。先回顾一下它的调用方式:

int bufsize = 
        AudioTrack.getMinBufferSize(8000, // 采样率:每秒 8000 个采样点
        AudioFormat.CHANNEL_CONFIGURATION_STEREO, // 声道数:双声道
        AudioFormat.ENCODING_PCM_16BIT // 采样精度:一个采样点 16 比特,相当于  2个字节
);

来看这个函数的实现:

-> AudioTrack.java

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:
        // STEREO 双声道 
        channelCount = 2;
        break;
    default: 
        ...
        return ERROR_BAD_VALUE;
    }

    // 检测 AudioFormat 精度是否合理,常见 ENCODING_PCM_8BIT、ENCODING_PCM_16BIT
    if (!AudioFormat.isPublicEncoding(audioFormat)) {
        loge("getMinBufferSize(): Invalid audio format.");
        return ERROR_BAD_VALUE;
    }

    // 采样率要求,4000Hz ~ 192000Hz,太低太高都不支持
    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;
    }

    // 调用 Nntive 函数,因此需要确认硬件是否支持这些参数,因此必须进入 Native 层查询
    int size = native_get_min_buff_size(sampleRateInHz, channelCount, audioFormat);
    if (size <= 0) {
        loge("getMinBufferSize(): error querying hardware");
        return ERROR;
    }
    else {
        return size;
    }
}

Native 函数将查询 Audio 系统中音频输出硬件 HAL 对象的一些信息,并确认它们是否支持这些采样率和采样精度。来看 Native 层的 native_get_min_buff_size 函数实现,在 android_media_track.cpp 中。

-> android_media_track.cpp

/* 我们传入的参数:
   sampleRateInHz = 8000, channelCount = 2
   audioFormat = AudioFramat.ENCODING_PCM_16BIT */
static jint android_media_AudioTrack_get_min_buff_size(JNIEnv *env,  jobject thiz,
    jint sampleRateInHertz, jint channelCount, jint audioFormat) {

    size_t frameCount;
    //  1. 获取内部缓存区最小 Frame 个数。 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)) {
        // 2. 根据最小的 FrameCount 计算缓存 buffer 大小
        // 所需缓存区 buffer 大小 = FrameCount x 每个采样点的字节数 x 声道数 
        const size_t bytesPerSample = audio_bytes_per_sample(format);
        return frameCount * channelCount * bytesPerSample;
    } else {
        return frameCount;
    }
}

Frame 引入

上面的代码中出现了音频系统中的一个重要概念: Frame(帧)。Frame 是一个单位,用来直观的描述数据量的多少,一单位的 Frame 等于一个采样点的字节数 x 声道数,例如 PCM16,双声道的 1 个 Frame = 2 x 2 = 4 字节。

我们知道,一个采样点只针对一个声道,而实际上可能会有一个或多个声道。为了用一个独立的单位来表示全部声道一次采样的数据量,因此引入了 Frame 的概念。Frame 的大小也就是采样点的字节数 x 声道数,另外在目前的声卡驱动中,其内部的缓存区也是采用 Frame 作为单位来分配和管理的。

可以看到首先从 AudioTrack.cpp 中查询所需的最小 Frame 个数(AudioTrack::getMinFrameCount),然后再根据 Frame 的计算公式就能计算缓存区 buffer 的大小。下面来看下 AudioTrack 中是如何获取所需的最小 Frame 个数。

开始看代码前我们先了解下传输延迟(latency)的概念,Linux ALSA 把数据缓冲区划分为若干个块,dma 每传输完一个块上的数据即发出一个硬件中断,cpu 收到中断信号后,再配置 dma 去传输下一个块上的数据。一个块即是一个周期,周期大小(periodSize)即是一个数据块的帧数。再回到传输延迟(latency),传输延迟等于周期大小除以采样率,即

latency = periodSize / sampleRate。

-> AudioTrack.cpp

status_t AudioTrack::getMinFrameCount(
        size_t* frameCount,
        audio_stream_type_t streamType,
        uint32_t sampleRate)
{
    uint32_t afSampleRate;
    status_t status;
    
    // 下面的调用涉及到了 AudioSystem,这个与 AudioPolicy 相关。这里仅把他看成系统查询即可
    
    /*
		查询硬件输出采样率,一般返回所支持的最高采样率,如 44100。
		音频设备一般都是工作在一个固定的采样率上,因此所有音轨数据都需要重采样到这个固定的采样率上,
		然后再输出,才能保证所有音轨听起来都不失真。
	 */
    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;
    }
    size_t afFrameCount;
    // 查询硬件内部缓冲的大小,以 Frame 为单位
    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;
    }
    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;
    }
    
    // Ensure that buffer depth covers at least audio hardware latency
    // minBufCount 表示缓冲区的最少个数
    uint32_t minBufCount = afLatency / ((1000 * afFrameCount) / afSampleRate);
    if (minBufCount < 2) {
        minBufCount = 2; // 至少要两个缓冲
    }
    
    // 计算最小帧个数
    uint32_t minFrameCount = 
                (afFrameCount*sampleRate*minBufCount)/afSampleRate;
    
    *frameCount = minFrameCount
    ...
}

获取所需最小帧的个数之后,根据 Frame 帧大小计算公式 minFrameCount * 采样点字节数 * 声道数即计算得到一个最小缓存区的大小。

getMinBufSize 综合考虑硬件的的情况(包括是否支持采样率,硬件本身的延迟情况等)后,得到一个最小缓存区的大小,一般我们分配的缓冲大小是它的整数倍。

一些基本的概念介绍完之后,就可以开始分析 AudioTrack 了。

2 AudioTrack Java 空间分析

2.1 AudioTrack 构造函数

回顾一下用例中 AudioTrack 的调用代码:

AudioTrack track = new AudioTrack(  
                AudioManager.STREAM_MUSIC, // streamType 指定流的类型
                8000, AudioFormat.CHANNEL_OUT_STEREO,  // 采样率、声道
                AudioFormat.ENCODING_PCM_16BIT, // audioFormat: 采样精度
                bufsize,  // bufferSizeInBytes:内部音频数据缓存区的大小
                AudioTrack.MODE_STREAM // mode:数据加载模式);

AudioTrack 的构造函数在 AudioTrack.java 中,一起来看看,构造调用顺序从上往下。

-> AudioTrack.java

public AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat,
        int bufferSizeInBytes, int mode)
throws IllegalArgumentException {
    this(streamType, sampleRateInHz, channelConfig, audioFormat,
            bufferSizeInBytes, mode, AudioManager.AUDIO_SESSION_ID_GENERATE);
}

public AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat,
        int bufferSizeInBytes, int mode, int sessionId)
throws IllegalArgumentException {
    // mState already == STATE_UNINITIALIZED
    
    // 参数管理类:AudioAttributes(streamType)、AudioFormat(channel、audioFormat、sampleRate)
    this((new AudioAttributes.Builder())
                .setLegacyStreamType(streamType)
                .build(),
            (new AudioFormat.Builder())
                .setChannelMask(channelConfig)
                .setEncoding(audioFormat)
                .setSampleRate(sampleRateInHz)
                .build(),
            bufferSizeInBytes,
            mode, sessionId);
    // AudioTrack 构造 streamType 处理已被废弃
    deprecateStreamTypeForPlayback(streamType, "AudioTrack", "AudioTrack()");
}

public AudioTrack(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes,
        int mode, int sessionId)
                throws IllegalArgumentException {
    this(attributes, format, bufferSizeInBytes, mode, sessionId, false /*offload*/);
}

private AudioTrack(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes,
        int mode, int sessionId, boolean offload)
                throws IllegalArgumentException {
    ... 
    /*
        省略前面根据 AudioAttributes、AudioFormat 参数管理类获取值
        1. rate = sampleRate -> 采样率
        2. channelMask = channelConfig -> 声道配置
        3. channelIndexMask -> 声道配置,一般为 0 不用
        4. encoding -> audioFormat -> 采样精度
        5. mode -> 数据加载模式,MODE_STREAM 或 MODE_STATIC
    */
    
    // 检查参数是否合法
    audioParamCheck(rate, channelMask, channelIndexMask, encoding, mode);
    // 废弃通过参数传入流类型 stramType,这里设置为默认
    mStreamType = AudioSystem.STREAM_DEFAULT;

    // bufferSizeInBytes 通过 getMinBufferSize 得到,所以肯定能通过下面的检测
    audioBuffSizeCheck(bufferSizeInBytes);

    // android looper
    mInitializationLooper = looper;

    if (sessionId < 0) {
        throw new IllegalArgumentException("Invalid audio session ID: "+sessionId);
    }

    int[] sampleRate = new int[] {mSampleRate};
    int[] session = new int[1];
    session[0] = sessionId;
    /*
        1. 调用 native 层的 native_setup,构造一个 WeakReference 弱引用传递
    */
    int initResult = native_setup(new WeakReference<AudioTrack>(this), mAttributes,
            sampleRate, mChannelMask, mChannelIndexMask, mAudioFormat,
            mNativeBufferSizeInBytes, mDataLoadMode, session, 0 /*nativeTrackInJavaObj*/,
            offload);
    if (initResult != SUCCESS) {
        loge("Error code "+initResult+" when initializing AudioTrack.");
        return; // with mState == STATE_UNINITIALIZED
    }

    ...
}

OK,native_setup 对应的 JNI 层函数是 android_media_AudioTrack_setup,一起来看看。

-> android_media_AudioTrack.cpp

static jint
android_media_AudioTrack_setup(JNIEnv *env, jobject thiz, jobject weak_this, jobject jaa,
        jintArray jSampleRate, jint channelPositionMask, jint channelIndexMask,
        jint audioFormat, jint buffSizeInBytes, jint memoryMode, jintArray jSession,
        jlong nativeAudioTrack, jboolean offload) {
    ...

    // Java 层的值与 JNI 层的值转换,获取采样率
    int* sampleRates = env->GetIntArrayElements(jSampleRate, NULL);
    int sampleRateInHertz = sampleRates[0];
    env->ReleaseIntArrayElements(jSampleRate, sampleRates, JNI_ABORT);

    // Java 层的值与 JNI 层的值转换,获取声道数
    audio_channel_mask_t nativeChannelMask = nativeChannelMaskFromJavaChannelMasks(
            channelPositionMask, channelIndexMask);

    // 以双声道为例,nativeChannelMask 的值计算过程为: 
    // - Java(0x04 | 0x08) -> Native(Java_Value >> 2 = 0x3)
    // 
    // audio_channel_count_from_out_mask 函数调用 popCount 统计一个整数位中有多少个 1
    // 故 0x3 计算值为 2,表示声道总数为 2
    uint32_t channelCount = audio_channel_count_from_out_mask(nativeChannelMask);

    // Java 层的值与 JNI 层的值转换,获取采样精度
    audio_format_t format = audioFormatToNative(audioFormat);

    // 整个计算过程以帧为计算单位
    size_t frameCount;
    if (audio_has_proportional_frames(format)) {
        // 帧总数 = 字节总数 / 每帧字节数(声道数 x 每个采样点字节数)
        const size_t bytesPerSample = audio_bytes_per_sample(format);
        frameCount = buffSizeInBytes / (channelCount * bytesPerSample);
    } else {
        frameCount = buffSizeInBytes;   
    }

        // create the native AudioTrack object
        lpTrack = new AudioTrack();

    // 1. AudioTrackJniStorage 对象保存了一些信息,后面将详细分析
    AudioTrackJniStorage *lpJniStorage = new AudioTrackJniStorage();
    // AudioTrackJniStorage 保存 Java AudioTrack 类对象和弱引用
    lpJniStorage->mCallbackData.audioTrack_class = (jclass)env->NewGlobalRef(clazz);
    lpJniStorage->mCallbackData.audioTrack_ref = env->NewGlobalRef(weak_this);
    lpJniStorage->mCallbackData.isOffload = offload;
    lpJniStorage->mCallbackData.busy = false;

    ...     
    
    // 2. 创建 Native 层的 AudioTrack 对象
    sp<AudioTrack> lpTrack = new AudioTrack();
    status_t status = NO_ERROR;
    switch (memoryMode) {
    case MODE_STREAM:
        // 3. STREAM 模式
        status = lpTrack->set(
                AUDIO_STREAM_DEFAULT,// 流类型, 更多流信息保存在最后一个参数 paa 中
                sampleRateInHertz, // 采样率
                format,// 采样精度
                nativeChannelMask, // 声道配置
                frameCount,
                AUDIO_OUTPUT_FLAG_NONE, // flag
                // //callback, callback data (user)
                audioCallback, &(lpJniStorage->mCallbackData),
                // notificationFrames,值为 0 表示不再使用 EVENT_MORE_DATA 
                // 喂数据,只使用 write 写数据,后文会说明
                0,
                0,// 共享内存,STREAM 模式下为 0,实际使用的共享内存由 AudioFlinger 创建
                true,// thread can call Java
                sessionId,// session ID,默认为 0 表示由 AudioFlinger 生成
                AudioTrack::TRANSFER_SYNC,
                // offload 信息,offload 模式下解码将由音频 dsp 完成,
                // 因此需要 offloadInfo 信息额外传入采样率、采样精度等信息
                offload ? &offloadInfo : NULL,
                -1, -1,                       // default uid, pid values
                // paa audio_attributes_t 类型,保存流 来源-source、用途-usage、tag 
                // 等信息,获取已省略
                paa); 
        break;

    case MODE_STATIC:
        // STATIC 模式,由 AudioTrack 端创建共享内存
        if (!lpJniStorage->allocSharedMem(buffSizeInBytes)) {
            ALOGE("Error creating AudioTrack in static mode: error creating mem heap base");
            goto native_init_failure;
        }

        status = lpTrack->set(
                // stream type, but more info conveyed in paa (last argument)
                AUDIO_STREAM_DEFAULT,
                sampleRateInHertz,
                format,// word length, PCM
                nativeChannelMask,
                frameCount,
                AUDIO_OUTPUT_FLAG_NONE,
                // callback, callback data (user));
                audioCallback, &(lpJniStorage->mCallbackData),
                // notificationFrames == 0 since not using EVENT_MORE_DATA to
                //feed the AudioTrack
                0,
                lpJniStorage->mMemBase,// static 模式下,需要传递该共享内存
                true,// thread can call Java
                sessionId,// audio session ID
                AudioTrack::TRANSFER_SHARED,
                NULL,                         // default offloadInfo
                -1, -1,                       // default uid, pid values
                paa);
        break;
    }

    ...

    /* 
        保存我们刚创建的 C++ AudioTrack 对象到 Java 类成员变量 "nativeTrackInJavaObj" 中
        这样就把 JNI 层的 AudioTrack 对象和 Java 层的 AudioTrack 
        对象联系起来了,这是 Android 的常用技法。
        调用方法为:
    env->SetLongField(thiz, javaAudioTrackFields.nativeTrackInJavaObj, (jlong)lpTrack.get());
    */
    setAudioTrack(env, thiz, lpTrack);

    // 同样的将 lpJniStorage 指针也保存到 Java 对象中
    // 后续我们可以依靠这个指针来 free 释放该对象
    env->SetLongField(thiz, javaAudioTrackFields.jniData, (jlong)lpJniStorage);

    /*
        Java AudioTrack 指定 streamType 参数的方式现在已经被废弃了,在 C++ AudioTrack 对象
        创建过程中会根据传入的 audio_attributes_t(主要根据 usage 用途) 指定一个 stream type。
        因此将这个 streamType 也保存到 Java 对象中
    */
    env->SetIntField(thiz, javaAudioTrackFields.fieldStreamType, (jint) lpTrack->streamType());

    return (jint) AUDIO_JAVA_SUCCESS;

上面的代码中,列出了三个要点(标注 1 ~ 3),这一节仅分析 AudioTrackJniStorage 这个类,其余的在 Native AduioTrack 部分分析。

2.2 AudioTrackJniStorage 分析

AudioTrackJniStorage 是一个辅助类,其中有一些关于共享内存的较重要的知识需要先简单的介绍一下。

(1) 共享内存介绍

共享内存,作为进程间数据传递的一种方式,在 AudioTrack 和 AudioFlinger 中被大量使用。先简单了解一下有关共享内存的知识:

  • 一个 32 位系统,每个进程的内存空间是 4 GB,这个 4 GB 是由指针长度决定的,如果指针长度为 32 位,那么地址的最大编号 0xFFFFFFF,为 4 GB
  • 上面说的内存空间实际上是进程的虚拟地址空间,换言之,在应用程序中使用的指针其实是指向虚拟空间地址的。那么,如果通过这个虚拟地址找到存储在真实物理内存中的数据呢?

这个问题就引入了内存映射的概念。内存映射让虚拟空间的内存地址和真实物理内存地址之间建立起一种对应关系。也就是说,进程中操作的 0x12345678 这块内存地址,在经过 OS 内存管理机制的转换后,它实际上对应的物理地址可能是 0x87654321。当然,这一切对进程来说都是透明的,这些活操作系统悄悄的完成了。

我们用到的内存共享就和内存映射息息相关,来看下图:

image

上图中,真实内存中的 0x87654321 标识的这块内存页(OS 内存管理机制将物理内存分成了一个个的内存页,一块内存页一般 4 KB)现在已经映射到了进程 A。可它能同时映射到进程 B 吗?如果能,在进程 A 中对这块内存页所写的数据在进程 B 就能看到了,这岂不就成了内存在两种进程间共享吗?

事实的确如此,这个机制由操作系统提供实现,原理简单,实现却很复杂,这里就不深究了。

如果创建共享内存呢?Linux 平台的一般做法是:

  1. 进程 A 创建并打开一个文件,得到一个文件描述符 fd
  2. 通过 mmap 调用将 fd 映射成内存映射文件
  3. 进程 B 打开同一个文件,也得到一个文件描述符,这样 A 和 B 打开了同一个文件
  4. 进程 B 也调用 mmap 并指定参数表示想使用共享内存,这样 A 和 B 就通过打开同一个文件并构造内存映射,实现了进程间的内存共享。
(2) MemoryHeapBase 和 MemeryBase 介绍
case MODE_STATIC:
    // AudioTrack allocSharedMem 分配共享内存
    if (!lpJniStorage->allocSharedMem(buffSizeInBytes)) {
        ALOGE("Error creating AudioTrack in static mode: error creating mem heap base");
        goto native_init_failure;
    }

AudioTrackJniStorage 用到了 Android 对共享内存机制所设置的封装类,所以我们有必要先来看看 AudioTrackJniStorage 的内容。

-> android_media_AudioTrack.cpp

class AudioTrackJniStorage {
    public:
        // 这两个 Memory 很重要!
        sp<MemoryHeapBase>         mMemHeap;
        sp<MemoryBase>             mMemBase;
        // 下面的结构保存一些变量,没什么特别作用
        audiotrack_callback_cookie mCallbackData;
        sp<JNIDeviceCallback>      mDeviceCallback;

    AudioTrackJniStorage() {
        mCallbackData.audioTrack_class = 0;
        mCallbackData.audioTrack_ref = 0;
        mCallbackData.isOffload = false;
    }

    ~AudioTrackJniStorage() {
        mMemBase.clear();
        mMemHeap.clear();
    }

    bool allocSharedMem(int sizeInBytes) {
        /*
            注意关于 MemoryHeapBase & MemoryHeapBase 的用法。
            先 new 一个 MemoryHeapBase,再以它为参数 new 一个 mMemBase
        */
        mMemHeap = new MemoryHeapBase(sizeInBytes, 0, "AudioTrack Heap Base");
        mMemBase = new MemoryHeapBase(mMemHeap, 0, sizeInBytes);
        return true;
    }
};

MemeryHeapBase 是一个基于 Binder 通信的类,其中 BpMemeryHeapBase 由客户端使用,而 MemeryHeapBase 完成 BnMemeryHeapBase 的业务工作。

从 MemeryHeapBase 开始分析,它的构造方法:

-> frameworks/native/libs/binder/MemoryHeapBase

MemoryHeapBase::MemoryHeapBase(size_t size, uint32_t flags, char const * name)
    : mFD(-1), mSize(0), mBase(MAP_FAILED), mFlags(flags),
      mDevice(0), mNeedUnmap(false), mOffset(0)
{
    const size_t pagesize = getpagesize(); // 获取系统中内存页大小,一般为 4 KB
    size = ((size + pagesize-1) & ~(pagesize-1)); // 申请大小 4 字节对齐
    
    /*
        创建共享内存,ashmem_create_region 由 libcutils 提供。
        在真实设备上讲打开 /dev/ashmem 设备得到一个文件描述符,
    */
    int fd = ashmem_create_region(name == NULL ? "MemoryHeapBase" : name, size);
    // mapfd 函数将通过 mmap 方式得到内存地址。
    mapfd(fd, size);
}

MemoryHeapBase 构造完成之后,将得到以下结果:

  • mBase - 变量指向共享内存的起始位置
  • mSize - 所要求分配的内存大小
  • mFD - ashmem_create_region 返回的文件描述符

另外,MemoryHeapBase 提供了以下几个函数,可以获取共享内存的大小和位置。

  • int MemoryHeapBase::getHeapID() - 获取 mFD,如果为负数,表示创建失败
  • void* MemoryHeapBase::getBase() - 共享内存起始地址,mBase
  • size_t MemoryHeapBase::getSize() - 返回 mSize,申请共享内存大小

MemoryHeapBase 比较简单,通过 ashmem_create_region 得到了一个共享内存文件描述符。而 MemoryBase 也是一个基于 Binder 通信的类,它比起 MemoryHeapBase 来就显得更加简单了,看起来更像是一个辅助类。它的声明在 MemoryBase.h 中。

-> frameworks/native/libs/binder/MemoryBase.h

class MemoryBase : public BnMemory 
{
public:
    // 构造函数
    MemoryBase::MemoryBase(const sp<IMemoryHeap>& heap,
        ssize_t offset, size_t size)
    : mSize(size), mOffset(offset), mHeap(heap)
    {
    }
    virtual ~MemoryBase();
    virtual sp<IMemoryHeap> getMemory(ssize_t* offset, size_t* size) const;

protected:
    size_t getSize() const { return mSize; } // 返回所分配共享内存大小
    ssize_t getOffset() const { return mOffset; } // 返回当前共享内存偏移量
    const sp<IMemoryHeap>& getHeap() const { return mHeap; } // 返回 MemoryHeapBase 对象

private:
    size_t          mSize;
    ssize_t         mOffset;
    sp<IMemoryHeap> mHeap;
};

MemoryHeapBase & MemoryBase 总结起来就是:

  1. 分配了一块共享内存,这样两个进程可以共享这块内存。
  2. 基于 Binder 通信,这样使用这两个类的进程就可以交互了。

2.3 play、write 分析

第一小节的用例中,我们这样写数据:

// 3 开始播放
track.play();

while (true) {
    // 4 调用 write 开始往缓存区中写数据
    track.write(bytes_pkg, 0, bytes_pkg.length);
    if(stream_end) break;
}

现在来分析下这两个函数,直接转向 JNI 层。

1. play 分析
-> android_media_AudioTrack.cpp

static void
android_media_AudioTrack_start(JNIEnv *env, jobject thiz)
{
    // getAudioTrack 函数获取之前 setup 时保存在 Java 变量中的 Native 层 AudioTrack 对象
    // env->GetLongField(thiz, javaAudioTrackFields.nativeTrackInJavaObj);
    sp<AudioTrack> lpTrack = getAudioTrack(env, thiz);
    if (lpTrack == NULL) {
        jniThrowException(env, "java/lang/IllegalStateException",
            "Unable to retrieve AudioTrack pointer for start()");
        return;
    }

    // 调用 C++ AudioTrack 的 start 函数
    lpTrack->start();
}
2. write 分析

Java 层的 write 函数主要写三种类型数据:

  1. write(byte[] audioData, int offsetInBytes, int sizeInBytes) - 写 byte 数据
  2. write(short[] audioData, int offsetInShorts, int sizeInShorts) - 写 short 数据
  3. write(float[] audioData, int offsetInFloats, int sizeInFloats) - 写 float 数据

不管是数据输入格式是 byte、short 还是 float,实际最后处理送往 audio HAL 的都是 uint8_t 即单字节类型。

Java 层 write 函数通过 native_write_byte、native_write_short、native_write_float 最后都调用了 android_media_AudioTrack 中的 android_media_AudioTrack 函数。

-> android_media_AudioTrack.cpp

// 通过模板 template 兼容三种类型处理
template <typename T>
static jint writeToTrack(const sp<AudioTrack>& track, jint audioFormat, const T *data,
                         jint offsetInSamples, jint sizeInSamples, bool blocking) {
    // give the data to the native AudioTrack object (the data starts at the offset)
    ssize_t written = 0;
    size_t sizeInBytes = sizeInSamples * sizeof(T);
    if (track->sharedBuffer() == 0) {
        // sharedBuffer 即 setup 初始时创建的共享内存,如果是 STREAM 模式,共享内存为 NULL
        // 实际交给 C++ AudioTrack write 函数处理
        written = track->write(data + offsetInSamples, sizeInBytes, blocking);
        ...
    } else {
        // writing to shared memory, check for capacity
        if ((size_t)sizeInBytes > track->sharedBuffer()->size()) {
            sizeInBytes = track->sharedBuffer()->size();
        }
        // STATIC 模式下,直接把数据 memcpy 到共享内存,记住在这种模式下要先调用
        // write 后调用 play。
        memcpy(track->sharedBuffer()->pointer(), data + offsetInSamples, sizeInBytes);
        written = sizeInBytes;
    }
    if (written >= 0) {
        return written / sizeof(T);
    }
    return interpretWriteSizeError(written);
}

看上去 play 和 write 两个函数还是比较简单的,大部分的工作都是交给 Native 的 AudioTrack 去处理。

2.4 release 分析

如果数据都写完了,则需要调用 stop 停止播放,或者直接调用 release 来释放相关资源。(Java release 函数会先调用 stop 停止播放)

stop 函数只调用 C++ AudioTrack 的 stop 接口,而 release 函数主要是释放保存在 Java 中的 C++ 对象以及保存在 C++ 中的 Java 引用(pJniStorage)。

-> android_media_AudioTrack.cpp

static void
android_media_AudioTrack_stop(JNIEnv *env, jobject thiz)
{
    sp<AudioTrack> lpTrack = getAudioTrack(env, thiz);
    lpTrack->stop();
}

#define CALLBACK_COND_WAIT_TIMEOUT_MS 1000
static void android_media_AudioTrack_release(JNIEnv *env,  jobject thiz) {
    // 将之前保存在 Java 对象中的 C++ AudioTrack 对象置为 null
    // env->SetLongField(thiz, javaAudioTrackFields.nativeTrackInJavaObj, 0);
    sp<AudioTrack> lpTrack = setAudioTrack(env, thiz, 0);
    if (lpTrack == NULL) {
        return;
    }
    //ALOGV("deleting lpTrack: %x\n", (int)lpTrack);

    AudioTrackJniStorage* pJniStorage = (AudioTrackJniStorage *)env->GetLongField(
        thiz, javaAudioTrackFields.jniData);
    // 将之前保存在 Java 对象中的 C++ pJniStorage 对象置为 null
    env->SetLongField(thiz, javaAudioTrackFields.jniData, 0);

    // 删除 pJniStorage 存储的数据
    if (pJniStorage) {
        Mutex::Autolock l(sLock);
        audiotrack_callback_cookie *lpCookie = &pJniStorage->mCallbackData;
        //ALOGV("deleting pJniStorage: %x\n", (int)pJniStorage);
        while (lpCookie->busy) {
            if (lpCookie->cond.waitRelative(sLock,
                                            milliseconds(CALLBACK_COND_WAIT_TIMEOUT_MS)) !=
                                                    NO_ERROR) {
                break;
            }
        }
        sAudioTrackCallBackCookies.remove(lpCookie);
        // 删除之前 native_setup 函数中保存在 pJniStorage 中的 Java 引用
        env->DeleteGlobalRef(lpCookie->audioTrack_class);
        env->DeleteGlobalRef(lpCookie->audioTrack_ref);
        delete pJniStorage;
    }
}

至此,Java 层扫尾分析工作就结束了,我们来总结下 Java JNI 空间使用 C++ AudioTrack 的流程:

  1. new 一个 AudioTrack,使用无参构造函数
  2. 调用 set 函数,把 Java 参数传进去,另外还设置了一个 audiocallback 函数
  3. 调用 AudioTrack 的 start 函数
  4. 调用 AudioTrack 的 write 函数
  5. 工作完毕后,调用 stop

有了这些流程的认识,我们接下来进行 AudioTrack Native 空间分析

3 AudioTrack Native 空间分析

3.1 new AudioTrack 和 set 分析

-> AudioTrack.cpp

AudioTrack::AudioTrack()
    : mStatus(NO_INIT),
      mState(STATE_STOPPED),
      mPreviousPriority(ANDROID_PRIORITY_NORMAL),
      mPreviousSchedulingGroup(SP_DEFAULT),
      mPausedPosition(0),
      mSelectedDeviceId(AUDIO_PORT_HANDLE_NONE),
      mRoutedDeviceId(AUDIO_PORT_HANDLE_NONE)
{
    // Java 传递的 audio 参数(streamType)
    mAttributes.content_type = AUDIO_CONTENT_TYPE_UNKNOWN;
    mAttributes.usage = AUDIO_USAGE_UNKNOWN;
    mAttributes.flags = 0x0;
    strcpy(mAttributes.tags, "");
}

new 构造函数中,只有对一些控制状态值的初始化,如 mStatus 设置为 NO_INIT 等,再看看 set 调用。

-> AudioTrack.cpp

status_t AudioTrack::set(
        audio_stream_type_t streamType,     // STREAM_DEFAULT,流类型
        uint32_t sampleRate,                // 音频采样率
        audio_format_t format,              // 音频采样精度
        audio_channel_mask_t channelMask,   // 声道配置
        size_t frameCount,                  // 缓存区总帧数,计算获取
        audio_output_flags_t flags,         // flag
        callback_t cbf,                     // audioCallBack,回调函数
        void* user,                         // callback data (user)
        // // 0 表示不使用 EVENT_MORE_DATA 喂数据,只使用 write 写数据
        int32_t notificationFrames,         
        const sp<IMemory>& sharedBuffer,    // 共享内存,STREAM 模式下为 0
        bool threadCanCallJava,             // thread can call Java
        audio_session_t sessionId,          // 默认为 0,表示由 AudioFlinger 生成
        transfer_type transferType,
        // offload 信息,offload 模式下解码将由音频 dsp 完成,因此需要 
        // offloadInfo 信息额外传入采样率、采样精度等信息
        const audio_offload_info_t *offloadInfo,
        uid_t uid,
        pid_t pid,
        // paa audio_attributes_t 类型,保存流 来源-source、用途-usage、tag 等信息,获取已省略
        const audio_attributes_t* pAttributes,
        bool doNotReconnect,
        float maxRequiredSpeed,
        audio_port_handle_t selectedDeviceId)
{
    ...
    // 省略一些状态值初始化过程
    
    // MODE_STREAM 为 TRANSFER_SYNC 同步传输模式,MODE_STATIC 为 SHARED 共享传输模式 
    mTransfer = transferType; 
    
    // streamType 流类型默认值为 STREAM_MUSIC
    if (streamType == AUDIO_STREAM_DEFAULT) {
        streamType = AUDIO_STREAM_MUSIC;
    }
    
    ...
    // cbf 为 JNI 层传入的回调函数 audioCallback,如果用户设置了回调函数,则启动一个线程
    if (cbf != NULL) {
        mAudioTrackThread = new AudioTrackThread(*this, threadCanCallJava);
        mAudioTrackThread->run("AudioTrack", ANDROID_PRIORITY_AUDIO, 0 /*stack*/);
        // thread begins in paused state, and will not reference us until start()
    }

    // 调用 createTrack
    status = createTrack_l();
    
    ...
}

再继续跟进看下 createTrack_l 函数。

-> AudioTrack.cpp

status_t AudioTrack::createTrack_l()
{
    status_t status;

    //得到 AudioFlinger 的 Binder 代理端 BpAudioFlinger
    const sp<IAudioFlinger>& audioFlinger = AudioSystem::get_audio_flinger();
    if (audioFlinger == 0) {
        ALOGE("Could not get audioflinger");
        status = NO_INIT;
        goto exit;
    }

    ...

    // 初始化 createTrack 输入输出参数 - CreateTrackInput & CreateTrackOutput
    IAudioFlinger::CreateTrackInput input;
    if (mStreamType != AUDIO_STREAM_DEFAULT) {
        stream_type_to_audio_attributes(mStreamType, &input.attr);
    } else {
        input.attr = mAttributes;
    }
    input.config = AUDIO_CONFIG_INITIALIZER;
    input.config.sample_rate = mSampleRate;
    input.config.channel_mask = mChannelMask;
    input.config.format = mFormat;
    input.config.offload_info = mOffloadInfoCopy;
    input.clientInfo.clientUid = mClientUid;
    input.clientInfo.clientPid = mClientPid;
    input.clientInfo.clientTid = -1;

    ...

    IAudioFlinger::CreateTrackOutput output;

    /*
        1. 向 AudioFlinger 发送 createTrack 请求,注意其中的 2 个参数。
           - input.sharedBuffer 在 STREAM 模式下为空
           - output.outputId 为 AudioFlinger 中工作线程的索引号(下文解释)
    */
    sp<IAudioTrack> track = audioFlinger->createTrack(input,
                                                      output,
                                                      &status);

    ...

    // 返回在 AudioFlinger createTrack 创建的实际参数
    mFrameCount = output.frameCount;
    mNotificationFramesAct = (uint32_t)output.notificationFrameCount;
    mRoutedDeviceId = output.selectedDeviceId;
    // 还记得之前 Java 空间传入了 -1 默认 id 吗?这里 createTrack 会生成 sessionId 并返回
    mSessionId = output.sessionId;

    ...

    /*
        在 STREAM 模式下,没有在 AudioTrack 端创建共享内存,但前面提到了 AudioTrack 和
        AudioFlinger 的交互式通过共享内存完成的,这块共享内存便是由 AudioFlinger
        createTrack 时创建,我们以后分析 AudioFlinger 时还会介绍
    */
    sp<IMemory> iMem = track->getCblk();
    void *iMemPointer = iMem->pointer();
    mAudioTrack = track;
    mCblkMemory = iMem;
    IPCThreadState::self()->flushCommands();

    /*
        IMemory 的 pointer 在此处返回共享内存的首地址,类型为 void*,
        static_cast 把这个 void* 类型转为 audio_track_cblk_t* 类型,这说明
        这块内存首部中包含 audio_track_cblk_t 对象
    */
    audio_track_cblk_t* cblk = static_cast<audio_track_cblk_t*>(iMemPointer);
    mCblk = cblk;

    ...

    // 保存 AudioFlinger 工作线程的索引号
    mOutput = output.outputId;

    /*
        获取共享内存的地址,我们知道 STREAM 模式下 mSharedBuffer 为 0,则从 
        AudioFlinger 中返回的共享内存变量 cblk 首部再 + 一个 sizeof(audio_track_cblk_t)
        即为共享内存的地址
    */
    void* buffers;
    if (mSharedBuffer == 0) {
        buffers = cblk + 1;
    } else {
        buffers = mSharedBuffer->pointer();
        if (buffers == NULL) {
            ALOGE("Could not get buffer pointer");
            status = NO_INIT;
            goto exit;
        }
    }

    // 2. AudioTrackClientProxy 主要实现管理 cblk 和服务端通信
    // 后续实际 obtainBuffer 与 releaseBuffer 等共享内存相关的操作都是由 AudioTrackClientProxy 完成的
    if (mSharedBuffer == 0) {
        mStaticProxy.clear();
        mProxy = new AudioTrackClientProxy(cblk, buffers, mFrameCount, mFrameSize);
    } else {
        mStaticProxy = new StaticAudioTrackClientProxy(cblk, buffers, mFrameCount, mFrameSize);
        mProxy = mStaticProxy;
    }

    ...

    mStatus = status;

    // sp<IAudioTrack> track destructor will cause releaseOutput() to be called by AudioFlinger
    return status;
}

代码在标注 1 时向 AudioFlinger 发送了 createTrack 的请求,输出参数 CreateTrackOutput::output.outputId 会类型 audio_io_handle_t(int 型),这个值主要被 AudioFlinger 使用,用来表示内部的工程线程索引号。AudioFlinger 会根据情况创建几个工作线程,当收到 createTrack 请求时,AudioFlinger 会根据流类型等参数选择一个合适的工程线程,并返回它在 AudioFlinger 中的索引号。

我们注意到 createTrack 返回值类型为 IAudioTrack,根据 Binder 通信的原理,我们知道 IAudioTrack 是代理端,AudioFlinger 持有 BnAudioTrack 服务端来处理代理端的请求,一起来看看 IAudioTrack 与 AudioTrack、AudioFlinger 的关系。

(1) IAudioTrack 与 AudioTrack、AudioFlinger 的关系

image

从图中可以看出,IAudioTrack 是 AudioTrack 和 AudioFlinger 的关键纽带。至于 BnAudioTrack 在 AudioFlinger 端做什么,在分析 AudioFlinger 时详细解释。

(2) 共享内存及其 Control Block

通过前面代码的分析,我们发现 IAudioTrack 中有一块共享内存,其头部是一个 audio_track_cblk_t (简称 CB)对象,在这个对象之后才是数据缓冲。简单来说,CB 对象就是为了数据同步。AudioTrack 和 AudioFlinger 作为典型的生产者和消费者,需要这么个机制来协调和管理二者数据生产和消费的步伐。

先简单阐述下同步的原理,具体的分析将在 AudioFlinger 中以一个实例进行分析。

image

如上图,CB 对象中维护了共享内存的读写位置,在 AudioTrack 中我们 new 了 一个 AudioTrackClientProxy 代理端,并将 CB 对象作为参数传递过去,而在 AudioFlinger 端我们将 new 一个 AudioTrackServerProxy 同样是传递并操作 CB 对象。这两个 Proxy 一个操作写数据位置一个操作读数据位置,协调配合同步管理这块共享内存。

3.2 write 输入数据

write 函数涉及 Audio 系统中最重要的问题,即数据是如何传输的,前面的 AudioTrack::set 函数中,我们为 AudioFlinger & AudioTrack 的数据传输准备了:

  1. 通过共享内存传递数据
  2. 通过一个 cblk 控制结构协调生产者和消费者的步调

首先明白了工具,再分析就做法就事半功倍了。

-> AudioTrack.cpp

ssize_t AudioTrack::write(const void* buffer, size_t userSize, bool blocking)
{
    if (mTransfer != TRANSFER_SYNC) {
        return INVALID_OPERATION;
    }

    ...
    
    size_t written = 0;
    Buffer audioBuffer; // Buffer 是一个辅助性的结构

    while (userSize >= mFrameSize) {
        // 以帧为单位
        audioBuffer.frameCount = userSize / mFrameSize;

        // 从共享内存中得到一块空闲的数据块,第二个参数为是否允许阻塞,从用户空间传递过来
        status_t err = obtainBuffer(&audioBuffer,
                blocking ? &ClientProxy::kForever : &ClientProxy::kNonBlocking);
        if (err < 0) {
            if (written > 0) {
                break;
            }
            if (err == TIMED_OUT || err == -EINTR) {
                err = WOULD_BLOCK;
            }
            return ssize_t(err);
        }

        // obtainBuffer 获取到的空闲数据块实际大小,也就是实际写的数据大小
        size_t toWrite = audioBuffer.size;
        // 地址在 audioBuffer.i8 中,数据传递通过 memcpy 完成
        memcpy(audioBuffer.i8, buffer, toWrite);
        buffer = ((const char *) buffer) + toWrite;
        userSize -= toWrite;
        written += toWrite;

        // releaseBuffer 更新写位置,同时会触发消费者
        releaseBuffer(&audioBuffer);
    }

    if (written > 0) {
        mFramesWritten += written / mFrameSize;
    }
    return written;
}

通过 write 函数,会发现数据的传递其实就是很简单的 memcpy,但消费者和生产者的协调则是通过 obtainBuffer 和 releaseBuffer 来完成的。现在来看这两个函数。

3.3 obtainBuffer & releaseBuffer

这两个函数展示了 AudioTrack 与 CB 对象的交互方式。先简单看看,然后将它们的交互流程记录下来,以后在 CB 对象的单独分析部分再做详细介绍。

-> AudioTrack.cpp

status_t AudioTrack::obtainBuffer(Buffer* audioBuffer, int32_t waitCount, size_t *nonContig)
{
    ...
    
    Proxy::Buffer buffer;
    status_t status = NO_ERROR;
    
    sp<AudioTrackClientProxy> proxy;
    sp<IMemory> iMem;
    
    // AudioTrackClientProxy 前面介绍过是封装的代理类,主要操作共享内存写位置
    proxy = mProxy;
    // mCblkMemory 即 track->getCblk() 获取,包含头部控制结构
    iMem = mCblkMemory;
    
    // 调用 AudioTrackClientProxy obtainBuffer 去获取共享内存空闲数据块
    // requested、elapsed 为时间控制单位,主要决定获取是否允许阻塞
    buffer.mFrameCount = audioBuffer->frameCount;
    status = proxy->obtainBuffer(&buffer, requested, elapsed);
    
    // 获取成功之后填充实际 buffer 大小
    audioBuffer->frameCount = buffer.mFrameCount;
    audioBuffer->size = buffer.mFrameCount * mFrameSize;
    audioBuffer->raw = buffer.mRaw;
    
    return status;  
}

可以看到实际上是调用了 AudioTrackClientProxy obtainBuffer,在 AudioFlinger 分析时再详细介绍。obtainBuffer 的功能就是从 CB 对象管理的数据缓冲中得到一块可写空间,而 releaseBuffer 则是使用完这块内存后更新写指针的位置。

-> AudioTrack.cpp

void AudioTrack::releaseBuffer(const Buffer* audioBuffer)
{
    size_t stepCount = audioBuffer->size / mFrameSize;
    
    // 共享内存更新信息,写了几帧、当前写位置等
    Proxy::Buffer buffer;
    buffer.mFrameCount = stepCount;
    buffer.mRaw = audioBuffer->raw;
    
    // 调用 AudioTrackClientProxy 的 releaseBuffer 更新写位置
    mProxy->releaseBuffer(&buffer);
}

3.4 delete AudioTrack

到这里,AudioTrack 的使命就到了倒计时阶段。看它在生命的最后做了一些什么工作。

-> AudioTrack.cpp

AudioTrack::~AudioTrack()
{
    if (mStatus == NO_ERROR) {
        // 如果之前没调用 stop,析构函数会先调用 stop
        // stop 函数比较简单,就是调用 IAudioTrack 的 stop 函数,具体在 AudioFlinger 中分析
        stop();
    }
    
    if (mAudioTrackThread != 0) {
        // 通知 mAudioTrackThread 退出
        if(mProxy != NULL)
            mProxy->interrupt();
        mAudioTrackThread->requestExit();   // see comment in AudioTrack.h
        mAudioTrackThread->requestExitAndWait();
        mAudioTrackThread.clear();
        mAudioTrackThread = NULL;
    }
    
    ...
    
    // 将残留在 IPCThreadState 的缓存区信息发送出去
    IPCThreadState::self()->flushCommands();
}

4 关于 AudioTrack 的总结

在进行分析时,对于一些难度比较大的地方暂时没做介绍,不过,在将 AudioFlinger 分析完之后,肯定不会怕它们的。

在完成对 AudioTrack 的分析之前,先把它和 AudioFlinger 交互的流程总结一下,如下图所示。这些流程是以后攻克 AudioFlinger 的重要武器。

image

  • 5
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值