使用 OpenGL 实例化绘制高性能绘制字符画(带源码)

很久很久以前无意中看到别人的项目可以把一张图片转换字符画,我觉得非常有意思,然后去了解它的原理,原理非常简单,图片的每一个像素都对应一个亮度值,就像我们以前的黑白电视,没有其他颜色只有黑和白,亮度值最高时就是纯白,亮度值最低就是纯黑,在纯白和纯黑之间又定义了非常多的灰色,亮度值愈小这些灰色越接近纯黑,亮度值越大越接近纯白,我们就用这些小点来描述图片中的亮度值,其实不同的字符之间也有它的亮度值,比如亮度最高就是 空格字符,亮度最低就是 @ 字符,通过这样的映射我们就可以把一张图片中的像素映射到不同的字符上,然后就可以构成一张非常有意思的字符画了。

当我把一张图片转换成字符画后,我就在想:既然视频也是由一帧一帧的图片构成,那么这个字符画也可以用到视频上。我找到了很多关于图片转字符画的工具,但是就是没有发现转换视频的。没有那就自己尝试写一个吧,理想很丰满现实很骨感,主要的问题在于视频对于一帧图片渲染的性能要求非常高,我自己写的远远没有达到这个性能要求,然后这个想法就搁置了。

工作多年后了解到了 OpenGL,它天生是处理图片的好手,又想起了当年没有完成的遗憾,这次咱是有备而来,通过 OpenGL 就能够达到这个性能要求。

字符画滤镜的效果图如下,源码地址: tMediaPlayer

之前还介绍过Android 平台美颜实现,这篇文章简单介绍了使用 OpenGL 是如何渲染一张图片的,不会的同学可以先看看,不然文章后面的内容和听天书差不多,我用的是 FFmpeg 来解码视频,这里还涉及到使用 OpenGL 来渲染 YUV420sp (包括 NV12NV21) 和 YUV420p 的图片,这些内容后面有时间再介绍吧。滤镜的处理流程是播放器已经把视频的图片渲染到纹理上了,然后我们通过修改这纹理中的数据来达到我们想要的效果,然后返回一个新的纹理,最终渲染到屏幕上。

离屏渲染

在我们开始做字符画滤镜前我们需要先学习学习离屏渲染,默认我们使用 OpenGL 是渲染到屏幕上的,使用 GLSurfaceView 的时候 Android 已经帮我们做了这个操作,会绑定屏幕相关的帧缓冲,后续我们的渲染操作也是渲染到屏幕上的。

我们可以改变默认的帧缓冲,让其直接渲染到一个纹理上,通常常见的纹理中的 RGBA 数据我们都是从图片中获取,通过这种方式我们还可以自己绘制。

初始化帧缓冲

Kotlin 复制代码
internal fun glGenFrameBuffer(): Int {
    val buffer = newGlIntBuffer()
    GLES30.glGenFramebuffers(1, buffer)
    buffer.position(0)
    return buffer.get()
}

// 生成帧缓冲
val fbo = glGenFrameBuffer()
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, fbo)

// 生成帧缓冲的附件纹理
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, outputTexId)
// 纹理的数据设置为空,大小为图片大小, 光申请内存不填充,渲染时填充
GLES30.glTexImage2D(
    GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA,
    outputTexWidth, outputTexHeight, 0, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, null)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)

// 为帧缓冲添加纹理附件(颜色)
GLES30.glFramebufferTexture2D(
    GLES30.GL_FRAMEBUFFER, GLES30.GL_COLOR_ATTACHMENT0,
    GLES30.GL_TEXTURE_2D, outputTexId, 0)

val frameBufferStatus = GLES30.glCheckFramebufferStatus(GLES30.GL_FRAMEBUFFER)
if (frameBufferStatus != GLES30.GL_FRAMEBUFFER_COMPLETE) {
    MediaLog.e(TAG, "Create frame buffer fail: $frameBufferStatus")
    return
}

首先生成一个帧缓冲,绑定生成的帧缓冲,绑定帧缓冲的纹理,设置纹理属性,将上面的纹理添加至我们生成的帧缓冲颜色附件 (还有其他的附件类型,我们只用到颜色),添加完成后检查纹理的状态,没有错,进行下一步。

设置窗口信息

