【蓝牙开发】Andorid蓝牙绝对音量

蓝牙绝对音量相关基础知识
1. 什么是绝对音量

Android 蓝牙部分的官方文档有如下描述:
Androud Bluetooth Service

在 Android 6.0 及更高版本中,Android 蓝牙堆栈允许音频源设置绝对音量,以便用户准确控制音频音量。音频源设备会将音量信息和未衰减的音频发送到接收器。然后,接收器会根据音量信息放大音频,以便用户听到准确的播放音量。
音频源设备还可以注册接收音量通知。进行此项注册后,当用户使用接收器上的控件更改音量时,接收器便会向音频源发送通知。这样一来,音频源便能够准确地在界面上显示音量信息。
绝对音量控制默认处于开启状态。如需停用绝对音量控制,用户可以依次转到设置 > 系统 > 开发者选项,然后选择停用绝对音量功能开关。

而蓝牙绝对音量(absolute volume)的概念是在AVRCP Sepc 1.4中引入的, 是为了替代蓝牙中的相对音量(relative volume)命令: volume up 和 volume down, 之前的相对音量是不考虑对方的Target device是处于最大音量或在最小音量.

2. AVRCP中的Controller和Target 两种角色的理解

在这里插入图片描述
首先看下两种角色在协议构成角度来说是没有太大的区别, 现在来看下AVRCP Spec中关于AVRCP中Controller(CT)和Target(TG)的描述.

  • The controller (CT) is a device that initiates a transaction by sending a command frame to a target. Examples for CT are a personal computer, a PDA, a mobile phone, a remote controller or an AV device (such as an in car system, headphone, player/recorder, timer, tuner, monitor etc.).
    将发起AVRC控制命令到对方的蓝牙设备称为CT

  • The target (TG) is a device that receives a command frame and accordingly generates a response frame. Examples for TG are an audio player/recorder, a video player/recorder, a TV, a tuner, an amplifier or a headphone.
    将接收AVRC控制命令并回复respond的蓝牙设备称为TG

例如手机和车载蓝牙为例子:
在这里插入图片描述
如上图所示, 是车载蓝牙发起控制命令, 所以在这里车载蓝牙是CT,而手机是TG.

补充说明:
针对AVRCP中的CT和TG的角色定义 和谁发起蓝牙连接是无关的.
另外一个蓝牙蓝牙设备是可以同时支持CT和TG两种角色的(这点在后续讲到A2DP Source和Sink的时候会在详细讲解).

3. 蓝牙绝对音量(Absolute)的定义

蓝牙绝对音量的特性提供了音量处理的功能能够允许CT进行音量变化的展示.
在AVRCP的Spec中定义了两个命令用于绝对音量的设置以及音量变化的监听.

SetAbsoluteVolume命令用于设置绝对音量
命令格式如下:
在这里插入图片描述在这里插入图片描述这里以手机连接蓝牙音箱为例子, 在这个场景下手机为AVRCP CT,蓝牙音箱为AVRCP TG.
当在手机端进行音量调节时,手机端会发送SetAbsoluteVolume命令到音箱端进行音量的调节,
需要注意的是,当手机开启绝对音量的功能后,在手机端调节音量并不会对传输的音源进行音量处理而是通过TG进行音量的调节.

Notify Volume Change
RegisterNotification(Volume Changed Event)命令用于CT进行TG上本地音量的监听以及对于TG的音量同步.
命令格式定义如下:
在这里插入图片描述
下面可以看下AVRCP Spec中关于它的描述:
在这里插入图片描述
在CT和TG建立连接的时候,CT会注册RegisterNotification(Volume Changed)到TG, TG在接收到该注册请求后,会先返回一个临时的response(仅是获取当前TG的音量同步到CT). 之后只有在用户操作TG的本地音量后会再次同步一个RegisterNotification response(同步当前的TG音量). 而这才是完成一次Volume Change Notification.

这里需要注意的是, RegisterNotification(Volume Changed)的流程是不会因为CT发起了SetAbsoluteVolume命令打断的.

4. 什么情况下蓝牙设备需要支持绝对音量呢

