Android audio 二 AudioRecord 分析上

12 篇文章 0 订阅

Android audio 一 源码路径

Android audio 二 AudioRecord 分析上

Android audio 三 AudioRecord 分析下

Android audio 四 AudioTrack 分析上

Android audio 五 AudioTrack 分析下

Android audio 六 AudioRecord AudiTrack 拾音放音例子

Android 采集音频类 AudioRecord

文件:

frameworks/base/media/java/android/media/AudioRecord.java

frameworks/base/core/jni/android_media_AudioRecord.cpp

frameworks/av/media/libmedia/AudioRecord.cpp

 

在 APP 里创建一个拾音线程,先要实例化 AudioRecord 对象,下面从实例化对象 AudioRecord 开始分析 

private AudioRecord audiorecord = new AudioRecord(......)

 

AudioRecord 源码如下, 有三个构造函数 AudioRecord :

第一个构造函数实例化 AudioRecord 对象, APP 调用。

第二个 @SystemApi 是系统 API , 这个函数中调用了 native_setup ,实例化本地 AudioRecord 对象。

    // 调用 JNI ,创建本地 audiorecord 实例
    int initResult = native_setup(new WeakReference<AudioRecord>(this),
                                  mAudioAttributes, sampleRate, mChannelMask, mChannelIndexMask,
                                  mAudioFormat, mNativeBufferSizeInBytes,
                                  session, ActivityThread.currentOpPackageName(), 0 /*nativeRecordInJavaObj*/);
// frameworks/base/media/java/android/media/AudioRecord.java
//---------------------------------------------------------
// Constructor, Finalize
//--------------------
/**
 * Class constructor.
 * Though some invalid parameters will result in an {@link IllegalArgumentException} exception,
 * other errors do not.  Thus you should call {@link #getState()} immediately after construction
 * to confirm that the object is usable.
 * @param audioSource the recording source.
 *   See {@link MediaRecorder.AudioSource} for the recording source definitions.
 * @param sampleRateInHz the sample rate expressed in Hertz. 44100Hz is currently the only
 *   rate that is guaranteed to work on all devices, but other rates such as 22050,
 *   16000, and 11025 may work on some devices.
 *   {@link AudioFormat#SAMPLE_RATE_UNSPECIFIED} means to use a route-dependent value
 *   which is usually the sample rate of the source.
 *   {@link #getSampleRate()} can be used to retrieve the actual sample rate chosen.
 * @param channelConfig describes the configuration of the audio channels.
 *   See {@link AudioFormat#CHANNEL_IN_MONO} and
 *   {@link AudioFormat#CHANNEL_IN_STEREO}.  {@link AudioFormat#CHANNEL_IN_MONO} is guaranteed
 *   to work on all devices.
 * @param audioFormat the format in which the audio data is to be returned.
 *   See {@link AudioFormat#ENCODING_PCM_8BIT}, {@link AudioFormat#ENCODING_PCM_16BIT},
 *   and {@link AudioFormat#ENCODING_PCM_FLOAT}.
 * @param bufferSizeInBytes the total size (in bytes) of the buffer where audio data is written
 *   to during the recording. New audio data can be read from this buffer in smaller chunks
 *   than this size. See {@link #getMinBufferSize(int, int, int)} to determine the minimum
 *   required buffer size for the successful creation of an AudioRecord instance. Using values
 *   smaller than getMinBufferSize() will result in an initialization failure.
 * @throws java.lang.IllegalArgumentException
 */
public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,
                   int bufferSizeInBytes)
throws IllegalArgumentException
{
    this((new AudioAttributes.Builder())
         .setInternalCapturePreset(audioSource)
         .build(),
         (new AudioFormat.Builder())
         .setChannelMask(getChannelMaskFromLegacyConfig(channelConfig,
                         true/*allow legacy configurations*/))
         .setEncoding(audioFormat)
         .setSampleRate(sampleRateInHz)
         .build(),
         bufferSizeInBytes,
         AudioManager.AUDIO_SESSION_ID_GENERATE);
}

