QXRService:基于高通QXRService获取头显SLAM Pose和IMU Data

前述:

QXRService博文:

QVRService:基于SnapdragonXR-SDK 4.0.6进行QVRService的开发

QXRService:高通SnapdragonXR OpenXR SDK v1.x 概略

QXRService:基于高通QXRService获取头显SLAM Pose和IMU Data​​​​​​

QXRService:基于高通QXRService获取SLAM Camera图像

正文:

在上一篇博文的最后提到过,基于高通QXRService已经开发出了能够获取到几乎所有基础数据的工具应用。

今天就开始详细讲解如何基于高通QXRService进行程序开发,这一篇主要讲如何获取高通SLAM Pose和IMU Data。

在之前的博文中已经介绍过,由于高通新的SDK在创建几个关键结构体句柄时,需要传入Java虚拟机内存首地址(JavaVM*)以及运行上下文(Context),所以对QXRService的开发是JNI层的Native开发,需要具备一些JNI编程的基础知识

另外,此文的一些具体细节对之前的这一篇博文进行了补充和修正:《QVRService:基于SnapdragonXR-SDK 4.0.6进行QVRService的开发》

例如在上述博文中,曾经提到了JNI中创建QXRService相关结构体时,需要导入github上二次封装的开源jnipp.cpp和jnipp.h后才能正常使用,这一点已经不再需要。

如果还有其他未能提及到的差异点,以新的博客内容为准。

废话不多说,开始了。

一.使用AndroidStudio新建一个空的JNI应用

使用AndroidStudio新建一个名为 qvr-test 空的JNI应用,具体操作就不详细讲了,AndroidStudio的基本操作,网上资料也很多,请自行查阅。

二.添加依赖头文件和资源包到JNI应用中

2.1 添加依赖头文件

前面的博客中提到过,由于高通往后发布的基于OpenXR的Snapdragon XR OpenXR SDK v1.x系列SDK,将qxrservice封装在了Runtime中,而且以后会弱化掉qxrservice对外部接口暴露,所以我们程序中编译所需的qxrservice的相关头文件在新的Snapdragon XR OpenXR SDK v1.x中已不再提供。

所幸的是,这部分头文件我们从旧的SnapdragonXR-SDK 4.0.6中拷出后,仍然可以使用。

将SnapdragonXR-SDK 4.0.6/3rdparty/ 下的qvr和qxr两个子目录中inc文件夹里的头文件都拷贝到新建的qvr-test应用中:

​​   ​​

​​​​

在qvr-test应用的Jni部分代码中,新建一个inc和一个src文件夹

将上述目录中的头文件除个别文件外,其他都拷贝到 jni 目录的 inc 中

​​

2.2 添加依赖so

在之前的博文中也有介绍,针对QXRService的开发,需要加载QXRService的三个基础so,SnapdragonXR-SDK 4.0.6这种老的SDK版本中,这些so可以直接找到,但是在新版Snapdragon XR OpenXR SDK v1.x系列SDK中,需要我们做点不一样的操作:

​​

将openxr_runtime_app-inputService-release.aar的后缀由aar改为zip后解压得到如下:

​​

我们需要的三个基础so就在lib里面:

​​

将这三个so拷贝到qvr-test的jniLibs下面:

​​

2.3 添加依赖jar包

在最终整个qvr-test应用顺利编译完成,并且install到设备上开始运行时,qxrservice client时会调用一个QXRSocketFetcher类,其作用是高通QXRService内部从native与java层进行AIDL进程间通信。

因此在我们创建的qvr-test应用里,还需要再加载一个包含了QXRSocketFetcher以及相关AIDL文件的jar包,如果没有这个jar包,一运行就会crash,报如下异常:

​​

这个jar包在SnapdragonXR-SDK-source.rel.4.0.6的老版本中直接就有,就在 \SnapdragonXR-SDK-source.rel.4.0.6\3rdparty\qxr\libs 目录下,但是,我们现在做的是基于高通Snapdragon XR OpenXR SDK v1.x 系列SDK的QXRService开发,所以即使从老版本中拷贝过来也无法使用。

因为是这个类的依赖路径在新的SDK中,已经从老版本的 /com/qualcomm/qti/qxrsocketfetcher/ 变成了 /com/qualcomm/qti/qxrservice_client/

但是在Snapdragon XR OpenXR SDK v1.x 系列新版SDK中,高通并没有开放这个jar包给用户。

所以,需要找高通提case获取。

找高通要到这个class.jar之后,将其拷贝到libs下面,我们就能看到刚刚引用不到报错的QXRSocketFetcher类以及其他用于进程通信的相关AIDL文件:

