鸿蒙5.0【HarmonyOS原生开发】OpenGL绘制三角形

一、简介

在[鸿蒙NDK开发入门]中介绍了ArkTS与C/C++相互调用流程,本文承接上文,介绍使用OpenGL绘制一个三角形,通过绘制三角形来熟悉OpenGL的绘制流程。CPU和GPU都能用于图形渲染,部分场景下如果使用CPU渲染,性能就非常差。但GPU可以大大提高渲染速度,OpenGL可以操作GPU,是一个2D/3D图形库,用于视频渲染、视频编辑、视频特效、游戏引擎等。除了OpenGL外,ValKan、Metal、Direct3D、WebGL、WebGPU等都是优秀的2D/3D图形库。目前OpenGL应用最广泛,资料也最丰富,跨平台,学习起来相对容易。

二、OpenGL ES

OpenGL ES是OpenGL的子集,专门用于手机、平板等小型设备,删除了不必要的方法、减少了体积。所以准确的来说,我们学习的其实是OpenGL ES

三、XComponent

XComponent组件作为一种渲染组件,通常用于满足较为复杂的自定义渲染需求,例如相机预览流的显示、游戏画面的渲染、视频的渲染。XComponent又拥有单独的NativeWindow,可以在native侧提供native window用来创建EGL/OpenGLES环境,进而使用标准的OpenGL ES开发。

3、1添加EGL/OpenGLES库
# the minimum version of CMake.
cmake_minimum_required(VERSION 3.5.0)
project(egl)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif()

include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

# 查找相关库 (包括OpenGL ES相关库和XComponent提供的ndk接口)
find_library( EGL-lib
              EGL )

find_library( GLES-lib
              GLESv3 )

find_library( libace-lib
              ace_ndk.z )

find_library(
    hilog-lib
    hilog_ndk.z)

add_library(egl SHARED
    napi_init.cpp
    manager/EglManager.cpp
    render/EglCore.cpp
    render/EglRender.cpp)
target_link_libraries(egl PUBLIC ${hilog-lib} ${EGL-lib} ${GLES-lib} ${libace-lib} libace_napi.z.so libc++.a)
3、2 ArkTS侧添加XComponent

XComponent有三个重要的属性:

  • id : 与XComponent组件为一一对应关系,不建议重复。通常开发者可以在native侧通过OH_NativeXComponent_GetXComponentId接口来获取对应的id从而绑定对应的XComponent。如果id重复,在native侧将无法对多个XComponent进行区分。
  • type:指定为surface。
  • libraryname:加载模块的名称,必须与在native侧Napi模块注册时nm_modname的名字一致。
interface XComponentAttrs {
  id: string;
  type: number;
  libraryname: string;
}

@Component
export struct GLComponent {
  @State message: string = 'Hello World';
  xComponentContext: object | undefined = undefined;
  xComponentAttrs: XComponentAttrs = {
    id: 'xcomponentId', // 与XComponent组件为一一对应关系,不建议重复。可以在native侧通过OH_NativeXComponent_GetXComponentId接口来获取对应的id从而绑定对应的XComponent。
    type: XComponentType.SURFACE,
    libraryname: 'egl' // 加载模块的名称,必须与在native侧Napi模块注册时模块名字一致。
  }