/**
 * @hide
 * Class constructor with {@link AudioAttributes} and {@link AudioFormat}.
 * @param attributes a non-null {@link AudioAttributes} instance. Use
 *     {@link AudioAttributes.Builder#setAudioSource(int)} for configuring the audio
 *     source for this instance.
 * @param format a non-null {@link AudioFormat} instance describing the format of the data
 *     that will be recorded through this AudioRecord. See {@link AudioFormat.Builder} for
 *     configuring the audio format parameters such as encoding, channel mask and sample rate.
 * @param bufferSizeInBytes the total size (in bytes) of the buffer where audio data is written
 *   to during the recording. New audio data can be read from this buffer in smaller chunks
 *   than this size. See {@link #getMinBufferSize(int, int, int)} to determine the minimum
 *   required buffer size for the successful creation of an AudioRecord instance. Using values
 *   smaller than getMinBufferSize() will result in an initialization failure.
 * @param sessionId ID of audio session the AudioRecord must be attached to, or
 *   {@link AudioManager#AUDIO_SESSION_ID_GENERATE} if the session isn't known at construction
 *   time. See also {@link AudioManager#generateAudioSessionId()} to obtain a session ID before
 *   construction.
 * @throws IllegalArgumentException
 */
@SystemApi
public AudioRecord(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes,
                   int sessionId) throws IllegalArgumentException
{
    mRecordingState = RECORDSTATE_STOPPED;

    if(attributes == null)
    {
        throw new IllegalArgumentException("Illegal null AudioAttributes");
    }
    if(format == null)
    {
        throw new IllegalArgumentException("Illegal null AudioFormat");
    }

    // remember which looper is associated with the AudioRecord instanciation
	// 记住哪个线程与音频记录的安装相关
    if((mInitializationLooper = Looper.myLooper()) == null)
    {
        mInitializationLooper = Looper.getMainLooper();
    }

    // is this AudioRecord using REMOTE_SUBMIX at full volume?
    if(attributes.getCapturePreset() == MediaRecorder.AudioSource.REMOTE_SUBMIX)
    {
        final AudioAttributes.Builder filteredAttr = new AudioAttributes.Builder();
        final Iterator<String> tagsIter = attributes.getTags().iterator();
        while(tagsIter.hasNext())
        {
            final String tag = tagsIter.next();
            if(tag.equalsIgnoreCase(SUBMIX_FIXED_VOLUME))
            {
                mIsSubmixFullVolume = true;
                Log.v(TAG, "Will record from REMOTE_SUBMIX at full fixed volume");
            }
            else     // SUBMIX_FIXED_VOLUME: is not to be propagated to the native layers
            {
                filteredAttr.addTag(tag);
            }
        }
        filteredAttr.setInternalCapturePreset(attributes.getCapturePreset());
        mAudioAttributes = filteredAttr.build();
    }
    else
    {
        mAudioAttributes = attributes;
    }

    int rate = format.getSampleRate();
    if(rate == AudioFormat.SAMPLE_RATE_UNSPECIFIED)
    {
        rate = 0;
    }

    int encoding = AudioFormat.ENCODING_DEFAULT;
    if((format.getPropertySetMask() & AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_ENCODING) != 0)
    {
        encoding = format.getEncoding();
    }

    audioParamCheck(attributes.getCapturePreset(), rate, encoding);

    if((format.getPropertySetMask()
            & AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_CHANNEL_INDEX_MASK) != 0)
    {
        mChannelIndexMask = format.getChannelIndexMask();
        mChannelCount = format.getChannelCount();
    }
    if((format.getPropertySetMask()
            & AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_CHANNEL_MASK) != 0)
    {
        mChannelMask = getChannelMaskFromLegacyConfig(format.getChannelMask(), false);
        mChannelCount = format.getChannelCount();
    }
    else if(mChannelIndexMask == 0)
    {
        mChannelMask = getChannelMaskFromLegacyConfig(AudioFormat.CHANNEL_IN_DEFAULT, false);
        mChannelCount =  AudioFormat.channelCountFromInChannelMask(mChannelMask);
    }

    audioBuffSizeCheck(bufferSizeInBytes);

    int[] sampleRate = new int[] {mSampleRate};
    int[] session = new int[1];
    session[0] = sessionId;
    //TODO: update native initialization when information about hardware init failure
    //      due to capture device already open is available.
	// 调用 native 方法,创建 audiorecord 实例
    int initResult = native_setup(new WeakReference<AudioRecord>(this),
                                  mAudioAttributes, sampleRate, mChannelMask, mChannelIndexMask,
                                  mAudioFormat, mNativeBufferSizeInBytes,
                                  session, ActivityThread.currentOpPackageName(), 0 /*nativeRecordInJavaObj*/);
    if(initResult != SUCCESS)
    {
        loge("Error code "+initResult+" when initializing native AudioRecord object.");
        return; // with mState == STATE_UNINITIALIZED
    }

    mSampleRate = sampleRate[0];
    mSessionId = session[0];

    mState = STATE_INITIALIZED;
}