​​

三.编写CMakeLists.txt

在创建空的JNI应用后,会在jni目录下生成一个CMakeLists.txt文件,因为我们需要在外部加载so,所以方便起见,把这个CMakeLists.txt挪到app根目录下:

​​

因为我们对QXRService的代码实现在jni代码目录的src下:

​​

Jni的实现代码qxrtest.cpp最终会被编译成so,被java层Load(),Api会被调用,所以CMakeLists.txt也需要对qxrtest.cpp进行编写。

综合我们在前面已经加载了依赖的头文件和so,现在就将它们都写进CMakeLists.txt文件中,代码如下:

# CMakeLists.txt
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.
#CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)

set(jni_base_dir "${CMAKE_SOURCE_DIR}/src/main/jni")
set(jniLibs_base_dir "${CMAKE_SOURCE_DIR}/src/main/jniLibs")

include_directories(${jni_base_dir}/inc)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
add_library(
        #设置so文件名称.
        qxrtest
        #设置这个so文件为共享.
        SHARED
        #Provides a relative path to your source file(s).
        ${jni_base_dir}/src/qxrtest.cpp)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
        log-lib
        #Specifies the name of the NDK library that
        #you want CMake to locate.
        log)

#动态方式加载 STATIC:表示静态的.a的库 SHARED:表示.so的库。
add_library(qxrcamclient SHARED IMPORTED)
add_library(qxrcoreclient SHARED IMPORTED)
add_library(qxrsplitclient SHARED IMPORTED)

#设置要连接的so的相对路径 ${CMAKE_SOURCE_DIR}:表示CMake.txt的当前文件夹路径 
#${ANDROID_ABI}:编译时会自动根据CPU架构去选择相应的库
set_target_properties(qxrcamclient PROPERTIES
        IMPORTED_LOCATION "${jniLibs_base_dir}/${ANDROID_ABI}/libqxrcamclient.so")
set_target_properties(qxrcoreclient PROPERTIES
        IMPORTED_LOCATION "${jniLibs_base_dir}/${ANDROID_ABI}/libqxrcoreclient.so")
set_target_properties(qxrsplitclient PROPERTIES
        IMPORTED_LOCATION "${jniLibs_base_dir}/${ANDROID_ABI}/libqxrsplitclient.so")

#添加第三方头文件
target_include_directories(qxrtest PRIVATE ${jni_base_dir}/inc ${jni_base_dir}/src)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
        #制定目标库.
        qxrtest
        #Links the target library to the log library
        #included in the NDK.
        ${log-lib}
        qxrcamclient
        qxrcoreclient
        qxrsplitclient)

四.编写Java层代码

在Java层,我们会做一个很简单的界面,其中包含两个Button和Boolean变量,用于对QXRService输出的SLAM Pose和IMU Data获取的Start和Stop控制。

获取到的数据我们会在Jni中就地保存到 /data/data/com.qvr.test/ 目录下,保存成标准的TUM格式文件。

Java代码只有两个文件,一个MainActivity.java,一个JNI.java:

​​

4.1 MainActivity.java代码:

package com.qvr.test;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    private String TAG = "APP_LOG";

    private Button btnGetPose;
    private Button btnGetIMU;

    private boolean mStartGetPose = false;
    private boolean mStartGetIMU = false;

    private JNI mJni = new JNI();

    private Handler mHandler = new Handler();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initView();

        //在java app加载完成native库后把 context传到native库中。
        mJni.nativeStoreContext(getApplicationContext());

        //context传入到native之后,对QXRService进行初始化
        mJni.nativeInitQxrService();
    }

    @Override
    public void onResume() {
        super.onResume();
    }

    @Override
    public void onPause() {
        super.onPause();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
    }

    public void initView() {
        btnGetPose = (Button) this.findViewById(R.id.btn_get_pose);
        btnGetPose.setOnClickListener(this);

        btnGetIMU = (Button) this.findViewById(R.id.btn_get_imu);
        btnGetIMU.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn_get_pose: {
                if (!mStartGetPose) {
                    btnGetPose.setText("Stop-GetPose");
                    mStartGetPose = true;

                    //调用jni api,开始获取QXRService输出的SLAM Pose
                    mJni.nativeStartSavePose();
                } else {
                    btnGetPose.setText("Start-GetPose");
                    mStartGetPose = false;

                    //调用jni api,结束获取QXRService输出的SLAM Pose
                    mJni.nativeStopSavePose();
                }
            }
            break;

            case R.id.btn_get_imu: {
                if (!mStartGetIMU) {
                    btnGetIMU.setText("Stop-GetIMU");
                    mStartGetIMU = true;

                    //调用jni api,开始获取QXRService输出的IMU data
                    mJni.nativeStartGetIMU();
                } else {
                    btnGetIMU.setText("Start-GetIMU");
                    mStartGetIMU = false;

                    //调用jni api,结束获取QXRService输出的IMU data
                    mJni.nativeStopGetIMU();
                }
            }
            break;

            default:
                break;
        }
    }
}

