综合实战:仿微信相机(拍照+录像+滤镜+切换摄像头+手势缩放+对焦)

终于到了综合实战环节。说实话,前面学那么多零散的知识点,就是为了这一刻——把它们串起来,做一个真正能用的相机应用。

微信的相机大家天天用,拍个照、录个视频、加个滤镜、双指缩放、点一下对焦……看起来简单,但背后涉及的技术点其实不少。我当年第一次尝试仿写时,光是对焦和缩放的冲突就折腾了两天。嗯,今天咱们就把这些坑都填上。

本章核心目标:实现一个功能完整的相机应用,支持拍照、录像、实时滤镜、前后摄像头切换、双指手势缩放、点击对焦。所有功能基于 CameraX 实现。

整体架构设计

先别急着写代码。我个人习惯是先画一张图,把模块之间的关系理清楚。你看,整个相机应用可以拆成这么几块:

仿微信相机 — 模块架构图 CameraX 核心 拍照模块 录像模块 滤镜处理 手势交互 拍照 → 保存到相册 录像 → 输出 MP4 实时滤镜 → OpenGL 缩放 + 对焦 状态管理(录制中/空闲/滤镜切换)

说白了,CameraX 是底座,上面挂了四个功能模块。状态管理贯穿始终——你得知道当前是在拍照模式还是录像模式,滤镜有没有生效,这些状态直接影响 UI 的显示和操作逻辑。

第一步:项目配置与基础搭建

老规矩,先加依赖。在 build.gradle 里引入 CameraX 和相关库:

dependencies {
    // CameraX 核心
    def camerax_version = "1.3.1"
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    implementation "androidx.camera:camera-lifecycle:${camerax_version}"
    implementation "androidx.camera:camera-view:${camerax_version}"
    
    // 滤镜相关(OpenGL)
    implementation "androidx.camera:camera-extensions:${camerax_version}"
    
    // 权限
    implementation 'com.karumi:dexter:6.2.3'
}

权限方面,Android 6.0 以上需要动态申请 CAMERARECORD_AUDIO。录音权限很多人会忘,我刚开始做录像功能时就漏了它,结果录出来的视频全是静音……

小提示:如果只拍照,可以只申请 CAMERA 权限。但一旦涉及录像,RECORD_AUDIO 必须加上,否则 MediaRecorder 会直接报错。

第二步:实现拍照与录像

拍照用 ImageCapture,录像用 VideoCapture。这两个是 CameraX 提供的 UseCase,直接绑定到生命周期即可。

先看拍照的核心代码:

// 创建 ImageCapture 实例
val imageCapture = ImageCapture.Builder()
    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
    .build()

// 拍照
imageCapture.takePicture(
    ContextCompat.getMainExecutor(this),
    object : ImageCapture.OnImageSavedCallback {
        override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
            // 保存成功,更新相册
            Toast.makeText(this@MainActivity, "拍照成功", Toast.LENGTH_SHORT).show()
        }
        override fun onError(exception: ImageCaptureException) {
            // 处理错误
            Log.e("Camera", "拍照失败: ${exception.message}")
        }
    }
)

录像稍微复杂一点,因为涉及 MediaRecorder 的配置。CameraX 的 VideoCapture 内部封装了 MediaRecorder,但你需要自己管理录制状态:

// 创建 VideoCapture
val videoCapture = VideoCapture.Builder()
    .setVideoFrameRate(30)
    .setBitRate(10_000_000) // 10 Mbps
    .build()

// 开始录制
fun startRecording() {
    val outputFile = File(getExternalFilesDir(null), "video_${System.currentTimeMillis()}.mp4")
    val outputOptions = VideoCapture.OutputFileOptions.Builder(outputFile).build()
    
    videoCapture.startRecording(
        outputOptions,
        ContextCompat.getMainExecutor(this),
        object : VideoCapture.OnVideoSavedCallback {
            override fun onVideoSaved(outputFileResults: VideoCapture.OutputFileResults) {
                // 录制完成
            }
            override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
                // 录制出错
            }
        }
    )
}

// 停止录制
fun stopRecording() {
    videoCapture.stopRecording()
}

注意:录像时不能同时拍照,反之亦然。CameraX 虽然支持同时绑定多个 UseCase,但 ImageCaptureVideoCapture 不能同时处于活跃状态。我见过有人想边录边拍,结果两个 UseCase 互相抢占资源,直接崩溃。

第三步:实时滤镜实现

滤镜这块,CameraX 官方提供了 CameraExtensions 库,但说实话,它支持的滤镜种类有限,而且不同厂商兼容性不一样。我更推荐自己用 OpenGL 做。

核心思路是:拿到 CameraX 的 PreviewView,然后通过 SurfaceTexture 把每一帧画面传给 OpenGL 做处理,最后渲染到屏幕上。

这里我给出一个简化版的滤镜切换逻辑:

// 假设你有一个 OpenGL 渲染器
class FilterRenderer : GLSurfaceView.Renderer {
    private var currentFilter: Int = 0 // 0=原图, 1=黑白, 2=怀旧, 3=冷色调
    
    fun switchFilter(filterIndex: Int) {
        currentFilter = filterIndex
        // 重新编译着色器
        updateShaderProgram(filterIndex)
    }
    
    private fun updateShaderProgram(filterIndex: Int) {
        // 根据 filterIndex 加载不同的 fragment shader
        val fragmentShader = when (filterIndex) {
            1 -> getBlackWhiteShader()
            2 -> getSepiaShader()
            3 -> getCoolToneShader()
            else -> getNormalShader()
        }
        // 重新创建着色器程序
    }
}