Kotlin 复制代码
// 获取当前的 view port, 离屏渲染完成后,需要还原
val lastViewPort = IntArray(4)
GLES30.glGetIntegerv(GLES30.GL_VIEWPORT, lastViewPort, 0)

// 创建离屏渲染的 view port
GLES30.glViewport(0, 0, outputTexWidth, outputTexHeight)
GLES30.glClearColor(0.0f, 0.0f, 0.0f, 0.0f)
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)

首先获取当前窗口的大小,供离屏渲染完后还原,设置礼品渲染的窗口大小(也就是最后输出的纹理的分辨率),清空纹理。

到这里之后就能够做渲染操作了,渲染操作和其他的渲染没有什么区别。

渲染完成后的一些还原操作

Kotlin 复制代码
GLES30.glBindVertexArray(GLES30.GL_NONE)
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, GLES30.GL_NONE)

GLES30.glUseProgram(0)
GLES30.glFinish()

GLES30.glViewport(lastViewPort[0], lastViewPort[1], lastViewPort[2], lastViewPort[3])
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, GLES30.GL_NONE)

// 激活默认缓冲
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0)
GLES30.glDeleteFramebuffers(1, intArrayOf(fbo) , 0)

重置一些绑定的数据,还原窗口大小,激活默认屏幕帧缓冲。

到这里就完成了我们就把数据渲染到目标的纹理上了。

将字符渲染到纹理数组

这算是一个前置步骤,需要先构建字符纹理。

这里我们需要不同亮度值的字符,以下是我用的字符:

Kotiln 复制代码
private const val asciiChars = " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"

OpenGL 本身是没有提供直接渲染字符的方法,需要借助 FreeType 库来渲染。虽然 Android 系统中的字符渲染也是借助 FreeType,但是应用无法直接使用,需要单独的引入。不过 Android 中有一个非常简单的渲染字符方法,就是通过 Canvans 绘制到 Bitmap 上,然后把 Bitmap 再绘制到纹理上。

初始化一个纹理数组

这和普通的纹理没有太大区别,普通的纹理只有 x, y 两个坐标来表示,纹理数组还需要加入 z 来表示数组的下标。

Kotlin 复制代码
val charTexturesArray = IntArray(1)
GLES30.glGenTextures(1, charTexturesArray, 0)
val charTextures = charTexturesArray[0]
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D_ARRAY, charTextures)
val charSize = 16
GLES30.glTexImage3D(GLES30.GL_TEXTURE_2D_ARRAY, 0, GLES30.GL_RGBA, charSize, charSize, asciiChars.length, 0, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, null)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D_ARRAY, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D_ARRAY, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D_ARRAY, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D_ARRAY, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)
GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D_ARRAY)

我用的字符大小为 16 * 16,数组的大小是字符的长度,其他的配置也都是和普通的纹理是类似的。

绘制字符到纹理数组

Kotlin 复制代码
for ((i, c) in asciiChars.toCharArray().withIndex()) {
    val bitmap = Bitmap.createBitmap(charSize, charSize, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    val metrics = charPaint.fontMetrics
    val charWidth = charPaint.measureText(c.toString())
    val x = max((charSize - charWidth) / 2.0f, 0.0f)
    val y = - metrics.top / (metrics.bottom - metrics.top) * charSize
    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
    canvas.drawText(c.toString(), x, y, charPaint)
    val b = ByteBuffer.allocate(charSize * charSize * 4)
    bitmap.copyPixelsToBuffer(b)
    b.position(0)
    GLES30.glTexSubImage3D(GLES30.GL_TEXTURE_2D_ARRAY, 0, 0, 0, i, charSize, charSize, 1, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, b)
    bitmap.recycle()
}

遍历整个字符串,依次绘制,得到绘制后的 bitmap 后,然后将其中的数据复制到 Buffer 中,然后调用 glTexSubImage3D() 方法把 Buffer 中的绘制的字符数据传递到纹理中。

降低输入纹理分辨率和计算像素亮度

前面可以算是准备工作,到这里就算是正式开始字符画的处理了,输入的数据是需要处理的纹理图片。

Kotlin 复制代码
val asciiWidth = charLineWidth.get()
val asciiHeight = (asciiWidth.toFloat() * input.height.toFloat() / input.width.toFloat()).toInt()
val lumaImageBytes = ensureLumaImageCache(asciiHeight * asciiWidth * 4)
offScreenRender(
    outputTexId = renderData.lumaTexture,
    outputTexWidth = asciiWidth,
    outputTexHeight = asciiHeight
) {
    GLES30.glUseProgram(renderData.lumaProgram)
    GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, input.texture)
    GLES30.glUniform1i(GLES30.glGetUniformLocation(renderData.lumaProgram, "Texture"), 0)

    GLES30.glBindVertexArray(renderData.lumaVao)
    GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, renderData.lumaVbo)
    GLES30.glDrawArrays(GLES30.GL_TRIANGLE_FAN, 0, 4)

    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, renderData.lumaTexture)
    GLES30.glReadPixels(
        0, 0,
        asciiWidth, asciiHeight,
        GLES30.GL_RGBA,
        GLES30.GL_UNSIGNED_BYTE,
        ByteBuffer.wrap(lumaImageBytes)
    )
}

