OpenGL ES 入门

匿名 (未验证) 提交于 2019-12-02 23:43:01

记录一下 OpenGL ES Android 开发的入门教程。逻辑性可能不那么强,想到哪写到哪。也可能自己的一些理解有误。

参考资料:

LearnOpenGL CN
Android官方文档
《OpenGL ES应用开发实践指南Android卷》
《OpenGL ES 3.0 编程指南第2版》

目前android 4.3或以上支持opengles 3.0,但目前很多运行android 4.3系统的硬件能支持opengles 3.0的也是非常少的。不过,opengles 3.0是向后兼容的,当程序发现硬件不支持opengles 3.0时则会自动调用opengles 2.0的API。Andorid 中使用 OpenGLES 有两种方式,一种是基于Android框架API, 另一种是基于 Native Development Kit(NDK)使用 OpenGL。本文介绍Android框架接口。

本文写一个最基本的三角形绘制,来说明一下 OpenGL ES 的基本流程,以及注意点。

<!-- Tell the system this app requires OpenGL ES 3.0. --> <uses-feature android:glEsVersion="0x00030000" android:required="true" />

如果程序中使用了纹理压缩的话,还需进行如下声明,以防止不支持这些压缩格式的设备尝试运行程序。

<supports-gl-texture android:name="GL_OES_compressed_ETC1_RGB8_texture" /> <supports-gl-texture android:name="GL_OES_compressed_paletted_texture" />

MainActivity.java 代码:

package com.sharpcj.openglesdemo;  import android.app.ActivityManager; import android.content.Context; import android.content.pm.ConfigurationInfo; import android.opengl.GLSurfaceView; import android.os.Build; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.util.Log;  public class MainActivity extends AppCompatActivity {     private static final String TAG = MainActivity.class.getSimpleName();      private GLSurfaceView mGlSurfaceView;     private boolean mRendererSet;      @Override     protected void onCreate(Bundle savedInstanceState) {         super.onCreate(savedInstanceState); //        setContentView(R.layout.activity_main);         if (!checkGlEsSupport(this)) {             Log.d(TAG, "Device is not support OpenGL ES 2");             return;         }         mGlSurfaceView = new GLSurfaceView(this);         mGlSurfaceView.setEGLContextClientVersion(2);         mGlSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0);         mGlSurfaceView.setRenderer(new MyRenderer(this));         setContentView(mGlSurfaceView);         mRendererSet = true;     }      @Override     protected void onPause() {         super.onPause();         if (mRendererSet) {             mGlSurfaceView.onPause();         }     }      @Override     protected void onResume() {         super.onResume();         if (mRendererSet) {             mGlSurfaceView.onResume();         }     }      /**      * 检查设备是否支持 OpenGLEs 2.0      *      * @param context 上下文环境      * @return 返回设备是否支持 OpenGLEs 2.0      */     public boolean checkGlEsSupport(Context context) {         final ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);         final ConfigurationInfo configurationInfo = activityManager.getDeviceConfigurationInfo();         final boolean supportGlEs2 = configurationInfo.reqGlEsVersion >= 0x20000                 || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1                 && (Build.FINGERPRINT.startsWith("generic")                 || Build.FINGERPRINT.startsWith("unknown")                 || Build.MODEL.contains("google_sdk")                 || Build.MODEL.contains("Emulator")                 || Build.MODEL.contains("Andorid SDK built for x86")));         return supportGlEs2;     } }

关键步骤:

  • 创建一个 GLSurfaceView 对象
  • 给GLSurfaceView 对象设置 Renderer 对象
  • 调用 setContentView() 方法,传入 GLSurfaceView 对象。

创建一个类,实现 GLSurfaceView.Renderer 接口,并实现其中的关键方法

package com.sharpcj.openglesdemo;  import android.content.Context; import android.opengl.GLSurfaceView;  import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.opengles.GL10;  import static android.opengl.GLES30.*;   public class MyRenderer implements GLSurfaceView.Renderer {     private Context mContext;     private MyTriangle mTriangle;      public MyRenderer(Context mContext) {         this.mContext = mContext;     }      @Override     public void onSurfaceCreated(GL10 gl, EGLConfig config) {         glClearColor(1.0f, 1.0f, 1.0f, 1.0f);         mTriangle = new MyTriangle(mContext);     }      @Override     public void onSurfaceChanged(GL10 gl, int width, int height) {         glViewport(0, 0, width, height);     }      @Override     public void onDrawFrame(GL10 gl) {         glClear(GL_COLOR_BUFFER_BIT);         mTriangle.draw();     } }

三个关键方法:

  • onSurfaceCreated() - 在View的OpenGL环境被创建的时候调用。
  • onSurfaceChanged() - 如果视图的几何形状发生变化(例如,当设备的屏幕方向改变时),则调用此方法。
  • onDrawFrame() - 每一次View的重绘都会调用

glViewport(0, 0, width, height); 用于设置视口。
glCrearColor(1.0f, 1.0f, 1.0f, 1.0f) 方法用指定颜色(这里是白色)清空屏幕。
在 onDrawFrame 中调用 glClearColor(GL_COLOR_BUFFER_BIT) ,擦除屏幕现有的绘制,并用之前的颜色清空屏幕。 该方法中一定要绘制一些东西,即便只是清空屏幕,因为该方法调用后会交换缓冲区,并显示在屏幕上,否则可能会出现闪烁。该例子中将具体的绘制封装在了 Triangle 类中的 draw 方法中了。
注意:在 windows 版的 OpenGL 中,需要手动调用 glfwSwapBuffers(window) 来交换缓冲区。

创建 MyTriangle.java 类:

package com.sharpcj.openglesdemo;  import android.content.Context;  import com.sharpcj.openglesdemo.util.ShaderHelper; import com.sharpcj.openglesdemo.util.TextResourceReader;  import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.FloatBuffer;  import static android.opengl.GLES30.*;  public class MyTriangle {     private final FloatBuffer mVertexBuffer;      static final int COORDS_PER_VERTEX = 3;  // number of coordinates per vertex in this array     static final int COLOR_PER_VERTEX = 3;  // number of coordinates per vertex in this array      static float triangleCoords[] = {   // in counterclockwise order:             0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f,     // top             -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,   // bottom left             0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f     // bottom right     };       private Context mContext;     private int mProgram;      public MyTriangle(Context context) {         mContext = context;         // initialize vertex byte buffer for shape coordinates         mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();         mVertexBuffer.put(triangleCoords);  // add the coordinates to the FloatBuffer         mVertexBuffer.position(0);  // set the buffer to read the first coordinate          String vertexShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_vertex_glsl);         String fragmentShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_fragment_glsl);          int vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode);         int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode);          mProgram = ShaderHelper.linkProgram(vertexShader, fragmentShader);     }      public void draw() {         if (!ShaderHelper.validateProgram(mProgram)) {             glDeleteProgram(mProgram);             return;         }         glUseProgram(mProgram);  // Add program to OpenGL ES environment  //        int aPos = glGetAttribLocation(mProgram, "aPos");  // get handle to vertex shader's vPosition member         mVertexBuffer.position(0);         glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data         glEnableVertexAttribArray(0);  // Enable a handle to the triangle vertices  //        int aColor = glGetAttribLocation(mProgram, "aColor");         mVertexBuffer.position(3);         glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data         glEnableVertexAttribArray(1);          // Draw the triangle         glDrawArrays(GL_TRIANGLES, 0, 3);      } }

在该类中,我们使用了,两个工具类:
TextResourceReader.java, 用于读取文件的类容,返回一个字符串,准确说,它与 OpenGL 本身没有关系。

package com.sharpcj.openglesdemo.util;  import android.content.Context; import android.content.res.Resources; import android.util.Log;  import java.io.BufferedReader; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader;  public class TextResourceReader {      private static String TAG = "TextResourceReader";      public static String readTextFileFromResource(Context context, int resourceId) {         StringBuilder body = new StringBuilder();         InputStream inputStream = null;         InputStreamReader inputStreamReader = null;         BufferedReader bufferedReader = null;         try {             inputStream = context.getResources().openRawResource(resourceId);             inputStreamReader = new InputStreamReader(inputStream);             bufferedReader = new BufferedReader(inputStreamReader);             String nextLine;             while ((nextLine = bufferedReader.readLine()) != null) {                 body.append(nextLine);                 body.append("\n");             }         } catch (IOException e) {             throw new RuntimeException("Could not open resource: " + resourceId, e);         } catch (Resources.NotFoundException nfe) {             throw new RuntimeException("Resource not found: " + resourceId, nfe);         } finally {             closeStream(inputStream);             closeStream(inputStreamReader);             closeStream(bufferedReader);         }         return body.toString();     }      private static void closeStream(Closeable c) {         if (c != null) {             try {                 c.close();             } catch (IOException e) {                 Log.e(TAG, e.getMessage());             }         }     } }

ShaderHelper.java 着色器的工具类,这个跟 OpenGL 就有非常大的关系了。

package com.sharpcj.openglesdemo.util;  import android.util.Log;  import static android.opengl.GLES30.*;  public class ShaderHelper {     private static final String TAG = "ShaderHelper";      public static int compileVertexShader(String shaderCode) {         return compileShader(GL_VERTEX_SHADER, shaderCode);     }      public static int compileFragmentShader(String shaderCode) {         return compileShader(GL_FRAGMENT_SHADER, shaderCode);     }      private static int compileShader(int type, String shaderCode) {         final int shaderObjectId = glCreateShader(type);         if (shaderObjectId == 0) {             Log.w(TAG, "could not create new shader.");             return 0;         }         glShaderSource(shaderObjectId, shaderCode);         glCompileShader(shaderObjectId);          final int[] compileStatus = new int[1];         glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0);         /*Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "                 + glGetShaderInfoLog(shaderObjectId));*/          if (compileStatus[0] == 0) {             glDeleteShader(shaderObjectId);             Log.w(TAG, "Compilation of shader failed.");             return 0;         }         return shaderObjectId;     }      public static int linkProgram(int vertexShaderId, int fragmentShaderId) {         final int programObjectId = glCreateProgram();         if (programObjectId == 0) {             Log.w(TAG, "could not create new program");             return 0;         }         glAttachShader(programObjectId, vertexShaderId);         glAttachShader(programObjectId, fragmentShaderId);         glLinkProgram(programObjectId);         final int[] linkStatus = new int[1];         glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);         /*Log.d(TAG, "Results of linking program: \n"                 + glGetProgramInfoLog(programObjectId));*/         if (linkStatus[0] == 0) {             glDeleteProgram(programObjectId);             Log.w(TAG, "Linking of program failed");             return 0;         }         return programObjectId;     }      public static boolean validateProgram(int programId) {         glValidateProgram(programId);         final int[] validateStatus = new int[1];         glGetProgramiv(programId, GL_VALIDATE_STATUS, validateStatus, 0);         /*Log.d(TAG, "Results of validating program: " + validateStatus[0]                 + "\n Log: " + glGetProgramInfoLog(programId));*/         return validateStatus[0] != 0;     } }

着色器是 OpenGL 里面非常重要的概念,这里我先把代码贴上来,然后来讲流程。
在 res/raw 文件夹下,我们创建了两个着色器文件。
顶点着色器,simple_vertex_shader.glsl

#version 330  layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0 layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1  out vec3 vColor; // 向片段着色器输出一个颜色  void main() {     gl_Position = vec4(aPos.xyz, 1.0);     vColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色 }

片段着色器, simple_fragment_shader.glsl

#version 330 precision mediump float;  in vec3 vColor;  out vec4 FragColor;  void main() {     FragColor = vec4(vColor, 1.0); } 

全部的代码就只这样了,具体绘制过程下面来说。运行程序,我们看到效果如下:


一张图说明 OpenGL 渲染过程:

我们看 MyTriangle.java 这个类。
要绘制三角形,我们肯定要定义三角形的顶点坐标和颜色。(废话,不然GPU怎么知道用什么颜色绘制在哪里)。
首先我们定义了一个 float 型数组:

static float triangleCoords[] = {   // in counterclockwise order:             0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f,     // top             -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,   // bottom left             0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f     // bottom right     };

注意:这个数组中,定义了 top, bottom left, bottom right 三个点。每个点包含六个数据,前三个数表示顶点坐标,后三个点表示颜色的 RGB 值。

可能注意到了,因为我们这里绘制最简单的平面二维图像,Z 轴坐标都为 0 ,屏幕中的 X, Y 坐标点都是在(-1,1)的范围。我们没有对视口做任何变换,设置的默认视口,此时的坐标系统是以屏幕正中心为坐标原点。 屏幕最左为 X 轴 -1 , 屏幕最右为 X 轴 +1。同理,屏幕最下方为 Y 轴 -1, 屏幕最上方为 Y 轴 +1。OpenGL 坐标系统使用的是右手坐标系,Z 轴正方向为垂直屏幕向外。

mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); mVertexBuffer.put(triangleCoords);

这一行代码,作用是将数据从 java 堆复制到本地堆。我们知道,在 java 虚拟机内存模型中,数组存在 java 堆中,受 JVM 垃圾回收机制影响,可能会被回收掉。所以我们要将数据复制到本地堆。
首先调用 ByteBuffer.allocateDirect() 分配一块本地内存,一个 float 类型的数字占 4 个字节,所以分配的内存大小为 triangleCoords.length * 4 。
调用 order() 指定字节缓冲区中的排列顺序, 传入 ByteOrder.nativeOrder() 保证作为一个平台,使用相同的排序顺序。
调用 asFloatBuffer() 可以得到一个反映底层字节的 FloatBuffer 类的实例。
最后调用 put(triangleCoords) 把数据从 Android 虚拟机堆内存中复制到本地内存。

接下来,通过 TextResourceReader 工具类,读取顶点着色器和片段着色器文件的的内容。

String vertexShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_vertex_shader); String fragmentShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_fragment_shader);