/**
 * A constructor which explicitly connects a Native (C++) AudioRecord. For use by
 * the AudioRecordRoutingProxy subclass.
 * @param nativeRecordInJavaObj A C/C++ pointer to a native AudioRecord
 * (associated with an OpenSL ES recorder). Note: the caller must ensure a correct
 * value here as no error checking is or can be done.
 */
/*package*/ AudioRecord(long nativeRecordInJavaObj)
{
    mNativeRecorderInJavaObj = 0;
    mNativeCallbackCookie = 0;
    mNativeDeviceCallback = 0;

    // other initialization...
    if(nativeRecordInJavaObj != 0)
    {
        deferred_connect(nativeRecordInJavaObj);
    }
    else
    {
        mState = STATE_UNINITIALIZED;
    }
}

 

在实例化 Audio Record 调用 native_setup 方法,进入 native 。

本地方法接口如下列表:

// frameworks/base/core/jni/android_media_AudioRecord.cpp
static const JNINativeMethod gMethods[] = {
    // name,               signature,  funcPtr
    {"native_start",         "(II)I",    (void *)android_media_AudioRecord_start},
    {"native_stop",          "()V",    (void *)android_media_AudioRecord_stop},
    {"native_setup",         "(Ljava/lang/Object;Ljava/lang/Object;[IIIII[ILjava/lang/String;J)I",
                                      (void *)android_media_AudioRecord_setup},
    {"native_finalize",      "()V",    (void *)android_media_AudioRecord_finalize},
    {"native_release",       "()V",    (void *)android_media_AudioRecord_release},
    {"native_read_in_byte_array",
                             "([BIIZ)I",
                                     (void *)android_media_AudioRecord_readInArray<jbyteArray>},
    {"native_read_in_short_array",
                             "([SIIZ)I",
                                     (void *)android_media_AudioRecord_readInArray<jshortArray>},
    {"native_read_in_float_array",
                             "([FIIZ)I",
                                     (void *)android_media_AudioRecord_readInArray<jfloatArray>},
    {"native_read_in_direct_buffer","(Ljava/lang/Object;IZ)I",
                                       (void *)android_media_AudioRecord_readInDirectBuffer},
    {"native_get_buffer_size_in_frames",
                             "()I", (void *)android_media_AudioRecord_get_buffer_size_in_frames},
    {"native_set_marker_pos","(I)I",   (void *)android_media_AudioRecord_set_marker_pos},
    {"native_get_marker_pos","()I",    (void *)android_media_AudioRecord_get_marker_pos},
    {"native_set_pos_update_period",
                             "(I)I",   (void *)android_media_AudioRecord_set_pos_update_period},
    {"native_get_pos_update_period",
                             "()I",    (void *)android_media_AudioRecord_get_pos_update_period},
    {"native_get_min_buff_size",
                             "(III)I",   (void *)android_media_AudioRecord_get_min_buff_size},
    {"native_setInputDevice", "(I)Z", (void *)android_media_AudioRecord_setInputDevice},
    {"native_getRoutedDeviceId", "()I", (void *)android_media_AudioRecord_getRoutedDeviceId},
    {"native_enableDeviceCallback", "()V", (void *)android_media_AudioRecord_enableDeviceCallback},
    {"native_disableDeviceCallback", "()V",
                                        (void *)android_media_AudioRecord_disableDeviceCallback},
    {"native_get_timestamp", "(Landroid/media/AudioTimestamp;I)I",
                                       (void *)android_media_AudioRecord_get_timestamp},
};

从 JNI 的接口声明映射,知道

native_setup  <-->  android_media_AudioRecord_setup

接下来分析 android_media_AudioRecord_setup