你想想看,滤镜的本质其实就是修改 fragment shader 里的颜色矩阵。黑白滤镜就是把 RGB 转成灰度值,怀旧滤镜加一点棕色调,冷色调就提高蓝色通道的权重。原理并不复杂。

我的经验:滤镜切换时,最好做一个平滑过渡动画,而不是瞬间切换。用户视觉体验会好很多。可以用 ValueAnimator 在 0.3 秒内渐变调整颜色矩阵。

第四步:手势缩放与对焦

这两个功能是用户最常操作的。微信相机里,双指捏合缩放,点击屏幕对焦,非常流畅。

缩放用 CameraControlsetZoomRatio() 方法。你需要监听 ScaleGestureDetector

// 缩放监听
class ZoomGestureListener(private val cameraControl: CameraControl) : ScaleGestureDetector.SimpleOnScaleGestureListener() {
    private var currentZoom = 1.0f
    
    override fun onScale(detector: ScaleGestureDetector): Boolean {
        currentZoom *= detector.scaleFactor
        currentZoom = currentZoom.coerceIn(1.0f, 5.0f) // 限制最大5倍变焦
        cameraControl.setZoomRatio(currentZoom)
        return true
    }
}

对焦稍微麻烦一点。CameraX 的 CameraControl 提供了 startFocusAndMetering() 方法,但你需要把屏幕点击坐标转换成传感器坐标:

// 点击对焦
previewView.setOnTouchListener { _, event ->
    if (event.action == MotionEvent.ACTION_UP) {
        val x = event.x
        val y = event.y
        
        // 将屏幕坐标转换为归一化坐标 (0~1)
        val normalizedX = x / previewView.width
        val normalizedY = y / previewView.height
        
        // 创建 MeteringPoint
        val meteringPoint = previewView.meteringPointFactory.createPoint(normalizedX, normalizedY)
        val action = FocusMeteringAction.Builder(meteringPoint).build()
        
        camera.cameraControl.startFocusAndMetering(action)
    }
    true
}

避坑指南:我曾经遇到过一个问题——缩放和对焦手势冲突。双指缩放时,手指抬起可能会触发点击事件,导致对焦。解决办法是在 ScaleGestureDetector 检测到缩放时,设置一个标志位,屏蔽点击对焦事件,等缩放结束后再恢复。

第五步:切换摄像头

前后摄像头切换,本质上是重新绑定 CameraSelector。CameraX 让这件事变得很简单:

private var lensFacing = CameraSelector.LENS_FACING_BACK

fun switchCamera() {
    lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
        CameraSelector.LENS_FACING_FRONT
    } else {
        CameraSelector.LENS_FACING_BACK
    }
    
    // 重新绑定 UseCase
    val cameraSelector = CameraSelector.Builder()
        .requireLensFacing(lensFacing)
        .build()
    
    cameraProvider.unbindAll()
    cameraProvider.bindToLifecycle(this, cameraSelector, imageCapture, videoCapture, preview)
}

注意,切换摄像头时,之前设置的缩放比例会重置。我建议在切换后把 zoomRatio 恢复为 1.0f,或者记录下当前缩放值,切换后重新设置。

状态管理:录制中 vs 空闲

这个很容易被忽略。用户正在录像时,拍照按钮应该禁用,滤镜切换也应该禁用。反过来,拍照模式下录像按钮不可用。

我习惯用一个枚举来管理状态:

enum class CameraState {
    IDLE,           // 空闲
    CAPTURING,      // 拍照中(短暂状态)
    RECORDING       // 录像中
}

private var currentState = CameraState.IDLE

fun updateUIForState(state: CameraState) {
    when (state) {
        CameraState.IDLE -> {
            btnCapture.isEnabled = true
            btnRecord.isEnabled = true
            btnFilter.isEnabled = true
            btnSwitch.isEnabled = true
        }
        CameraState.CAPTURING -> {
            btnCapture.isEnabled = false
            btnRecord.isEnabled = false
            btnFilter.isEnabled = false
            btnSwitch.isEnabled = false
        }
        CameraState.RECORDING -> {
            btnCapture.isEnabled = false
            btnRecord.text = "停止"
            btnFilter.isEnabled = false
            btnSwitch.isEnabled = false
        }
    }
}

说白了,状态管理就是一把锁。锁住了,用户就不能乱操作。等当前任务完成,再解锁。

完整流程梳理

最后,我把整个流程串起来,方便你理解:

  1. 初始化:申请权限 → 创建 PreviewView → 绑定 CameraX UseCase(ImageCapture + VideoCapture + Preview)
  2. 拍照:点击拍照按钮 → ImageCapture.takePicture() → 保存到文件 → 更新相册
  3. 录像:点击录像按钮 → VideoCapture.startRecording() → 再次点击停止 → 保存 MP4
  4. 滤镜:点击滤镜按钮 → 切换 OpenGL 着色器 → 实时渲染
  5. 缩放:双指手势 → ScaleGestureDetector → CameraControl.setZoomRatio()
  6. 对焦:点击屏幕 → 坐标转换 → startFocusAndMetering()
  7. 切换摄像头:点击切换按钮 → 重新绑定 CameraSelector

每一步都不难,但合在一起就需要细心了。尤其是状态管理和手势冲突,这两个是 bug 的高发区。

最后一个小建议:开发过程中,多打 Log,把每个状态切换、每个手势事件都打印出来。调试时你会感谢这些日志的。


公众号:蓝海资料掘金营,微信deep3321