AVRCP V1.4及以上版本SDP中AVRCP 的分类:Category 2需支持绝对音量
而GetCapabilities中的EVENT_VOLUME_CHANGED 标志会影响CT端是否会发起SetAbsoluteVolume.
关于绝对音量的支持,由于Spec中没有特明确的规定导致不同厂商在实现上会有些差异,也就导致了我们常说的兼容性问题,所以在我们掌握了什么是绝对音量后可以根据实际抓到数据进行特定适配.


Android 蓝牙中绝对音量的实现

本文是基于一款基于Android系统开发的带屏音箱进行讲述, 该音箱实现了A2DP Sink 和A2DP Source.
而A2DP Sink是和AVRCP Target搭配,A2DP Source是和AVRCP Controller搭配.

手机连接音箱场景下音箱端绝对音量的实现

手机连接音箱进行蓝牙音乐播放场景下,手机是: A2DP Source + AVRCP Controller
而音箱端是: A2DP Sink + AVRCP Target

1. 基于Bluetooth Spec 讲述绝对音量的控制流程

手机端在与音箱端建立蓝牙连接后,发出"Get Capabilities PDU 0x10", 问询音箱端支持的event
音箱端在接收到"Get Capabilities PDU"后,回复所支持的event, 其中包含: (absolute)volume changed - 0x0d

手机端在收到response后,看到音箱端支持"(absolute)volume changed", 于是发起了notification register - “registers for volume changed notifications”

然后音箱端需要立即回复一个临时的response(interim response),将当前音箱端(Target)的音量同步到手机(Controller)

手机端在收到该response后就可以及时的通过音量条显示,而手机端希望调大音量 会发出"SET_ABSOLUTE_VOLUME"命令到音箱端进行音量的设置

音箱端在接收到该命令后,会进行音量的设置 并返回真正设置的音量到手机端, 而手机端会基于该返回的音量值进行显示

而此刻当用户在音箱端主动进行音量的调节后,会触发"Voloume Changed notification" 通知到手机端.
而手机端希望再次监听音箱端的音量变化需要重新注册notification register - “registers for volume changed notifications”.
在这里插入图片描述

2. 基于音箱端抓取到HCI日志分析绝对音量控制流程

待输出

3. 音箱端(Android: A2DP Sink + AVRCP Target)的绝对音量的相关代码分析

补充说明的是,关于Android系统下蓝牙的相关基础知识会在其它文章中进行分享,本文是基于大家对于Android 系统蓝牙有了基本了解的前提下展开.
下面来分析下当手机端发起notification register - “registers for volume changed notifications” 和 SET_ABSOLUTE_VOLUME指令过程中音箱端相关代码的实现.

在system/bt(bluedroid)中主要是对手机发过来的registers for volume changed notifications 进行数据的解析.