  build() {
    Row() {
      Column() {
        XComponent(this.xComponentAttrs)
          .onLoad(() => {
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}
3、2 获取OH_NativeXComponent

在ArkTS侧添加XComponent后,就可以在C++侧获取OH_NativeXComponent。

  • 调用napi_get_named_property函数解析参数。
  • 调用napi_unwrap函数获取OH_NativeXComponent对象。
  • 调用OH_NativeXComponent_GetXComponentId获取id,这个id就是在ArkTS侧给XComponent设置的id。
void EglManager::Export(napi_env env, napi_value exports) {
    napi_value exportInstance = nullptr;
    if (napi_ok != napi_get_named_property(env, exports, OH_NATIVE_XCOMPONENT_OBJ, &exportInstance)) {
        LOGE("解析参数出错");
        return;
    }
    OH_NativeXComponent *nativeXComponent = nullptr;
    if (napi_ok != napi_unwrap(env, exportInstance, reinterpret_cast<void **>(&nativeXComponent))) {
        LOGE("获取OH_NativeXComponent对象出错");
        return;
    }
    // 获取id
    char idStr[OH_XCOMPONENT_ID_LEN_MAX + 1] = {'\0'};
    uint64_t size = OH_XCOMPONENT_ID_LEN_MAX + 1;
    if (napi_ok != OH_NativeXComponent_GetXComponentId(nativeXComponent, idStr, &size)) {
        LOGE("获取XComponentId出错");
        return;
    }
    string id(idStr);
    if (nativeXComponent != nullptr) {
        setNativeXComponent(id, nativeXComponent);
        getRender(id);
        // 注册回调
        OH_NativeXComponent_RegisterCallback(nativeXComponent, &EglRender::callback);
    }
}
3、3注册回调

OH_NativeXComponent_Callback是个结构体,结构体里面是函数指针。

typedef struct OH_NativeXComponent_Callback {
    /** 当surface创建完成后回调 */
    void (*OnSurfaceCreated)(OH_NativeXComponent* component, void* window);
    /** 当surface发生改变时回调 */
    void (*OnSurfaceChanged)(OH_NativeXComponent* component, void* window);
    /** 当surface被销毁时回调 */
    void (*OnSurfaceDestroyed)(OH_NativeXComponent* component, void* window);
    /** 当触发触摸事件时调用 */
    void (*DispatchTouchEvent)(OH_NativeXComponent* component, void* window);
} OH_NativeXComponent_Callback;

调用OH_NativeXComponent_RegisterCallback函数注册回调,OH_NativeXComponent_RegisterCallback函数的第一个参数是OH_NativeXComponent对象,第二个参数OH_NativeXComponent_Callback结构体指针。

OH_NativeXComponent_RegisterCallback(nativeXComponent, &EglRender::callback);

当surface创建完成后回调OnSurfaceCreated函数,我们需要在OnSurfaceCreated函数里面搭建EGL环境,这样才能调用OpenGL ES的函数

四、EGL

OpenGL是跨平台接口,面对不同平台的差异,需要有一个介于平台设备与OpenGL之间的桥梁,EGL则是其中的桥梁。EGL是OpenGL ES和系统之间的通信接口,OpenGL ES的平台无关性正是借助EGL实现的,EGL屏蔽了不同平台的差异。主要包括以下几个类:

  • EGLDisplay一个抽象的系统显示类,用于操作设备窗口,加载OpenGL库。
  • EGLConfig,EGL配置,如rgba位数。
  • EGLSurface渲染缓存,一块内存空间,所有要渲染到屏幕上的图像数据,都要先缓存在EGLSurface上。
  • EGLContext上下文,用于存储OpenGL的绘制状态信息、数据。

要想调用OpenGL ES的函数,首先就得搭建EGL环境

4、1 搭建EGL环境

1、获取EGLDisplay对象:调用eglGetDisplay函数得到EGLDisplay,并加载OpenGL ES库。

EGLDisplay eglGetDisplay(EGLNativeDisplayType displayId);

2、初始化EGL连接:调用eglInitialize函数初始化,获取库的版本号。

GLBoolean eglInitialize(EGLDisplay display,   // EGLDisplay对象
              EGLint *majorVersion, // 主版本号
              EGLint *minorVersion) // 次版本号

3、确定渲染表面的配置信息:调用eglChooseConfig函数得到EGLConfig。

GLBoolean eglChooseConfig (EGLDisplay dpy, 
const EGLint *attrib_list, 
EGLConfig *configs, 
EGLint config_size, 
EGLint *num_config);

4、创建渲染表面:通过EGLDisplay和EGLConfig,调用eglCreateWindowSurface函数创建渲染表面,得到EGLSurface。

EGLSurface eglCreateWindowSurface(    
    EGLDisplay display,    //EGLDisplay对象
     EGLConfig config,    //EGLConfig对象
     NativeWindowType native_window,    //原生窗口
     EGLint const * attrib_list);    // attrib_list为Window Surface属性列表,可以为NULL

5、创建渲染上下文:通过EGLDisplay和EGLConfig,调用eglCreateContext函数创建渲染上下文,得到EGLContext。

EGLContext EGLAPIENTRY eglCreateContext(
        EGLDisplay display, 
        EGLConfig config,
        EGLContext share_context,
        const EGLint *attribList);

6、绑定上下文:通过eglMakeCurrent函数将EGLSurface、EGLContext、EGLDisplay三者绑定,接下来就可以使用OpenGL进行绘制了。

EGLBoolean EGLAPIENTRY eglMakeCurrent(
        EGLDisplay display,                     
        EGLSurface draw,
        EGLSurface read, EGLContext context);

7、交换缓冲:当用OpenGL绘制结束后,调用eglSwapBuffers函数交换前后缓冲,将绘制内容显示到屏幕上。

下面搭建EGL环境的完整代码。EglContextInit函数是在OnSurfaceCreated函数中调用的。

bool EglCore::EglContextInit(void *window, int width, int height) {
    this->width = width;
    this->height = height;
    // 获取EGLDisplay对象:调用eglGetDisplay函数得到EGLDisplay,并加载OpenGL ES库。
    eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
    if (eglDisplay == EGL_NO_DISPLAY) {
        LOGE("eGLDisplay获取失败");
        return false;
    }
    EGLint major;
    EGLint minor;
    // 初始化EGL连接:调用eglInitialize函数初始化,获取库的版本号。
    if (!eglInitialize(eglDisplay, &major, &minor)) {
        LOGE("eGLDisplay初始化失败");
        return false;
    }
    const EGLint maxConfigSize = 1;
    EGLint numConfigs;
    // 确定渲染表面的配置信息:调用eglChooseConfig函数得到EGLConfig。
    if (!eglChooseConfig(eglDisplay, ATTRIB_LIST, &eglConfig, maxConfigSize, &numConfigs)) {
       LOGE("eglConfig初始化失败");
        return false; 
    }
    eglWindow = reinterpret_cast<EGLNativeWindowType>(window);
    // 创建渲染表面:通过EGLDisplay和EGLConfig,调用eglCreateWindowSurface函数创建渲染表面,得到EGLSurface。
    eglSurface = eglCreateWindowSurface(eglDisplay, eglConfig, eglWindow, nullptr);
    if (nullptr == eglSurface) {
        LOGE("创建eGLSurface失败");
        return false;
    }
    // 创建渲染上下文:通过EGLDisplay和EGLConfig,调用eglCreateContext函数创建渲染上下文,得到EGLContext。
    eglContext = eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, CONTEXT_ATTRIBS);
    if (nullptr == eglContext) {
        LOGE("创建eglContext失败");
        return false;
    }
    // 绑定上下文:通过eglMakeCurrent函数将EGLSurface、EGLContext、EGLDisplay三者绑定,接下来就可以使用OpenGL进行绘制了。
    if (!eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
        LOGE("eglMakeCurrent失败");
        return false;
    }
    return true;
}

五、OpenGL ES坐标系

OpenGL ES两个重要坐标系分别是标准坐标系和屏幕坐标系。

5、1标准坐标系
  • 屏幕中心是原点。
  • 横纵坐标的范围在-1到1之间。
    1
5、2屏幕坐标系
  • 屏幕左上角是原点。
  • 单位是像素。
  • OpenGL会把标准坐标系转换成屏幕坐标系。 2
5、3标准坐标系转换成屏幕坐标系
  • 将标准坐标系的顶点加1,标准坐标系的顶点范围是-1到1之间,加1后,顶点范围就是0到2之间。
  • 每个顶点乘以屏幕的宽高。
  • 上面两步可以通过变换矩阵完成。

六、着色器

着色器是运行在GPU上的小程序,分为顶点着色器和片元着色器。着色器允许开发者自定义渲染过程,提高了灵活度。可以充分利用GPU的并行计算能力,提高渲染速度。可以通过变成编程,灵活的控制GPU。

6、1顶点着色器

顶点着色器用于处理几何图形的顶点,对应标准坐标系,标准坐标系的原点是屏幕中心。

6、2片元着色器

片元着色器用于处理像素颜色和纹理,为每个像素设置不同的颜色,对应屏幕坐标系,屏幕坐标系的左上角为原点。

6、3着色器语言GLSL

OpenGL 2.0加入了可编程渲染管线,可以更加灵活的控制渲染。但也因此需要学习多一门针对GPU的编程语言,语法与C语言类似,名为GLSL。

6、3、1顶点着色器语言

下面是顶点着色器的代码,我们可以看到熟悉的主函数,在主函数中,将变量vPosition赋值给gl_Position。其含义是将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,这样确定了几何图形的顶点坐标。vPosition被attribute修饰,attribute用在顶点着色器中,相当于Java/C的局部变量,一般用来传递的是顶点坐标和纹理坐标。uniform修饰的是统一变量,相当于Java/C的全局变量,传的通常是矩阵。varying是由顶点着色器传递到片元着色器的。

attribute vec4 vPosition;
void main() {
  // 将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,确定几何图形的顶点坐标。
  gl_Position = vPosition;
} 

下面的顶点坐标就是下图所对应的坐标,这就是一个三角形的坐标。再次强调下,下面的顶点坐标对应的是标准坐标系,标准坐标系的原点是屏幕中心。这个顶点坐标会传给顶点着色器的vPosition变量,vPosition变量再把顶点坐标传给内建变量gl_Position,这样就确定了几何图形的顶点。

const GLfloat rectangleVertices[] = {
        0.0f, 0.5f, 0.0f,
        -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f
    };

3

6、3、2片元着色器语言

下面是片元着色器的代码,片元装饰器用于设置颜色。第一行代码指定精度,精度分别有lowp低精度,mediump中精度,highp高精度。代码第二行定义vColor变量,它是uniform类型,vec4表示有4个元素的向量,颜色是由ARGB组成,需要4个元素。gl_FragColor是内建变量,gl_FragColor用于确定每个像素的颜色,这里将vColor赋值给gl_FragColor,vColor变量将会通过程序传递过来。

precision mediump float;
uniform vec4 vColor;
void main() {
  // 将vColor赋值给内建变量gl_FragColor,确定几何图形的颜色
  gl_FragColor = vColor;
} 

下面的代码定义了一个有4个元素的数组,这四个元素分别代表颜色RGBA,也就是红绿蓝和透明度。这个数组就会传递给片元着色器的vColor变量,vColor变量再把颜色值传给内建变量gl_FragColor,这样就确定几何图形的颜色。

// 颜色值#7E8FFB
const GLfloat DRAW_COLOR[] = { 126.0f / 255, 143.0f / 255, 251.0f / 255, 1.0f };

七、编译和链接着色器

  • 调用glCreateShader创建着色器。
  • 调用glShaderSource绑定源码。
  • 调用glCompileShader编译着色器。
  • 调用glCreateProgram创建程序。
  • 调用glAttachShader绑定着色器。
  • 调用glLinkProgram链接程序。
/**
 * 创建程序
 * 
 * @param vertexShader 顶点着色器源码
 * @param fragmentShader 片元着色器源码
 * @return
 */
GLuint EglCore::createProgram(const char* vertexShader, const char* fragmentShader) {
    GLuint vertex = loadShader(GL_VERTEX_SHADER, vertexShader);
    if (vertex == PROGRAM_ERROR) {
        LOGE("编译顶点着色器失败");
        return PROGRAM_ERROR;
    }
    GLuint fragment = loadShader(GL_FRAGMENT_SHADER, fragmentShader);
    if (fragment == PROGRAM_ERROR) {
        LOGE("编译片元着色器失败");
        return PROGRAM_ERROR;
    }
    GLuint program = glCreateProgram();
    if (program == PROGRAM_ERROR) {
        LOGE("创建程序失败");
        return PROGRAM_ERROR;
    }
    // 绑定着色器
    glAttachShader(program, vertex);
    glAttachShader(program, fragment);
    // 链接程序
    glLinkProgram(program);
    GLint link;
    glGetProgramiv(program, GL_LINK_STATUS, &link);
    if (PROGRAM_ERROR != link) {
        glDeleteShader(vertex);
        glDeleteShader(fragment);
        // 链接程序成功
        return program;
    }
    // 链接程序失败
    glDeleteShader(vertex);
    glDeleteShader(fragment);
    glDeleteProgram(program);
    return PROGRAM_ERROR;
}
    
GLuint EglCore::loadShader(GLenum type, const char* shaderSrc) {
    // 创建着色器
    GLuint shader = glCreateShader(type);
    // 绑定做得起源码
    glShaderSource(shader, 1, &shaderSrc, nullptr);
    // 编译着色器
    glCompileShader(shader);
    GLint compiled;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
    if (0 != compiled) {
        return shader;
    }
    glDeleteShader(shader);
    return PROGRAM_ERROR;
}

八、绘制三角形

8、1 清屏,设置屏幕颜色
  • 调用glViewport设置窗口大小
  • 调用glClearColor清屏,将屏幕颜色设置为黑色。
  • 调用glClear清除颜色缓冲
void EglCore::prepareDraw() {
    // 设置窗口大小
    glViewport(DEFAULT_X_POSITION, DEFAULT_X_POSITION, width, height);
    // 清屏,将屏幕颜色设置为黑色
    glClearColor(GL_RED_DEFAULT, GL_GREEN_DEFAULT, GL_BLUE_DEFAULT, GL_ALPHA_DEFAULT);
    // 清除颜色缓冲
    glClear(GL_COLOR_BUFFER_BIT);
}
8、2 绘制
  • 调用glUseProgram函数使用程序。
  • 调用glGetAttribLocation函数获取顶点着色器中定义的属性。
  • 调用glEnableVertexAttribArray函数启用顶点数组。
  • 调用glVertexAttribPointer函数向顶点着色器传递顶点数组。
  • 调用glGetUniformLocation函数获取片元着色器中定义的变量。
  • 调用glUniform4fv函数向片元着色器传递颜色。
  • 调用glDrawArrays函数绘制三角形。
  • 调用glDisableVertexAttribArray函数释放属性变量。
  • 调用eglSwapBuffers函数交换前后缓冲,将绘制内容显示到屏幕上。
void EglCore::draw() {
    prepareDraw();
    // 使用程序
    glUseProgram(program);
    // 获取顶点着色器中定义的属性
    LOGD("绘制");
    GLint positionHandler = glGetAttribLocation(program, POSITION_NAME);
    // 启用顶点数组
    glEnableVertexAttribArray(positionHandler);
    /*
     * 向顶点着色器传递顶点数组
     * 第一个参数是属性变量的下标
     * 第二个参数是顶点坐标的个数,我们在定义顶点坐标的时候,使用了空间坐标系,每个坐标使用x,y,z,所以第二个参数为3
     * 第三个参数是数据的类型
     * 第四个参数是否进行了归一化处理,这里写false
     * 第五个参数是跨度,这里是0,没有跨度
     * 第六个参数是要传递的顶点数据
     */
    glVertexAttribPointer(positionHandler, 3, GL_FLOAT, false, 0, rectangleVertices);
    // 获取片元着色器中定义的变量
    GLint colorHandler = glGetUniformLocation(program, COLOR_NAME);
    /*
     * 向片元着色器传递颜色
     * 第一个参数是变量的下标
     * 第二个参数是数据的数量,由于将所有的像素都设置成一样的颜色,所以第二个参数是1
     * 第三个参数是颜色
     */
    glUniform4fv(colorHandler, 1, DRAW_COLOR);
    // 绘制三角形
    GLsizei count = sizeof(rectangleVertices) / sizeof(rectangleVertices[0]) / 3;
    /*
     * 绘制三角形
     * 第一个参数是绘制的图形
     * 第二个参数是从哪里开始读取,这里从0开始读取
     * 第三个参数是顶点的数量
     */
    glDrawArrays(GL_TRIANGLES, 0, count);
    // 释放属性变量
    glDisableVertexAttribArray(positionHandler);
    finishDraw();
}

bool EglCore::finishDraw() {
    glFlush();
    glFinish();
    // 交换前后缓冲,将绘制内容显示到屏幕上
    return eglSwapBuffers(eglDisplay, eglSurface);
}

看下绘制出来的结果吧。 5

九、总结

真是不容易呀,如果大家能坚持看到文章末尾,希望大家能够熟悉OpenGL绘制流程。我们用大量的代码和大量的篇幅来介绍使用OpenGL绘制三角形。可能有人会问,使用Canvas可以轻轻松松的绘制出三角形,为什么要用大量的代码和大量的篇幅来介绍使用OpenGL绘制一个简简单单的三角形?
  Canvas绘制其实上使用的是CPU渲染。大部分情况下,CPU渲染性能不差。但是视频渲染、视频编辑、视频特效、游戏引擎等使用CPU渲染,性能非常差,GPU渲染会大大提高渲染速度,OpenGL就是用来操作GPU的。
  虽然OpenGL代码量大,但是大部分的代码是不变的,只需要写一次。自定义XComponent,注册XComponent回调,搭建EGL环境,编译和链接着色器等等,这些代码都是不变的。最重要的还是顶点着色器和片元着色器,只要OpenGL框架代码搭建起来了,大部分情况下,我们还是在写下面的代码。

attribute vec4 vPosition;
void main() {
  // 将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,确定几何图形的顶点坐标。
  gl_Position = vPosition;
} 

precision mediump float;
uniform vec4 vColor;
void main() {
  // 将vColor赋值给内建变量gl_FragColor,确定几何图形的颜色
  gl_FragColor = vColor;
} 


最后呢,很多开发朋友不知道需要学习那些鸿蒙技术?鸿蒙开发岗位需要掌握那些核心技术点?为此鸿蒙的开发学习必须要系统性的进行。

而网上有关鸿蒙的开发资料非常的少,假如你想学好鸿蒙的应用开发与系统底层开发。你可以参考这份资料,少走很多弯路,节省没必要的麻烦。由两位前阿里高级研发工程师联合打造的《鸿蒙NEXT星河版OpenHarmony开发文档》里面内容包含了(ArkTS、ArkUI开发组件、Stage模型、多端部署、分布式应用开发、音频、视频、WebGL、OpenHarmony多媒体技术、Napi组件、OpenHarmony内核、Harmony南向开发、鸿蒙项目实战等等)鸿蒙(Harmony NEXT)技术知识点

如果你是一名Android、Java、前端等等开发人员,想要转入鸿蒙方向发展。可以直接领取这份资料辅助你的学习。下面是鸿蒙开发的学习路线图。

​​​​1

高清完整版请点击《鸿蒙NEXT星河版开发学习文档》

针对鸿蒙成长路线打造的鸿蒙学习文档。话不多说,我们直接看详细资料鸿蒙(OpenHarmony )学习手册(共计1236页)与鸿蒙(OpenHarmony )开发入门教学视频,帮助大家在技术的道路上更进一步。

《鸿蒙 (OpenHarmony)开发学习视频》

《鸿蒙生态应用开发V2.0白皮书》

《鸿蒙 (OpenHarmony)开发基础到实战手册》

《鸿蒙开发基础》

《鸿蒙开发进阶》

《鸿蒙开发实战》
在这里插入图片描述

获取这份鸿蒙星河版学习资料,请点击→《鸿蒙NEXT星河版开发学习文档》

总结

鸿蒙—作为国家主力推送的国产操作系统。部分的高校已经取消了安卓课程,从而开设鸿蒙课程;企业纷纷跟进启动了鸿蒙研发。

并且鸿蒙是完全具备无与伦比的机遇和潜力的;预计到年底将有 5,000 款的应用完成原生鸿蒙开发,未来将会支持 50 万款的应用。那么这么多的应用需要开发,也就意味着需要有更多的鸿蒙人才。鸿蒙开发工程师也将会迎来爆发式的增长,学习鸿蒙势在必行!

  • 14
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值