在移动端图像处理中,自由裁剪是一个常见但实现起来颇具挑战的功能。本文将基于Android平台,使用Kotlin语言和OpenGL ES 3.0,详细讲解如何实现一个高性能的自由裁剪功能,包括向内向外拖动裁剪框时图片纹理的对应放大和缩小效果。
自由裁剪功能需要满足以下几个核心需求:
为什么选择OpenGL ES而不是Android自带的Canvas API?
EGL是OpenGL ES和原生窗口系统之间的桥梁,我们需要先建立EGL环境:
kotlin复制// 抽象工厂模式:负责创建EGL相关组件族
interface EGLComponentFactory {
fun createEGL(): EGL10
fun createEGLDisplay(egl: EGL10): EGLDisplay
fun createEGLConfig(egl: EGL10, display: EGLDisplay): EGLConfig
fun createEGLContext(egl: EGL10, display: EGLDisplay, config: EGLConfig): EGLContext
fun createEGLSurface(egl: EGL10, display: EGLDisplay, config: EGLConfig, surface: Surface): EGLSurface
}
这种抽象工厂模式的设计有以下优点:
kotlin复制class DefaultEGLFactory : EGLComponentFactory {
override fun createEGL(): EGL10 = EGLContext.getEGL() as EGL10
override fun createEGLDisplay(egl: EGL10): EGLDisplay {
val eglDisplay = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY)
if (eglDisplay == EGL10.EGL_NO_DISPLAY) {
throw RuntimeException("eglGetDisplay failed")
}
val version = IntArray(2)
if (!egl.eglInitialize(eglDisplay, version)) {
throw RuntimeException("eglInitialize failed")
}
return eglDisplay
}
override fun createEGLConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
val attributes = intArrayOf(
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 8,
EGL_STENCIL_SIZE, 8,
EGL_NONE
)
val numConfigs = IntArray(1)
egl.eglChooseConfig(display, attributes, null, 0, numConfigs)
if (numConfigs[0] <= 0) {
throw RuntimeException("No matching EGL configs")
}
val configs = arrayOfNulls<EGLConfig>(numConfigs[0])
egl.eglChooseConfig(display, attributes, configs, numConfigs.size, numConfigs)
return configs[0] ?: throw RuntimeException("No suitable EGL config found")
}
// ...其他方法实现
}
注意事项:EGL配置属性中,我们选择了8位的颜色通道和深度缓冲,这对于图像处理应用已经足够。如果需要进行更复杂的3D渲染,可能需要调整这些参数。
使用构建者模式来管理EGL环境的创建过程:
kotlin复制class EGLEnvironmentBuilder(private val factory: EGLComponentFactory = DefaultEGLFactory()) {
// ...成员变量省略
fun build(surface: Surface): EGLEnvironment {
mEGL = factory.createEGL()
mEGLDisplay = factory.createEGLDisplay(mEGL)
mEGLConfig = factory.createEGLConfig(mEGL, mEGLDisplay)
mEGLContext = factory.createEGLContext(mEGL, mEGLDisplay, mEGLConfig)
mEGLSurface = factory.createEGLSurface(mEGL, mEGLDisplay, mEGLConfig, surface)
if (!mEGL.eglMakeCurrent(mEGLDisplay, mEGLSurface, mEGLSurface, mEGLContext)) {
throw RuntimeException("eglMakeCurrent failed")
}
return EGLEnvironment(mEGL, mEGLDisplay, mEGLConfig, mEGLContext, mEGLSurface)
}
class EGLEnvironment(
private val egl: EGL10,
private val display: EGLDisplay,
private val config: EGLConfig,
private val context: EGLContext,
private var surface: EGLSurface
) {
// ...环境管理方法
}
}
这种设计模式的优势在于:
我们设计了一个OpenGLData接口来管理渲染数据:
kotlin复制interface OpenGLData {
fun onSurfaceCreated()
fun onSurfaceChanged(width: Int, height: Int)
fun onDrawFrame()
fun onSurfaceDestroyed()
}
这个接口定义了OpenGL渲染的生命周期方法,使得我们可以将渲染逻辑与EGL环境管理分离。
在BaseOpenGLData类中,我们定义了顶点数据和着色器程序:
kotlin复制// 顶点和纹理坐标合并在一个数组中
// 格式:x, y, z, u, v (顶点坐标后跟纹理坐标)
val vertexData = floatArrayOf(
// 顶点坐标 // 纹理坐标
-1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 左上
-1.0f, -1.0f, 0.0f, 0.0f, 0.0f, // 左下
1.0f, 1.0f, 0.0f, 1.0f, 1.0f, // 右上
1.0f, -1.0f, 0.0f, 1.0f, 0.0f // 右下
)
// 顶点着色器代码
val vertexShaderCode = """#version 300 es
uniform mat4 uMVPMatrix; // 变换矩阵
in vec4 aPosition; // 顶点坐标
in vec2 aTexCoord; // 纹理坐标
out vec2 vTexCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTexCoord = aTexCoord;
}""".trimIndent()
// 片段着色器代码
val fragmentShaderCode = """#version 300 es
precision mediump float;
uniform sampler2D uTexture_0;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
fragColor = texture(uTexture_0, vTexCoord);
}""".trimIndent()
实操心得:将顶点数据和纹理坐标合并到一个缓冲区中,可以减少GPU的内存访问次数,提高渲染性能。这是现代OpenGL的常见优化手段。
kotlin复制private fun initTexture() {
val textureId = IntArray(1)
GLES30.glGenTextures(1, textureId, 0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureId[0])
// 设置纹理参数
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.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_CLAMP_TO_EDGE)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_CLAMP_TO_EDGE)
// 加载图片
val options = BitmapFactory.Options().apply {
inScaled = false // 不进行缩放
}
val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.crop, options)
GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0)
bitmap.recycle()
mTextureID[0] = textureId[0]
mImageWidth = bitmap.width
mImageHeight = bitmap.height
}
注意事项:设置inScaled = false非常重要,这可以确保Bitmap在加载时不被系统自动缩放,保持原始尺寸,避免纹理失真。
自由裁剪功能的核心在于变换矩阵的计算:
kotlin复制private fun computeMVPMatrix() {
val isLandscape = mWidth > mHeight
val viewPortRatio = if (isLandscape) mWidth.toFloat() / mHeight else mHeight.toFloat() / mWidth
// 计算包围图片的球半径
val radius = sqrt(1f + viewPortRatio * viewPortRatio)
val near = 0.1f
val far = near + 2 * radius
val distance = near / (near + radius)
// 视图矩阵
Matrix.setLookAtM(
mViewMatrix, 0,
0f, 0f, near + radius, // 相机位置
0f, 0f, 0f, // 看向原点
0f, 1f, 0f // 上方向
)
// 投影矩阵
Matrix.frustumM(
mProjectionMatrix, 0,
if (isLandscape) (-viewPortRatio * distance) else (-1f * distance), // 左边界
if (isLandscape) (viewPortRatio * distance) else (1f * distance), // 右边界
if (isLandscape) (-1f * distance) else (-viewPortRatio * distance), // 下边界
if (isLandscape) (1f * distance) else (viewPortRatio * distance), // 上边界
near, far
)
// 模型矩阵
Matrix.setIdentityM(mModelMatrix, 0)
Matrix.translateM(mModelMatrix, 0, translateX, translateY, translateZ)
// 旋转(顺序:X → Y → Z)
if (rotationX != 0f) Matrix.rotateM(mModelMatrix, 0, rotationX, 1f, 0f, 0f)
if (rotationY != 0f) Matrix.rotateM(mModelMatrix, 0, rotationY, 0f, 1f, 0f)
if (rotationZ != 0f) Matrix.rotateM(mModelMatrix, 0, rotationZ, 0f, 0f, 1f)
// 缩放
Matrix.scaleM(mModelMatrix, 0, scaleX, scaleY, scaleZ)
// 图片宽高比校正
if (mImageWidth > 0 && mImageHeight > 0) {
val imageAspect = mImageWidth.toFloat() / mImageHeight.toFloat()
if (imageAspect > 1f) {
Matrix.scaleM(mModelMatrix, 0, 1f, 1f / imageAspect, 1f)
} else {
Matrix.scaleM(mModelMatrix, 0, imageAspect, 1f, 1f)
}
}
// 计算MVP矩阵
Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0)
Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mMVPMatrix, 0)
// 纹理坐标系翻转(因为OpenGL纹理坐标原点在左下,而Bitmap在左上)
Matrix.scaleM(mMVPMatrix, 0, 1f, -1f, 1f)
}
技术细节:这里使用了模型-视图-投影矩阵(MVP矩阵)来实现2D图像的3D变换效果。虽然我们处理的是2D图像,但使用3D变换矩阵可以更方便地实现缩放、旋转等效果。
裁剪功能的核心是使用FBO(帧缓冲对象)进行离屏渲染:
kotlin复制private fun executeCrop(nLeft: Float, nTop: Float, nRight: Float, nBottom: Float) {
val cropW = ((nRight - nLeft) * mImageWidth).toInt().coerceAtLeast(1)
val cropH = ((nBottom - nTop) * mImageHeight).toInt().coerceAtLeast(1)
val state = OpenGLUtils.saveGLState()
// 1. 创建目标纹理
val newTex = IntArray(1)
GLES30.glGenTextures(1, newTex, 0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, newTex[0])
GLES30.glTexImage2D(
GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA,
cropW, cropH, 0,
GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, null
)
// ...设置纹理参数
// 2. 创建FBO并绑定目标纹理
val fbo = IntArray(1)
GLES30.glGenFramebuffers(1, fbo, 0)
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, fbo[0])
GLES30.glFramebufferTexture2D(
GLES30.GL_FRAMEBUFFER, GLES30.GL_COLOR_ATTACHMENT0,
GLES30.GL_TEXTURE_2D, newTex[0], 0
)
// 3. 构建裁剪区域的顶点数据
val cropVertices = floatArrayOf(
-1f, 1f, 0f, nLeft, nBottom,
-1f, -1f, 0f, nLeft, nTop,
1f, 1f, 0f, nRight, nBottom,
1f, -1f, 0f, nRight, nTop
)
// 4. 创建临时VAO/VBO
val tempVAO = IntArray(1)
val tempVBO = IntArray(1)
// ...初始化VAO/VBO
// 5. 用单位矩阵渲染,采样源纹理的裁剪区域
val identityMatrix = FloatArray(16)
Matrix.setIdentityM(identityMatrix, 0)
GLES30.glUseProgram(mProgram)
val matrixHandle = GLES30.glGetUniformLocation(mProgram, "uMVPMatrix")
GLES30.glUniformMatrix4fv(matrixHandle, 1, false, identityMatrix, 0)
OpenGLUtils.enableTexture0(mProgram, mTextureID[0])
GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)
// 6. 清理临时资源
GLES30.glDeleteVertexArrays(1, tempVAO, 0)
GLES30.glDeleteBuffers(1, tempVBO, 0)
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0)
GLES30.glDeleteFramebuffers(1, fbo, 0)
// 7. 替换纹理
GLES30.glDeleteTextures(1, mTextureID, 0)
mTextureID[0] = newTex[0]
mImageWidth = cropW
mImageHeight = cropH
// 8. 恢复GL状态,重置变换
OpenGLUtils.restoreGLState(state)
reset()
}
实操心得:使用FBO进行离屏渲染时,一定要注意保存和恢复OpenGL的状态,否则可能会影响后续的正常渲染。我们封装了saveGLState和restoreGLState方法来简化这个过程。
为了实现自由裁剪框的拖动效果,我们需要处理触摸事件:
kotlin复制// 在Activity或View中处理触摸事件
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 检测触摸点是否在裁剪框边缘
handleTouchDown(event.x, event.y)
}
MotionEvent.ACTION_MOVE -> {
// 根据触摸点移动更新裁剪框位置
handleTouchMove(event.x, event.y)
}
MotionEvent.ACTION_UP -> {
// 结束触摸交互
handleTouchUp()
}
}
return true
}
private fun handleTouchMove(x: Float, y: Float) {
// 计算移动距离
val dx = x - lastX
val dy = y - lastY
// 更新裁剪框位置
when (dragMode) {
DRAG_LEFT -> cropLeft += dx / viewWidth
DRAG_TOP -> cropTop += dy / viewHeight
// ...其他边缘处理
}
// 限制裁剪框范围
cropLeft = cropLeft.coerceIn(0f, cropRight - minCropSize)
// ...其他边界限制
// 请求重新渲染
renderer.requestRender()
lastX = x
lastY = y
}
问题现象:纹理显示为黑色或颜色异常
可能原因:
解决方案:
问题现象:裁剪后的图像比原图模糊
可能原因:
解决方案:
问题现象:应用运行一段时间后内存占用持续增加
可能原因:
解决方案:
当前实现是矩形裁剪,可以扩展支持:
实现思路:在片段着色器中根据坐标判断是否在裁剪区域内,丢弃不需要的像素。
可以为裁剪框的调整添加平滑动画:
kotlin复制fun animateCrop(targetLeft: Float, targetTop: Float,
targetRight: Float, targetBottom: Float) {
animator?.cancel()
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = ANIMATION_DURATION
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { animation ->
val progress = animation.animatedValue as Float
currentLeft = lerp(startLeft, targetLeft, progress)
// ...其他参数类似
requestRender()
}
start()
}
}
private fun lerp(start: Float, end: Float, progress: Float): Float {
return start + (end - start) * progress
}
扩展当前实现以支持同时处理多个纹理,可以实现更复杂的效果,如:
实现要点:
在实际开发中,根据项目需求选择合适的优化方向和扩展功能,平衡性能与功能丰富度。