// system/bt/btif/src/btif_rc.cc
void btif_rc_handler(tBTA_AV_EVT event, tBTA_AV* p_data) {
...
    case BTA_AV_META_MSG_EVT: {
      ...
      } else if (bt_rc_ctrl_callbacks != NULL) {
        /* This is case of Sink + CT + TG(for abs vol)) */
        switch (p_data->meta_msg.p_msg->hdr.opcode) {
          case AVRC_OP_VENDOR:
            if ((p_data->meta_msg.code >= AVRC_RSP_NOT_IMPL) &&
                (p_data->meta_msg.code <= AVRC_RSP_INTERIM)) {
              /* Its a response */
              handle_avk_rc_metamsg_rsp(&(p_data->meta_msg));
            } else if (p_data->meta_msg.code <= AVRC_CMD_GEN_INQ) {
              /* Its a command  */
              handle_avk_rc_metamsg_cmd(&(p_data->meta_msg));
            }
            break;    
}

// system/bt/btif/src/btif_rc.cc
// handle_avk_rc_metamsg_cmd
static void handle_avk_rc_metamsg_cmd(tBTA_AV_META_MSG* pmeta_msg) {
    ...
      if (avrc_cmd.pdu == AVRC_PDU_REGISTER_NOTIFICATION) {
        uint8_t event_id = avrc_cmd.reg_notif.event_id;
        BTIF_TRACE_EVENT("%s: Register notification event_id: %s", __func__,
                         dump_rc_notification_event_id(event_id));
      } else if (avrc_cmd.pdu == AVRC_PDU_SET_ABSOLUTE_VOLUME) {
        BTIF_TRACE_EVENT("%s: Abs Volume Cmd Recvd", __func__);
      }

      btif_rc_ctrl_upstreams_rsp_cmd(avrc_cmd.pdu, &avrc_cmd, pmeta_msg->label,
                                     p_dev);
                                     
 }
 
 // 在btif_rc_ctrl_upstreams_rsp_cmd回调Bluetoth Process中往Bluedorid中注册的registernotification_absvol_cb
 static void btif_rc_ctrl_upstreams_rsp_cmd(uint8_t event,
                                           tAVRC_COMMAND* pavrc_cmd,
                                           uint8_t label,
                                           btif_rc_device_cb_t* p_dev) {
  BTIF_TRACE_DEBUG("%s: pdu: %s: handle: 0x%x", __func__,
                   dump_rc_pdu(pavrc_cmd->pdu), p_dev->rc_handle);
  switch (event) {
    case AVRC_PDU_REGISTER_NOTIFICATION:
      if (pavrc_cmd->reg_notif.event_id == AVRC_EVT_VOLUME_CHANGE) {
        // 最终会回调Bluetooth/jni/com_android_bluetooth_avrcp_controller.cpp 中定义的
        do_in_jni_thread(
            FROM_HERE,
            base::Bind(bt_rc_ctrl_callbacks->registernotification_absvol_cb,
                       p_dev->rc_addr, label));
      }
      break;
  }
}

在system/bt(Bluedorid)处理解析"registers for volume changed notifications“ 后会回调到Bluetooth App中的相关代码, 并通过Bluetooth App中获取到当前系统音量后,返回interim response到手机端.

// /packages/apps/Bluetooth/jni/com_android_bluetooth_avrcp_controller.cpp

static void btavrcp_register_notification_absvol_callback(
    const RawAddress& bd_addr, uint8_t label) {
  ALOGI("%s", __func__);
  CallbackEnv sCallbackEnv(__func__);
  if (!sCallbackEnv.valid()) return;

  ScopedLocalRef<jbyteArray> addr(
      sCallbackEnv.get(), sCallbackEnv->NewByteArray(sizeof(RawAddress)));
  if (!addr.get()) {
    ALOGE("Fail to get new array ");
    return;
  }

  sCallbackEnv->SetByteArrayRegion(addr.get(), 0, sizeof(RawAddress),
                                   (jbyte*)&bd_addr.address);
  // jni 方式回调Java的API method_handleRegisterNotificationAbsVol - handleRegisterNotificationAbsVol
  sCallbackEnv->CallVoidMethod(sCallbacksObj,
                               method_handleRegisterNotificationAbsVol,
                               addr.get(), (jbyte)label);
}

// /packages/apps/Bluetooth/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java

// Called by JNI when remote wants to receive absolute volume notifications.
    private synchronized void handleRegisterNotificationAbsVol(byte[] address, byte label) {
        Log.d(TAG, "handleRegisterNotificationAbsVol ");
        BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
        if (device != null && !device.equals(mConnectedDevice)) {
            Log.e(TAG, "handleRegisterNotificationAbsVol device not found " + address);
            return;
        }
        Message msg = mAvrcpCtSm.obtainMessage(
                AvrcpControllerStateMachine.MESSAGE_PROCESS_REGISTER_ABS_VOL_NOTIFICATION,
                (int) label, 0);
        mAvrcpCtSm.sendMessage(msg);
    }

// 发送MESSAGE_PROCESS_REGISTER_ABS_VOL_NOTIFICATION到ArcpController的状态机中进一步处理
// /packages/apps/Bluetooth/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
// Connected状态下进行处理
 class Connected extends State {
     public boolean processMessage(Message msg) {
     A2dpSinkService a2dpSinkService = A2dpSinkService.getA2dpSinkService();
            synchronized (mLock) {
                switch (msg.what) {
                case MESSAGE_PROCESS_REGISTER_ABS_VOL_NOTIFICATION: {
                        mRemoteDevice.setNotificationLabel(msg.arg1);
                        mRemoteDevice.setAbsVolNotificationRequested(true);
                        int percentageVol = getVolumePercentage();
                        if (DBG) {
                            Log.d(TAG, " Sending Interim Response = " + percentageVol + " label "
                                    + msg.arg1);
                        }
                        // 通过JNI方式调用sendRegisterAbsVolRspNative
                        AvrcpControllerService.sendRegisterAbsVolRspNative(
                                mRemoteDevice.getBluetoothAddress(), NOTIFICATION_RSP_TYPE_INTERIM,
                                percentageVol, mRemoteDevice.getNotificationLabel());
                    }
                    break;
                ...
}

// /packages/apps/Bluetooth/jni/com_android_bluetooth_avrcp_controller.cpp
static void sendRegisterAbsVolRspNative(JNIEnv* env, jobject object, ...{
    // system/bt中register_abs_vol_rs(实际函数: volume_change_notification_rsp)
    bt_status_t status = sBluetoothAvrcpInterface->register_abs_vol_rsp(
      rawAddress, (btrc_notification_type_t)rsp_type, (uint8_t)abs_vol,
      (uint8_t)label);
}

// system/bt/btif/src/btif_rc.cc
/***************************************************************************
 *
 * Function         send_register_abs_vol_rsp
 *
 * Description      Rsp for Notification of Absolute Volume
 *
 * Returns          void
 *
 **************************************************************************/
static bt_status_t volume_change_notification_rsp(...
{
  // 这里就比较好理解了 数据格式在AVRCP Sepc中有详细的描述
  avrc_rsp.reg_notif.opcode = AVRC_OP_VENDOR;
  avrc_rsp.reg_notif.pdu = AVRC_PDU_REGISTER_NOTIFICATION;
  avrc_rsp.reg_notif.status = AVRC_STS_NO_ERROR;
  avrc_rsp.reg_notif.param.volume = abs_vol;
  avrc_rsp.reg_notif.event_id = AVRC_EVT_VOLUME_CHANGE;

  status = AVRC_BldResponse(p_dev->rc_handle, &avrc_rsp, &p_msg);
  if (status == AVRC_STS_NO_ERROR) {
    BTIF_TRACE_DEBUG("%s: msgreq being sent out with label: %d", __func__,
                     label);
    uint8_t* data_start = (uint8_t*)(p_msg + 1) + p_msg->offset;
    BTA_AvVendorRsp(p_dev->rc_handle, label,
                    (rsp_type == BTRC_NOTIFICATION_TYPE_INTERIM)
                        ? AVRC_RSP_INTERIM
                        : AVRC_RSP_CHANGED,
                    data_start, p_msg->len, 0);
}

另外这里将上述的代码调用过程整理成了下面的UML图,由于图片较大 需要令打开一个窗口进行放大查看.
在这里插入图片描述
以上讲述了手机向音箱注册“volume changed notifications“时 音箱端的处理流程,而在音箱接受这个注册后再进行音箱端的音量调节时会有哪些行为呢?请看接下来的分析

// /packages/apps/Bluetooth/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
// BroadcastReceiver 监听系统音量的变化,在用户进行音量调节时, 这里监听到后会发送MESSAGE_PROCESS_VOLUME_CHANGED_NOTIFICATION
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            if (action.equals(AudioManager.VOLUME_CHANGED_ACTION)) {
                int streamType = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, -1);
                if (streamType == AudioManager.STREAM_MUSIC) {
                    sendMessage(MESSAGE_PROCESS_VOLUME_CHANGED_NOTIFICATION);
                }
            }
        }
    };
  
// 
class Connected extends State {
        @Override
        public boolean processMessage(Message msg) {
            if (DBG) Log.d(TAG, " HandleMessage: " + dumpMessageString(msg.what));
            A2dpSinkService a2dpSinkService = A2dpSinkService.getA2dpSinkService();
            synchronized (mLock) {
                switch (msg.what) {
                 case MESSAGE_PROCESS_VOLUME_CHANGED_NOTIFICATION: {

                     if (mRemoteDevice.getAbsVolNotificationRequested()) {
                         int percentageVol = getVolumePercentage();
                         if (percentageVol != mPreviousPercentageVol) {
                             // 通过JNI 方式 sendRegisterAbsVolRspNative
                             // 最终会通过system/bt中的volume_change_notification_rsp
                             // 这里大家会注意 这个接口不就是前面提到的Interim Response 是一样的吗? 是一样 这是由于这两个动作的数据格式和行为本质上是相同的
                             // 一个是当手机发起注册行为时需要立即回复
                             // 一个是在用户调节音量后进行回复
                             AvrcpControllerService.sendRegisterAbsVolRspNative(mRemoteDevice.getBluetoothAddress(), NOTIFICATION_RSP_TYPE_CHANGED, percentageVol,mRemoteDevice.getNotificationLabel());
                          }
                     }
              }
              
              ...
}

补充说明:
需要注意的是, 用户在调节音量并回复response到手机后,手机如果期望继续监听音箱端音量的变化 需要再次注册"volume changed notifications"

下面继续看下当手机端发出“SET_ABSOLUTE_VOLUME”命令时,音箱端的处理流程.

在system/bt中SET_ABSOLUTE_VOLUME的处理入口和“volume changed notifications”一样, 是在
handle_avk_rc_metamsg_cmd.

// system/bt/btif/src/btif_rc.cc
static void handle_avk_rc_metamsg_cmd(tBTA_AV_META_MSG* pmeta_msg) {
    ...
     if (avrc_cmd.pdu == AVRC_PDU_REGISTER_NOTIFICATION) {
        uint8_t event_id = avrc_cmd.reg_notif.event_id;
        BTIF_TRACE_EVENT("%s: Register notification event_id: %s", __func__,
                         dump_rc_notification_event_id(event_id));
      } else if (avrc_cmd.pdu == AVRC_PDU_SET_ABSOLUTE_VOLUME) {
        BTIF_TRACE_EVENT("%s: Abs Volume Cmd Recvd", __func__);
      }

      btif_rc_ctrl_upstreams_rsp_cmd(avrc_cmd.pdu, &avrc_cmd, pmeta_msg->label,
                                     p_dev);
   ...
}

// 在btif_rc_ctrl_upstreams_rsp_cmd中通过JNI的方式 调用Bluetooth Precess中定义的API
static void btif_rc_ctrl_upstreams_rsp_cmd(uint8_t event,
                                           tAVRC_COMMAND* pavrc_cmd,
                                           uint8_t label,
                                           btif_rc_device_cb_t* p_dev) {
  BTIF_TRACE_DEBUG("%s: pdu: %s: handle: 0x%x", __func__,
                   dump_rc_pdu(pavrc_cmd->pdu), p_dev->rc_handle);
  switch (event) {
    case AVRC_PDU_SET_ABSOLUTE_VOLUME:
      // 而这里 bt_rc_ctrl_callbacks->setabsvol_cmd_cb 这里是怎么调用的Bluetooth Process中的相关api呢?
      // 
      do_in_jni_thread(
          FROM_HERE,
          base::Bind(bt_rc_ctrl_callbacks->setabsvol_cmd_cb, p_dev->rc_addr,
                     pavrc_cmd->volume.volume, label));
      break;
    ...
  }
}

// 下来先来看下 bt_rc_ctrl_callbacks是如何被初始化的
// 通过阅读源码可以看到 bt_rc_ctrl_callbacks是在 init_ctrl中进行赋值的
static bt_status_t init_ctrl(btrc_ctrl_callbacks_t* callbacks) {
    
  if (bt_rc_ctrl_callbacks) return BT_STATUS_DONE;

  bt_rc_ctrl_callbacks = callbacks;

}

// 而init_ctrl是和bt_rc_ctrl_interface关联的也就是外部使用的初始化接口
static const btrc_ctrl_interface_t bt_rc_ctrl_interface = {
    sizeof(bt_rc_ctrl_interface),
    init_ctrl,
    ...
}

// 进一步看 在Bluetooth Proceess中是如何调用init_ctrl 进行初始化的
// /packages/apps/Bluetooth/jni/com_android_bluetooth_avrcp_controller.cpp

static void initNative(JNIEnv* env, jobject object) {
    ...
    const bt_interface_t* btInf = getBluetoothInterface();
    ...
    sBluetoothAvrcpInterface =
      (btrc_ctrl_interface_t*)btInf->get_profile_interface(
          BT_PROFILE_AV_RC_CTRL_ID);
          
    ...
    // static btrc_ctrl_callbacks_t sBluetoothAvrcpCallbacks = {
    //     btavrcp_set_abs_vol_cmd_callback
    //     ...
    // 也就是 bt_rc_ctrl_callbacks->setabsvol_cmd_cb 对应的是 btavrcp_set_abs_vol_cmd_callback
    bt_status_t status =
      sBluetoothAvrcpInterface->init(&sBluetoothAvrcpCallbacks);
}

// 继续看下 btavrcp_set_abs_vol_cmd_callback中是如何操作的.
static void btavrcp_set_abs_vol_cmd_callback(const RawAddress& bd_addr,...
{
    ...
    // method_handleSetAbsVolume --> handleSetAbsVolume
    sCallbackEnv->CallVoidMethod(sCallbacksObj, method_handleSetAbsVolume,
                               addr.get(), (jbyte)abs_vol, (jbyte)label);
}

 // Called by JNI when remote wants to set absolute volume.
    private synchronized void handleSetAbsVolume(byte[] address, byte absVol, byte label) {
        Log.d(TAG, "handleSetAbsVolume ");
        BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
        if (device != null && !device.equals(mConnectedDevice)) {
            Log.e(TAG, "handleSetAbsVolume device not found " + address);
            return;
        }
        Message msg = mAvrcpCtSm.obtainMessage(
                AvrcpControllerStateMachine.MESSAGE_PROCESS_SET_ABS_VOL_CMD, absVol, label);
        mAvrcpCtSm.sendMessage(msg);
    }

// 在ArcpController的状态机中进行处理
// /packages/apps/Bluetooth/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
class Connected extends State {
        @Override
        public boolean processMessage(Message msg) {
             case MESSAGE_PROCESS_SET_ABS_VOL_CMD:
                        mVolumeChangedNotificationsToIgnore++;
                        removeMessages(MESSAGE_INTERNAL_ABS_VOL_TIMEOUT);
                        sendMessageDelayed(MESSAGE_INTERNAL_ABS_VOL_TIMEOUT,
                                ABS_VOL_TIMEOUT_MILLIS);
                        setAbsVolume(msg.arg1, msg.arg2);
                        break;
                        ...
                        }
 
 

 private void setAbsVolume(int absVol, int label) {
     // 对absVol进行处理 并通过mAudioManager.setStreamVolume调整系统音量后返回到remote device
      AvrcpControllerService.sendAbsVolRspNative(mRemoteDevice.getBluetoothAddress(), absVol,
                label);
 }
 // 通过JNI的方式调用com_android_bluetooth_avrcp_controller.cpp下的sendAbsVolRspNative
 // /packages/apps/Bluetooth/jni/com_android_bluetooth_avrcp_controller.cpp
 
 static void sendAbsVolRspNative(JNIEnv* env, jobject object, jbyteArray address,...
 {
     
 }
 
 // 然后调用到system/bt下的set_volume_rsp
 // system/bt/btif/src/btif_rc.cc
 
 static bt_status_t set_volume_rsp(const RawAddress& bd_addr, uint8_t abs_vol,
                                  uint8_t label) {
                                  
     avrc_rsp.volume.opcode = AVRC_OP_VENDOR;
     avrc_rsp.volume.pdu = AVRC_PDU_SET_ABSOLUTE_VOLUME;
     avrc_rsp.volume.status = AVRC_STS_NO_ERROR;
     avrc_rsp.volume.volume = abs_vol;
     // 基于Spec中规定的AVRC_PDU_SET_ABSOLUTE_VOLUME RSP格式 回复message
     status = AVRC_BldResponse(p_dev->rc_handle, &avrc_rsp, &p_msg);
     BTA_AvVendorRsp(p_dev->rc_handle, label, AVRC_RSP_ACCEPT, data_start,
                      p_msg->len, 0);
                      ...
}                             
                                  

  • 4
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值