4.2 activity_main.xml代码:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_get_pose"
        android:layout_width="155dp"
        android:layout_height="60dp"
        android:layout_marginLeft="16dp"
        android:layout_marginTop="52dp"
        android:text="Start-GetPose"
        android:textAllCaps="false"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn_get_imu"
        android:layout_width="155dp"
        android:layout_height="60dp"
        android:layout_marginLeft="16dp"
        android:layout_marginTop="128dp"
        android:text="Start-GetIMU"
        android:textAllCaps="false"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

4.3 JNI.java 代码:

package com.qvr.test;

import android.content.Context;

import androidx.annotation.NonNull;

public class JNI {
    {
        System.loadLibrary("qxrtest");
    }

    public native void nativeStoreContext(@NonNull Context context);

    public native void nativeInitQxrService();

    public native void nativeStartSavePose();

    public native void nativeStopSavePose();

    public native void nativeStartGetIMU();

    public native void nativeStopGetIMU();
}

MainActivity.java 和 JNI.java 两个类中的代码较为简单,其中也已添加注释,稍微有点JNI开发基础知识的童鞋一看就明白,不再做过多介绍。

五.JNI代码编写

Jni部分的文件也只有两个,一个是qxrtest.h,一个是qxrtest.cpp

​​

5.1 qxrtest.h代码

我们将一些要初始化的变量写在.h文件中,另外创建一个结构体保存从Java层传下来的(JavaVM*)指针和Context,代码如下:

/*
* Created by shawn.xiao on 2022/6/20.
*/
#include <jni.h>
#include <string>
#include <unistd.h>
#include <android/log.h>

#include <stdlib.h>
#include <stdio.h>
#include <time.h>

#include "QXRCamClient.h"
#include "QXRCoreClient.h"
#include "QVRServiceClient.h"

#include <pthread.h>
#include <fstream>
#include <iostream>
#include <typeinfo>
#include <string>
#include <cmath>

#define LOG_TAG "QVR-Test"
#define LOGW(...) __android_log_print( ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__ )

#ifdef __cplusplus
extern "C" {

//用于保存Java层传下来的Java虚拟机内存首地址和运行上下文
static struct {
    struct _JavaVM *vm;
    jobject context;
} jni_android_info;

//QXRServiceClient handler
static qvrservice_client_helper_t *qvrservice_client = NULL;

//用于保存QXRService SLAM输出Pose
static qvrservice_head_tracking_data_t *head_tracking_data = NULL;

//用于保存QXRService IMU输出数据
static qvrservice_sensor_data_raw_t *sensor_raw_data = NULL;

//用于控制抓取QXRService SLAM Pose抓取线程
bool mIsStopSavePose = false;
//用于控制抓取QXRService IMU Data抓取线程
bool mIsStopGetIMU = false;

}
#endif

5.2 qxrtest.cpp代码:

在cpp代码中主要做如下几件事:
1.使用JavaVM*和Context创建QXRService基础结构体实例,设置VR模式:
  1.1 创建qvrservice_client
  1.2 设置VR Mode为6DOF
  1.3 StartVRMode
2.创建两个线程savePoseThread()和saveImuThread()用于从QXRService获取数据
3.保存数据到文件

在贴代码之前我们先对高通QXRService输出的Pose和IMU数据做个基本的了解。

高通CreatePoint网站上有一篇"80-PV306-1-SXR2130 XR Platform API Reference.pdf"文档(文档可能已不是最新,前缀有可能不同,搜索"SXR2130 XR Platform API Reference"关键字即可),这篇文档中有QXRService几乎所有API、参数、变量、结构体的详细注解。

其中用于获取Pose和IMU数据的结构体分别是:
struct qvrservice_head_tracking_data_t
struct qvrservice_sensor_data_raw_t

文档中这两个结构体及其每个成员都有详细的注解,其中包含各种位姿数据,状态,标志位等,Pose数据结构体中甚至还包含了3DOF模式下的相关数据,IMU数据结构体中也包含了磁力计等相关数据。