首先确定每一行要显示多少字符,然后根据图片的比例计算出一共有多少行字符,然后用到上面介绍的离屏渲染的方式,以字符的分辨率渲染到 lumaTexture 纹理上,然后直接读取像素到 lumaImageBytes 的字节 Buffer 上,每一个像素占用 4 bytes,前 3 bytes 表示 RGB 数据,最后一个 byte 表示他们对应的亮度值。

这里是其顶点数据:

Kotlin 复制代码
val vertices = floatArrayOf(
    // 坐标(position 0)   // 纹理坐标
    -1.0f, 1.0f,        0.0f, 1.0f,    // 左上角
    1.0f, 1.0f,         1.0f, 1.0f,   // 右上角
    1.0f, -1.0f,        1.0f, 0.0f,   // 右下角
    -1.0f, -1.0f,       0.0f, 0.0f,   // 左下角
)

看看顶点着色器:

GLSL 复制代码
#version 300 es
layout (location = 0) in vec4 aPos;
out vec2 TexCoord;

void main() {
    gl_Position = vec4(aPos.xy, 0.0, 1.0);
    TexCoord = aPos.zw;
}

很简单,输入的 x,y 为定点坐标,输入的 z,w 作为纹理坐标传递给后面的片段着色器。

GLSL 复制代码
#version 300 es

precision highp float; // Define float precision
in vec2 TexCoord;
out vec4 FragColor;

uniform sampler2D Texture;

void main() {
    vec4 textureColor = texture(Texture, TexCoord);
    float y = 0.299 * textureColor.r + 0.587 * textureColor.g + 0.114 * textureColor.b;
    FragColor = vec4(textureColor.rgb, y);
}

这里通过 rgb 转 亮度的公式计算出了亮度,然后最终输出。

将图片像素转换成字符

想要把上一个步骤生成的图片转换成字符,思路就比较简单了,我们需要找到每个像素对应用哪个字符来表示,然后按照整个画布依次把所对应的像素排列就行了,在准备工作的阶段我们都已经生成了字符所对应的纹理数组。

一般的绘制方式就是通过循环遍历每个字符,根据他们的坐标调用 glDrawArrays 方法完成绘制,有多少个字符就需要调用多少次 glDrawArrays 方法,我开始的实现方式就是这样,功能确实实现了,但是会出现性能问题,字符少还刚好能够满足性能要求,当字符稍微多一点就会导致帧率很低,因为多次调用 glDrawArrays 时,会存在 CPUGPU 间的多次数据交互,他们之间的数据交互效率比较低,所以字符多了以后性能就明显不足,好在 OpenGL 提供了另外一种的渲染方式,CPUGPU 之间只需要一次的数据交互就能够完成多次的渲染,实例化渲染。

我这里举一个简单的例子,你想在屏幕上渲染两个完全一样的图形但是他们的位置不一样,通常就是构建两组图形完全一样的,但是位置不一样的顶点坐标,然后调用两次 glDrawArrays 就好了,绘制两个图形完全没有问题,但是如果我要绘制 100 个呢?这不就费劲了。实例化渲染,就是为了解决这个问题,我们先在屏幕左上角绘制一个图形,而其他图形都可以通过这个图形的位移而得到,也就是我们只需要左上角图形的顶点坐标,然后加上每个不同位置图形的偏移,就能够高效的绘制 100 个位置不同,形状一样的图形了。

实例化渲染只支持 OpenGL ES 3.0 及其以上的版本,在了解了实例化渲染后我们继续看代码。