然后通过 ShaderHelper 工具类编译着色器。然后链接到程序。

int vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode); int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode);  mProgram = ShaderHelper.linkProgram(vertexShader, fragmentShader); ShaderHelper.validateProgram(mProgram);

着色器是一个运行在 GPU 上的小程序。着色器的文件其实定义了变量,并且包含 main 函数。关于着色器的详细教程,请查阅:(LearnOpenGL CN 中的着色器教程)[https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/]

我这里记录一下,着色器的编译过程:

int shaderObjectId = glCreateShader(type);`    

创建一个着色器,并返回着色器的句柄(类似java中的引用),如果返回了 0 ,说明创建失败。GLES 中定义了常量,GL_VERTEX_SHADERGL_FRAGMENT_SHADER 作为参数,分别创建顶点着色器和片段着色器。

编译着色器,

glShaderSource(shaderObjectId, shaderCode); glCompileShader(shaderObjectId);

下面的代码,用于获取编译着色器的状态结果。

final int[] compileStatus = new int[1]; glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0); Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "         + glGetShaderInfoLog(shaderObjectId));  if (compileStatus[0] == 0) {     glDeleteShader(shaderObjectId);     Log.w(TAG, "Compilation of shader failed.");     return 0; }

亲测上面的程序在我手上真机可以正常运行,在 genymotion 模拟器中运行报了如下错误:

JNI DETECTED ERROR IN APPLICATION: input is not valid Modified UTF-8: illegal start byte 0xfe

网上搜索了一下,这个异常是由于Java虚拟机内部的dalvik/vm/CheckJni.c中的checkUtfString函数抛出的,并且JVM的这个接口明确是不支持四个字节的UTF8字符。因此需要在调用函数之前,对接口传入的字符串进行过滤,过滤函数,可以上网搜到,这不是本文重点,所以我把这个 log 注释掉了

Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "         + glGetShaderInfoLog(shaderObjectId));

编译完着色器之后,需要将着色器连接到程序才能使用。

int programObjectId = glCreateProgram();

创建一个 program 对象,并返回句柄,如果返回了 0 ,说明创建失败。

glAttachShader(programObjectId, vertexShaderId); glAttachShader(programObjectId, fragmentShaderId); glLinkProgram(programObjectId);

将顶点着色器个片段着色器链接到 program 对象。下面的代码用于获取链接的状态结果:

final int[] linkStatus = new int[1]; glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0); /*Log.d(TAG, "Results of linking program: \n"         + glGetProgramInfoLog(programObjectId));*/ if (linkStatus[0] == 0) {     glDeleteProgram(programObjectId);     Log.w(TAG, "Linking of program failed");     return 0; }

在使用 program 对象之前,我们还做了有效性判断:

glValidateProgram(programId); final int[] validateStatus = new int[1]; glGetProgramiv(programId, GL_VALIDATE_STATUS, validateStatus, 0); /*Log.d(TAG, "Results of validating program: " + validateStatus[0]                 + "\n Log: " + glGetProgramInfoLog(programId));*/

如果 validateStatus[0] == 0 , 则无效。

首先调用glUseProgram(mProgram) 将 program 对象添加到 OpenGL ES 的绘制环境。

看如下代码:

mVertexData.position(0); // 移动指针到 0,表示从开头开始读取  // 告诉 OpenGL, 可以在缓冲区中找到 a_Position 对应的数据 int aPos = glGetAttribLocation(mProgram, "aPos");  glVertexAttribPointer(aPos, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data glEnableVertexAttribArray(aPos);  int aColor = glGetUniformLocation(mProgram, "aColor"); glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data glEnableVertexAttribArray(aColor); 

在 OpenGL ES 2.0 中,我们通过如上代码,使用数据。调用 glGetAttribLocation() 方法,找到顶点和颜色对应的数据位置,第一个参数是 program 对象,第二个参数是着色器中的入参参数名。
然后调用 glVertexAttribPointer() 方法
参数如下(图片截取自《OpenGL ES应用开发实践指南Android卷》):

最后调用glEnableVertexAttribArray(aPos); 使 OpenGL 能使用这个数据。

但是你发现,我们上面给的代码中并没有调用 glGetAttribLocation() 方法寻找位置,这是因为,我使用的 OpenGLES 3.0 ,在 OpenGL ES 3.0 中,着色器代码中,新增了 layout(location = 0) 类似的语法支持。

#version 330  layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0 layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1  out vec3 vColor; // 向片段着色器输出一个颜色  void main() {     gl_Position = vec4(aPos.xyz, 1.0);     vColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色 }

这里已经指明了属性在顶点数组中对应的位置,所以在代码中,可以直接使用 0 和 1 来表示位置。

最后调用 glDrawArrays(GL_TRIANGLES, 0, 3) 绘制出一个三角形。
glDrawArrays() 方法第一个参数指定绘制的类型, OpenGLES 中定义了一些常量,通常有 GL_TRIANGLES , GL_POINTS, GL_LINES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN 等等类型,具体每种类型代表的意思可以查阅API 文档。

VAO : 顶点数组对象
VBO :顶点缓冲对象

通过使用 VAO 和 VBO ,可以建立 VAO 与 VBO 的索引对应关系,一次写入数据之后,每次使用只需要调用 glBindVertexArray 方法即可,避免重复进行数据的复制, 大大提高绘制效率。

int[] VBO = new int[2]; int[] VAO = new int[2];  glGenVertexArrays(0, VAO, 0); glGenBuffers(0, VBO, 0); glBindVertexArray(VAO[0]); glBindBuffer(GL_ARRAY_BUFFER, VBO[0]); glBufferData(GL_ARRAY_BUFFER, triangleCoords.length * 4, mVertexBuffer, GL_STATIC_DRAW);  glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, 0); glEnableVertexAttribArray(0); glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, COORDS_PER_VERTEX * 4); glEnableVertexAttribArray(1); glBindVertexArray(VAO[0]);  glGenVertexArrays(1, VAO, 0); glGenBuffers(1, VBO, 0); glBindVertexArray(VAO[1]); glBindBuffer(GL_ARRAY_BUFFER, VBO[1]); glBufferData(GL_ARRAY_BUFFER, triangleCoords.length * 4, mVertexBuffer2, GL_STATIC_DRAW);  glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, 0); glEnableVertexAttribArray(0); glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, COORDS_PER_VERTEX * 4); glEnableVertexAttribArray(1); glBindVertexArray(VAO[1]);   glBindVertexArray(VAO[0]); glDrawArrays(GL_TRIANGLES, 0, 3);  glBindVertexArray(VAO[1]); glDrawArrays(GL_TRIANGLES, 0, 3);
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!