在 android_media_AudioRecord_setup 中先判断是否已经存在 nativeRecordInJavaObj 

如果不存在 nativeRecordInJavaObj 

      lpRecorder = new AudioRecord(String16(opPackageNameStr.c_str()));

如果存在

     lpRecorder = (AudioRecord*)nativeRecordInJavaObj;

     把 long 类型指针,转换成 AudioRecord 对象指针,然后 setAudioRecord(env, thiz, lpRecorder);

在 CPP 中一般使用  static_cast 和 reinterpret_cast 模板转换 type-id 类型。

这里使用  (AudioRecord*)  强制指针类型转换。 

// ----------------------------------------------------------------------------
static jint
android_media_AudioRecord_setup(JNIEnv *env, jobject thiz, jobject weak_this,
                                jobject jaa, jintArray jSampleRate, jint channelMask, jint channelIndexMask,
                                jint audioFormat, jint buffSizeInBytes, jintArray jSession, jstring opPackageName,
                                jlong nativeRecordInJavaObj)
{
	......
    audio_attributes_t *paa = NULL;
    sp<AudioRecord> lpRecorder = 0;
    audiorecord_callback_cookie *lpCallbackData = NULL;

    jclass clazz = env->GetObjectClass(thiz);
    if(clazz == NULL)
    {
        ALOGE("Can't find %s when setting up callback.", kClassPathName);
        return (jint) AUDIORECORD_ERROR_SETUP_NATIVEINITFAILED;
    }

    // if we pass in an existing *Native* AudioRecord, we don't need to create/initialize one.
    if(nativeRecordInJavaObj == 0)
    {
		......

        // create an uninitialized AudioRecord object
        lpRecorder = new AudioRecord(String16(opPackageNameStr.c_str()));

        // read the AudioAttributes values
        paa = (audio_attributes_t *) calloc(1, sizeof(audio_attributes_t));
        const jstring jtags =
            (jstring) env->GetObjectField(jaa, javaAudioAttrFields.fieldFormattedTags);
        const char* tags = env->GetStringUTFChars(jtags, NULL);
        // copying array size -1, char array for tags was calloc'd, no need to NULL-terminate it
        strncpy(paa->tags, tags, AUDIO_ATTRIBUTES_TAGS_MAX_SIZE - 1);
        env->ReleaseStringUTFChars(jtags, tags);
        paa->source = (audio_source_t) env->GetIntField(jaa, javaAudioAttrFields.fieldRecSource);
        paa->flags = (audio_flags_mask_t)env->GetIntField(jaa, javaAudioAttrFields.fieldFlags);
        ALOGV("AudioRecord_setup for source=%d tags=%s flags=%08x", paa->source, paa->tags, paa->flags);

        audio_input_flags_t flags = AUDIO_INPUT_FLAG_NONE;
        if(paa->flags & AUDIO_FLAG_HW_HOTWORD)
        {
            flags = AUDIO_INPUT_FLAG_HW_HOTWORD;
        }
        // create the callback information:
        // this data will be passed with every AudioRecord callback
        lpCallbackData = new audiorecord_callback_cookie;
        lpCallbackData->audioRecord_class = (jclass)env->NewGlobalRef(clazz);
        // we use a weak reference so the AudioRecord object can be garbage collected.
        lpCallbackData->audioRecord_ref = env->NewGlobalRef(weak_this);
        lpCallbackData->busy = false;

        const status_t status = lpRecorder->set(paa->source,
                                                sampleRateInHertz,
                                                format,        // word length, PCM
                                                localChanMask,
                                                frameCount,
                                                recorderCallback,// callback_t
                                                lpCallbackData,// void* user
                                                0,             // notificationFrames,
                                                true,          // threadCanCallJava
                                                sessionId,
                                                AudioRecord::TRANSFER_DEFAULT,
                                                flags,
                                                -1, -1,        // default uid, pid
                                                paa);

        if(status != NO_ERROR)
        {
            ALOGE("Error creating AudioRecord instance: initialization check failed with status %d.",
                  status);
            goto native_init_failure;
        }
    }
    else     // end if nativeRecordInJavaObj == 0)
    {
        lpRecorder = (AudioRecord*)nativeRecordInJavaObj;

        // create the callback information:
        // this data will be passed with every AudioRecord callback
        lpCallbackData = new audiorecord_callback_cookie;
        lpCallbackData->audioRecord_class = (jclass)env->NewGlobalRef(clazz);
        // we use a weak reference so the AudioRecord object can be garbage collected.
        lpCallbackData->audioRecord_ref = env->NewGlobalRef(weak_this);
        lpCallbackData->busy = false;
    }
	......
    // save our newly created C++ AudioRecord in the "nativeRecorderInJavaObj" field
    // of the Java object
    setAudioRecord(env, thiz, lpRecorder);

    // save our newly created callback information in the "nativeCallbackCookie" field
    // of the Java object (in mNativeCallbackCookie) so we can free the memory in finalize()
    env->SetLongField(thiz, javaAudioRecordFields.nativeCallbackCookie, (jlong)lpCallbackData);

    return (jint) AUDIO_JAVA_SUCCESS;

    // failure:
    native_init_failure:
    env->DeleteGlobalRef(lpCallbackData->audioRecord_class);
    env->DeleteGlobalRef(lpCallbackData->audioRecord_ref);
    delete lpCallbackData;
    env->SetLongField(thiz, javaAudioRecordFields.nativeCallbackCookie, 0);

    // lpRecorder goes out of scope, so reference count drops to zero
    return (jint) AUDIORECORD_ERROR_SETUP_NATIVEINITFAILED;
}