Kotlin 复制代码
val charVao = glGenVertexArrays()
val charVertVbo = glGenBuffers()
val charOffsetVbo = glGenBuffers()
val charColorAndTextureVbo = glGenBuffers()
GLES30.glBindVertexArray(charVao)
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, charVertVbo)
GLES30.glVertexAttribPointer(0, 4, GLES30.GL_FLOAT, false, 16, 0)
GLES30.glEnableVertexAttribArray(0)
GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, 64, null, GLES30.GL_STREAM_DRAW)

GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, charOffsetVbo)
GLES30.glVertexAttribPointer(1, 2, GLES30.GL_FLOAT, false, 8, 0)
GLES30.glEnableVertexAttribArray(1)

GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, charColorAndTextureVbo)
GLES30.glVertexAttribPointer(2, 4, GLES30.GL_FLOAT, false, 16, 0)
GLES30.glEnableVertexAttribArray(2)

GLES30.glVertexAttribDivisor(1, 1)
GLES30.glVertexAttribDivisor(2, 1)

首先构建一个 VAO,构建三个 VBO, index 0VBO 是用来表示顶点坐标和纹理坐标 (charVertVbo),index 1VBO 是用来表示上面说到实例化渲染时的偏离坐标(charOffsetVbo),index 2VBO 就表示字符的 RGB 颜色和所对应的纹理数组的下标(charColorAndTextureVbo)。

这里还有一个非常重要的方法就是 glVertexAttribDivisor() 方法,第一个参数表示 VBOindex,第二个参数表示每绘制几个实例的时候取一次数据,我上面的设置就是,偏移、颜色和字符纹理下标都是每绘制一个实例的时候取一次,默认是零,表示每次绘制顶点都取。

然后继续看代码。

Kotlin 复制代码
offScreenRender(
    outputTexId = renderData.charTexture,
    outputTexWidth = input.width,
    outputTexHeight = input.height
) {
    GLES30.glEnable(GLES30.GL_BLEND)
    GLES30.glBlendFunc(GLES30.GL_SRC_ALPHA, GLES30.GL_ONE_MINUS_SRC_ALPHA)
    GLES30.glUseProgram(renderData.charProgram)
    val charWidthGLStep = 2.0f / asciiWidth.toFloat()
    val charHeightGLStep = 2.0f / asciiHeight.toFloat()
    val pixelSize = asciiWidth * asciiHeight
    var pixelIndex = 0
    val start = SystemClock.uptimeMillis()
    GLES30.glBindVertexArray(renderData.charVao)
    GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, renderData.charVertVbo)
    renderData.charVert.let {
        it[1] = -1.0f + charHeightGLStep
        it[4] = -1.0f + charWidthGLStep
        it[5] = -1.0f + charHeightGLStep
        it[8] = -1.0f + charWidthGLStep
    }
    
    renderData.charVertBuffer.clear()
    renderData.charVertBuffer.put(renderData.charVert)
    renderData.charVertBuffer.position(0)
    GLES30.glBufferSubData(GLES30.GL_ARRAY_BUFFER, 0, 64, renderData.charVertBuffer)
    // ...
}

因为我们最后的输出结果也是一个纹理,所以也要用到离屏渲染,这里首先要根据我们的字符数量来确定每一个字符在 OpenGL 中的宽高,然后更新其顶点坐标,然后把其数据绑定到 charVertVbo 中。

Kotlin 复制代码
// ...
val offsetCache = getCharOffsetCacheArray(pixelSize)
val offsetArray = offsetCache.floatArray
val colorTexCache = getCharColorAndTextureCacheArray(pixelSize)
val colorTexArray = colorTexCache.floatArray
var xOffset = 0.0f
var yOffset = 0.0f
for (h in 0 until asciiHeight) {
    for (w in 0 until asciiWidth) {
        offsetArray[pixelIndex * 2] = xOffset
        offsetArray[pixelIndex * 2 + 1] = yOffset
        val r = lumaImageBytes[pixelIndex * 4].toUnsignedInt()
        val g = lumaImageBytes[pixelIndex * 4 + 1].toUnsignedInt()
        val b = lumaImageBytes[pixelIndex * 4 + 2].toUnsignedInt()
        val y = lumaImageBytes[pixelIndex * 4 + 3].toUnsignedInt()
        val charIndex = if (reverseChar.get()) asciiLightLevelIndexReverse[y] else asciiLightLevelIndex[y]
        colorTexArray[pixelIndex * 4] = r.toFloat()
        colorTexArray[pixelIndex * 4 + 1] = g.toFloat()
        colorTexArray[pixelIndex * 4 + 2] = b.toFloat()
        colorTexArray[pixelIndex * 4 + 3] = charIndex.toFloat()

        pixelIndex ++
        xOffset += charWidthGLStep
    }
    xOffset = 0.0f
    yOffset += charHeightGLStep
}
// ...