由于文档有高通Logo水印,我就不在这截图显示了,有需要的同学自行下载查看即可。
或者直接在QVRTypes.h等相关头文件中的看代码定义也一样。

在qvrtest.cpp中对Pose和IMU数据进行文件存储时,只选取了部分关键成员数据
Pose:{时间戳,Position,四元数}
IMU:{时间戳,Gyroscope(陀螺仪),Accelerometer(加速度计)}

代码如下:

/*
/* Created by shawn1.xiao on 2022/6/20.
*/
#include "qxrtest.h"

using namespace std;

#ifdef __cplusplus
extern "C" {

string txt = ".txt";
string rootPath = "/data/data/com.qvr.test/";

JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeStoreContext(JNIEnv *env, jobject thiz, jobject context) {
    JavaVM *jvm = nullptr;
    jint result = env->GetJavaVM(&jvm);
    assert(result == JNI_OK);
    assert(jvm);

    jobject jContext = env->NewGlobalRef(context);

    //保存JavaVM*、Context
    jni_android_info.vm = jvm;
    jni_android_info.context = jContext;
}

//Init QVRService Start
JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeInitQxrService(JNIEnv *env, jobject thiz) {
    int ret = QVR_ERROR;

    //*********************** QVRServiceClient Init**************************
    qvrservice_client = QXRCoreClient_Create(jni_android_info.vm, jni_android_info.context);
    if (qvrservice_client == NULL) {
        LOGW("Fail to create qvrservice_client!");
    } else {
        LOGW("Success to create qvrservice_client!");
    }

    //设置TrackingMode为6DOF
    ret = QVRServiceClient_SetTrackingMode(qvrservice_client, TRACKING_MODE_POSITIONAL);
    if (ret != QVR_SUCCESS) {
        LOGW("set tracking mode 6dof failed!  ret:%d", ret);
    }

    sleep(1);

    //获取当前VRMode,必须要是VRMODE_STOPPED状态才能Start
    QVRSERVICE_VRMODE_STATE vrmode = QVRServiceClient_GetVRMode(qvrservice_client);
    LOGW("get vr mode:%d", vrmode);
    if (VRMODE_STOPPED == vrmode) {
        //当前VRMode为VRMODE_STOPPED状态下,Start VRMode
        ret = QVRServiceClient_StartVRMode(qvrservice_client);
        LOGW("start vr mode ret:%d, vrmode:%d", ret, QVRServiceClient_GetVRMode(qvrservice_client));
        if (ret != QVR_SUCCESS) {
            LOGW("start vr mode failed");
        }
    }
}
//Init QVRService End

//获取当前系统时间,按format进行转换
string getDateTime() {  //24H data format
    struct tm tm;
    time_t ts = time(0);
    localtime_r(&ts, &tm);

    char buff[128];
    strftime(buff, sizeof(buff), "%Y-%m%d-%H%M-%S", &tm);

    string time = buff;
    return time;
}

//GetPose Start
//抓取QXRService SLAM Pose数据线程
void *savePoseThread(void *arg) {
    int ret = QVR_ERROR;
    mIsStopSavePose = false;

    //用于保存每条Pose
    string trajContent;
    //文件全路径为:根目录路径+"traj-"+当前时间+".txt"
    string trajPath = rootPath + "traj-" + getDateTime() + txt;

    ofstream os_traj;                   //创建文件输出流对象
    os_traj.open(trajPath, ios::app);   //将对象与文件关联

    //while循环获取,当mIsStopSavePose为true时,跳出循环
    while (!mIsStopSavePose) {
        //通过创建的qvrservice_client获取Pose数据
        ret = QVRServiceClient_GetHeadTrackingData(qvrservice_client, &head_tracking_data);
        if (ret == QVR_SUCCESS && head_tracking_data != NULL) {
            //每一条Pose包含:{时间戳,Position,四元素}
            //四元素可以自行转换欧拉角,相关函数已实现,此处不作说明
            trajContent = to_string(head_tracking_data->ts)
                          + " " + to_string(head_tracking_data->translation[0])
                          + " " + to_string(head_tracking_data->translation[1])
                          + " " + to_string(head_tracking_data->translation[2])
                          + " " + to_string(head_tracking_data->rotation[0])
                          + " " + to_string(head_tracking_data->rotation[1])
                          + " " + to_string(head_tracking_data->rotation[2])
                          + " " + to_string(head_tracking_data->rotation[3])
                          + "\n";
            os_traj << trajContent;
        }
        usleep(10000);
    }

    sleep(1);
    os_traj.close();

    pthread_exit(NULL);
    return NULL;
}

JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeStartSavePose(JNIEnv *env, jobject obj) {
    //创建抓取Pose数据线程
    pthread_t myThread;
    int res = pthread_create(&myThread, NULL, savePoseThread, NULL);
    if (res != 0) {
        LOGW("savePoseThread create failed!");
        return;
    }
}

JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeStopSavePose(JNIEnv *env, jobject obj) {
    LOGW("nativeStopSavePose()    mIsStopSavePose:%d", mIsStopSavePose);
    //由Java调用,Stop的时候,线程停止运行
    mIsStopSavePose = true;
}
//GetPose End

//GetImu Start
//流程与PoseThread类似,仅差异部分作注解,其他部分请看看代码就明白了
void *saveImuThread(void *arg) {
    int ret = QVR_ERROR;
    mIsStopGetIMU = false;

    //IMU 陀螺仪和加速度计数据
    string gyroContent, acceContent;
    string gyroPath = rootPath + "Gyro-" + getDateTime() + txt;
    string accePath = rootPath + "Acce-" + getDateTime() + txt;

    ofstream os_gyro, os_acce;
    os_gyro.open(gyroPath, ios::app);
    os_acce.open(accePath, ios::app);

    while (!mIsStopGetIMU) {
        ret = QVRServiceClient_GetSensorRawData(qvrservice_client, &sensor_raw_data);
        if (ret == QVR_SUCCESS && sensor_raw_data != NULL) {
            LOGW("GetIMU Gyroscope     :{%lu, %f, %f, %f}", sensor_raw_data->gts,sensor_raw_data->gx,sensor_raw_data->gy,sensor_raw_data->gz);
            LOGW("GetIMU Accelerometer :{%lu, %f, %f, %f}", sensor_raw_data->ats,sensor_raw_data->ax,sensor_raw_data->ay,sensor_raw_data->az);
            gyroContent = to_string(sensor_raw_data->gts)
                          + " " + to_string(sensor_raw_data->gx)
                          + " " + to_string(sensor_raw_data->gy)
                          + " " + to_string(sensor_raw_data->gz)
                          + "\n";
            os_gyro << gyroContent;

            acceContent = to_string(sensor_raw_data->ats)
                          + " " + to_string(sensor_raw_data->ax)
                          + " " + to_string(sensor_raw_data->ay)
                          + " " + to_string(sensor_raw_data->az)
                          + "\n";
            os_acce << acceContent;
        }

        usleep(10000);
    }

    sleep(1);
    os_gyro.close();
    os_acce.close();

    pthread_exit(NULL);
    return NULL;
}

JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeStartGetIMU(JNIEnv *env, jobject obj) {
    pthread_t myThread;
    int res = pthread_create(&myThread, NULL, saveImuThread, NULL);
    if (res != 0) {
        LOGW("saveImuThread create failed!");
        return;
    }
}

JNIEXPORT void JNICALL
Java_com_qvr_test_JNI_nativeStopGetIMU(JNIEnv *env, jobject obj) {
       mIsStopGetIMU = true;
}
//GetImu End

}
#endif