接下来分析 setAudioRecord 函数,该函数返回 AudioRecord 对象的指针

// frameworks/base/core/jni/android_media_AudioRecord.cpp
// 结构体中的成员很重要, 把 AudioRecord 对象, 回调函数 和回调的音频数据保存到属性 jfielID,
// 并保存到 java 层,提高 JAVA <--> CPP 相互调用的效率。
struct audio_record_fields_t {
    // these fields provide access from C++ to the...
    jmethodID postNativeEventInJava; //... event post callback method
    jfieldID  nativeRecorderInJavaObj; // provides access to the C++ AudioRecord object
    jfieldID  nativeCallbackCookie;    // provides access to the AudioRecord callback data
    jfieldID  nativeDeviceCallback;    // provides access to the JNIDeviceCallback instance
};


static sp<AudioRecord> setAudioRecord(JNIEnv* env, jobject thiz, const sp<AudioRecord>& ar)
{
    Mutex::Autolock l(sLock);
    sp<AudioRecord> old =
            (AudioRecord*)env->GetLongField(thiz, javaAudioRecordFields.nativeRecorderInJavaObj);
    if (ar.get()) {
        ar->incStrong((void*)setAudioRecord);
    }
    if (old != 0) {
        old->decStrong((void*)setAudioRecord);
    }
    env->SetLongField(thiz, javaAudioRecordFields.nativeRecorderInJavaObj, (jlong)ar.get());
    return old;
}

 

分析了 stepup ,接下来分析 start 和 stop 函数,发现在 JNI 接口里调用本地 AudioRecord 对象。

主要的拾音业务逻辑在 AudioRecord.cpp 中。下一节分析 AudioRecord.cpp 。

// frameworks/base/core/jni/android_media_AudioRecord.cpp
// ----------------------------------------------------------------------------
static jint
android_media_AudioRecord_start(JNIEnv *env, jobject thiz, jint event, jint triggerSession)
{
    sp<AudioRecord> lpRecorder = getAudioRecord(env, thiz);
    if (lpRecorder == NULL ) {
        jniThrowException(env, "java/lang/IllegalStateException", NULL);
        return (jint) AUDIO_JAVA_ERROR;
    }

    return nativeToJavaStatus(
            lpRecorder->start((AudioSystem::sync_event_t)event, (audio_session_t) triggerSession));
}


// ----------------------------------------------------------------------------
static void
android_media_AudioRecord_stop(JNIEnv *env, jobject thiz)
{
    sp<AudioRecord> lpRecorder = getAudioRecord(env, thiz);
    if (lpRecorder == NULL ) {
        jniThrowException(env, "java/lang/IllegalStateException", NULL);
        return;
    }

    lpRecorder->stop();
    //ALOGV("Called lpRecorder->stop()");
}

 

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装 基于AudioRecord和OpenSL ES两种框架的采集PCM数据功能的统一封装
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值