首先生成 offsetArraycolorTexArray 来分别表示偏移数据和颜色与纹理下标,然后遍历在上一个阶段降低分辨率和计算亮度后的数据,把数据依次更新到 offsetArraycolorTexArray

Kotlin 复制代码
// ...
GLES30.glUniform1i(GLES30.glGetUniformLocation(renderData.charProgram, "reverseColor"), if (reverseColor.get()) 1 else 0)
GLES30.glUniform1f(GLES30.glGetUniformLocation(renderData.charProgram, "colorFillRate"), colorFillRate.get())
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, renderData.charOffsetVbo)
offsetCache.floatBuffer.apply {
    clear()
    put(offsetArray)
    position(0)
}
GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, offsetArray.size * 4, offsetCache.floatBuffer, GLES30.GL_STREAM_DRAW)
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, renderData.charColorAndTextureVbo)
colorTexCache.floatBuffer.apply {
    clear()
    put(colorTexArray)
    position(0)
}
GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, colorTexArray.size * 4, colorTexCache.floatBuffer, GLES30.GL_STREAM_DRAW)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D_ARRAY, renderData.charTexturesArray)
GLES30.glDrawArraysInstanced(GLES30.GL_TRIANGLE_FAN, 0, 4, pixelSize)
// ...

然后就设置了一些参数,把数据绑定到 VBO 中,最后调用 glDrawArraysInstanced() 执行实例化渲染。到这里就得到我们最后的结果了,数据是离屏渲染到纹理上的,你可以把这个纹理渲染到屏幕上,就看到效果了,也可以获取到纹理的像素数据然后重新编码输出一个新的字符画视频文件。

顶点着色器:

GLSL 复制代码
#version 300 es

layout (location = 0) in vec4 vert;
layout (location = 1) in vec2 offset;
layout (location = 2) in vec4 colorAndTexture;

out vec3 CharColor;
out vec3 TexCoord;

void main() {
    gl_Position = vec4(vert.x + offset.x, vert.y + offset.y, 0.0, 1.0);
    TexCoord = vec3(vert.zw, colorAndTexture.w);
    CharColor = colorAndTexture.xyz;
}

代码也非常简单,根据 vertoffset 得出最后的坐标,从 colorAndTexture 中获取颜色和纹理坐标(这是一个纹理数组,colorAndTexture.w 就表示它的下标),发送至片段着色器。

ini 复制代码
#version 300 es

precision highp float; // Define float precision
precision highp sampler2DArray;

in vec3 CharColor;
in vec3 TexCoord;
uniform sampler2DArray Texture;
uniform int reverseColor;
uniform float colorFillRate;

out vec4 FragColor;
void main() {
    float r = CharColor.x / 255.0;
    float g = CharColor.y / 255.0;
    float b = CharColor.z / 255.0;
    float a;
    if (reverseColor == 0) {
        a = texture(Texture, TexCoord).a;
    } else {
        a = 1.0 - texture(Texture, TexCoord).a;
    }
    float y = 0.299 * r + 0.587 * g + 0.114 * b;
    if (colorFillRate > y) {
        FragColor = vec4(r, g, b, a);
    } else {
        FragColor = vec4(1.0, 1.0, 1.0, a);
    }
}

片段着色器也非常简单,只是有一些效果上的配置,这里忽略,直接取出字符纹理数组中的值,最后输出。

如果对于 OpenGL 实例化渲染没太看懂的同学,可以参考这里面的代码,逻辑要简单很多:AndroidOpenGLPractice

相关推荐
闲暇部落2 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX4 小时前
Android 分区相关介绍
android
大白要努力!5 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee5 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood5 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-8 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
闲暇部落10 小时前
Android OpenGL ES详解——绘制圆角矩形
opengl·圆形·矩形·圆角矩形
Eastsea.Chen10 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年18 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿20 小时前
会员等级经验问题
android·开发语言·前端·javascript·php