该方案整理自我的博客文章《Camera无变形任意尺寸预览》与《相机录像新姿势-OpenGL共享上下文+MediaCodec》,可参考iCamera源码。
一、需求背景
多年前,尝试做过一个模仿Instagram的方形相机无变形预览及录像,做过Android相机开发的都知道,并不是你想让相机以什么尺寸预览都可以,就算是强行把预览画面设置成方形,画面也会变形,当时因为技术储备不到位,只能做到一种粗糙的方案,即使用不透明的遮盖,把视频的上下区域遮盖住,录像后,再通过一些视频处理的库,比如ffmpeg进行视频的裁剪。这种方案首先是做不到* *所见即所得的效果,有可能因为系统UI的影响,导致预览的画面与最终生成的画面有偏差,另外就是录像结束后,需要较长时间去处理。
多年后,做SDK开发,工作中遇到了这样的需求:
- 相机的预览画面可以是任意尺寸,且预览不能变形;
- 录像期间,可以做到切换前后摄像头;
- 可以扫码、拍照等;
这些需求中,最难的是1、2两点。
二、初版方案
使用传统的Camera+MediaRecorder肯定是不行的,因为MediaRecorder在录像期间,是不允许切换摄像头的。这种方案直接放弃。
那就使用一种更为曲折的方案,Camera帧数据+MediaCodec,由于不是本文的重点,简述一下这个初版方案。
- 预览使用TextureView,通过
setTransform
,将画面居中、缩放到一个合适的比例,避免画面预览时的变形问题; - 通过
camera.setPreviewCallback
获得帧数据,然后通过libyuv对帧数据进行格式缩放、裁切、旋转等操作,对于前置摄像头还要做镜像旋转,然后将处理后的帧数据送入MediaCodec,配合音频采集的数据编码成视频。对于录像期间切换摄像头,只需要将PreviewCallback
从当前相机移除,设置到新切换的相机实例上即可。
这种方案,满足了基本需求,但是有很大的缺点:
- 帧数据处理繁琐且易出错。 帧数据的缩放与裁剪是固定的步骤,除此之外, 当相机设置了方向,就要添加一个旋转帧数据的操作,当切换到自拍摄像头,就要添加一个镜像翻转的步骤,同样的,摄像头翻转回去,要去掉这个步骤。这还不算在处理帧数据时,遇到的不同处理器的兼容问题(主要是MediaCodec对帧数据尺寸的容错能力兼容)、绿屏问题、冷暖色调相反问题等;
- 尺寸问题。 额外引入的libyuv库会增加app/sdk的尺寸,当时我对libyuv库做了裁剪,移除了很多不需要的功能,之留下针对NV21这种yuv格式的支持以及缩放、裁剪、镜像、旋转等操作。最终的尺寸影响控制在100k以内,勉强可以接受;
- 后续维护难题。 由于做SDK开发,很多客户可能自身会使用libyuv,有可能冲突,同时还有加固的问题等,最麻烦的是后续遇到bug,比较难维护。
三、最终的低成本方案
在后续的开发工作中,积累了一些OpenGL与surface的相关经验,受到Grafika这个神库的启发,最终总结出如下的方案。 当前的方案实现,是在工作中遇到问题解决问题的过程中,总结出的方案,其中涉及到一些OpenGL的知识,我本人对这些并不精通,只是拿来用一下,如果有OpenGL的大神,可以提供更好的方案,欢迎评论区讨论或者fork代码进行修改。
3.1 基本思路
在一般的Camera应用开发时,通常需要有一个预览画面的控件,一般时SurfaceView或者TextureView,然后把Surface或者SurfaceTexture设置给Camera进行预览。
而在我们的方案中,这个过程,要增加一些步骤。具体的步骤如下:
- 从SurfaceView或者TextureView获得Surface对象,比如
holder.surface
或者Surface(surfaceTexture)
; - 由此Surface对象,我们创建EGL环境,并重新创建一个
SurfaceTexture
对象,这个新的SurfaceTexture
对象,最后需要设置给Camera进行预览; - 监听步骤2中创建的
SurfaceTexture
对象的帧数据,通过OpenGL对画面消除形变并进行渲染。 - 与步骤2类似的,我们要创建一个新的EGL环境,用户共享步骤2中的EGL环境的纹理,这样我们就可以在共享的EGL环境中,进行录像或者扫码等。
以上就是基本思路,是不是还是一头雾水?没关系,下面进行具体实现的讲解。
3.2 具体实现
3.2.1 通过Surface创建EGL环境
一般代码示例如下:
private val display: EGLDisplay by lazy { EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) }
private var eglSurface: EGLSurface? = null
var eglContext: EGLContext? = null
private set
private fun createEGL(surface: Surface) {
val version = IntArray(2)
EGL14.eglInitialize(display, version, 0, version, 1)
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_NONE, 0, // placeholder for recordable [@-3]
EGL14.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
EGL14.eglChooseConfig(display, attributes, 0, configs, 0,
configs.size, numConfigs, 0)
val config = configs[0]
eglSurface = EGL14.eglCreateWindowSurface(
display, config, surface, intArrayOf(
EGL14.EGL_NONE
), 0
)
eglContext = EGL14.eglCreateContext(
display, config, sharedContext ?: EGL14.EGL_NO_CONTEXT, intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE
), 0
)
EGL14.eglMakeCurrent(display, eglSurface, eglSurface, eglContext)
}
这里需要注意的是,这个方法调用需要在一个独立的线程中,最后一句代码是与当前线程绑定,相关的OpenGL的操作,只能在对应的线程中进行。 有了这个OpenGL环境以后,就可以创建对应的SurfaceTexture
了,简单的代码如下:
val textureIds = IntArray(1)
GLES20.glGenTextures(1, textureIds, 0)
val texture = SurfaceTexture(textureIds[0])
同样的,这里的代码也要运行在之前的创建EGL环境相同的线程之下,之后便可以将这里创建的texture
设置给camera对象用于预览。
3.2.2 监听预览-消除形变-绘制画面
给上一步中创建的texture
设置一个画面监听。
val matrix = FloatArray(16)
texture.setOnFrameAvailableListener {
queue {
texture.updateTexImage()
texture.getTransformMatrix(matrix)
drawFrame(textureIds[0], matrix)
}
}
fun drawFrame(textureId: Int, matrix: FloatArray) {
// ....
}
简单解释一下这里代码:
- 先创建一个浮点数组,用于接收预览画面的坐标数据,用于OpenGL绘制画面使用;
- 然后设置画面监听,这里在相机开启预览后会触发一次;
- 在画面监听回调中,切换到EGL线程中,调用
updateTexImage
,只有这样,画面监听回调才会持续的被执行; - 最后调用
getTransformMatrix
获取画面坐标数据,为之后的画面绘制做准备。
接下来,便是执行画面的绘制,在画面绘制关键的一步就是消除形变,这里需要先获得camera对象的预览尺寸,从支持的预览尺寸中,挑选最佳匹配尺寸以及消除方形问题的相关代码,这里不再赘述,只提供消除形变的关键代码。
val inputSize: Size // 相机输出的预览画面尺寸,因为要作为绘制的输入,所以称为inputSize,注意消除屏幕方向旋转的问题
val outputSize: Size // 输出画面的尺寸
val viewport: Rect // 计算出预览画面要消除形变,需要做的位置与尺寸变更
private fun calculateBestViewPort() {
if (inputSize.isEmpty || outputSize.isEmpty) {
return
}
val scale = max(outputSize.width.toFloat() / inputSize.width, outputSize.height.toFloat() / inputSize.height)
val srcWidth = (inputSize.width * scale).toInt()
val srcHeight = (inputSize.height * scale).toInt()
val left = (outputSize.width - srcWidth) / 2
val top = (outputSize.height - srcHeight) / 2
viewport.set(left, top, left + srcWidth, top + srcHeight)
}
在绘制画面前,执行OpenGL的glViewport
方法,就可以消除形变了。
fun drawFrame(textureId: Int, texMatrix: FloatArray?) {
if (!viewport.isEmpty) {
GLES20.glViewport(viewport.left, viewport.top, viewport.width(), viewport.height())
}
// 绘制画面代码有大量的OpenGL操作,具体不再赘述
}
完成以上步骤,就可以完成无变形的预览画面了。但是我们还有录像的需求。
3.3.3 共享纹理-录像
我们需要事先获得一个Surface,对于录像而言,则是MediaCodec的getInputSurface。对这个Surface,配合预览的EGLContext,生成一个共享预览纹理的ShareSurface。
class SharedSurface(private val surface: Surface) : PreviewSurface.OnDrawFrameCallback {
fun attach(previewSurface: PreviewSurface) {
sharedThread.start()
sharedHandler.post {
eglCore = EglCore(previewSurface.eglContext, EglCore.FLAG_RECORDABLE)
sharedSurface = WindowSurface(eglCore!!, surface, false)
sharedSurface?.makeCurrent()
drawer = SizedFullFrameRect(Texture2dProgram(Texture2dProgram.ProgramType.TEXTURE_EXT))
val inputSize = previewSurface.getInputSize()
drawer?.setInputSize(inputSize.width, inputSize.height)
previewSurface.addOnDrawFrameCallback(this)
attachedSurface = previewSurface
}
}
}
3.3.4 扫码
与录像类似,扫码同样需要用到共享纹理的ShareSurface,此时构造ShareSurface时传入的参数则需要传入ImageReader的getSurface(),然后在ImageReader.OnImageAvailableListener#onImageAvailable(ImageReader reader)中处理数据。源码中并没有设置扫码的demo,如果有需要,可以评论区留言,我将补充相关代码。
基于相同的原理,只要我们有一个ImageReader就可以做很多其他的处理,比如拍照,帧数据回调(此时的帧数据,也是所见即所得的帧数据尺寸)。
四、总结
新方案的优点如下:
- 直接将surface的画面送入MediaCodec,所见即所得;
- 不需要引入额外的libyuv或者ffmpeg,只需要Android自身的API即可实现,不需要繁琐的帧数据处理,app/sdk的尺寸也变小了;
- 配合ImageReader实现的扫码、拍照、获取帧数据等功能,也不再需要进行裁剪、缩放等操作了;
当前文章中,也只是粗略的提供了思路和基本代码,更多的代码还是请参考iCamera源码。
五、后记
当前的方案依然使用的是被官方标记为过时的Camera1的API,原因是在使用Camera2 API时,无法获取其预览的帧数据尺寸,也就无法做修正。如果有关于Camera2 API的更好的方式,可以评论区讨论。