到此,基于高通QXRService获取头显SLAM Pose和IMU Data的Demo代码开发工作就已完成,在我们对代码进行编译、安装后,再做个简单的测试,看是否能得到我们想要的结果。

六.运行测试

6.1 编译apk,安装

生成的apk以及在native launcher上安装后的简单界面:

​​

 ​​

6.2 执行Start-GetPose和Start-GetIMU

在点击按钮"Start-GetPose"和"Start-GetIMU"之后,就开始抓取Pose和IMU数据,

同时两个Button上的Text会显示"Stop-GetPose"和"Stop-GetIMU"

如果想停止数据抓取,再次点击按钮即可,相应的两个按钮上的Text也会切换回"Start-GetPose"和"Start-GetIMU"

此时,在Start和Stop之间的SLAM和IMU数据就被抓取并保存在"/data/data/com.qvr.test/"目录下了,使用adb pull命令将其pull出来就行了

6.3 查看保存的数据文件

使用adb命令将保存的文件pull出来后,查看其中数据:

​​

traj-2022-1025-1419-50.txt: 

​​

Gyro-2022-1025-1419-53.txt:

​​

Acce-2022-1025-1419-53.txt:

​​

七.结束

如果按照博文内容动手撸代码一直到这里,相信你对高通QXRService的开发已经有了一个基本的理解了,基于QXRService我们可以成功地拿到头显的Head Tracking Date也就是SLAM Pose,还有IMU Sensor Raw Data。

下一篇博文接着讲怎么基于QXRService拿到头显顶部和底部SLAM摄像头的图像数据。

  • 7
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值