Compose结合CameraX快速实现相机“拍视频实时滤镜“、”拍照+滤镜“

2,121 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

一、前言

短视频热潮还没有褪去,写这篇文章主要是帮助大部分人,能快速上手实现类似效果, 实际上是: CameraX拿相机数据,OpenGL给CameraX提供一个Surface,数据放到OpenGL渲染的线程上去做图像相关操作

OpenGL滤镜和录制视频核心代码来自github.com/aserbao/And… ,其实它里面用了Googlegrafika里面的视频录制的核心代码 github.com/google/graf… 感兴趣的小伙伴,可以细读哦。

注意:文章末尾会贴上本篇文章的最终源代码。

SVID_20220828_070549_1.gif
录屏速度过快,文章末尾下载源码体验

网上有太多太多,将解OpenGL的文章内容,同样的,也很少有文章教大家如何去快速集成实现,大多数文章,上来就是一大篇和别的文章雷同的基础知识点讲解,容易看睡着,对于没有这方面基础的很难啃。

二、CameraX拍照的问题

1. CameraX去拍照片:默认图片质量大(可调整),但是:拍照数据返回

2. 没有什么用的CameraX拓展:这玩意并不适合我们,没有这个拓展就用不了,那要这个拓展有什么用。

3. 拍视频并不能ImageAnalysis做滤镜,拍照片可以用ImageAnalysis做滤镜效果

三、自定义GLTextureView

基于AndroidCamera 定制一个GLCameraView方便支持我们的Activity动画打开。

因为我们要给CameraX提供surfaceProvider,所以我们需要实现SurfaceProvider接口

class GLCameraView(context: Context, private val ratio: Float) : GLTextureView(context), SurfaceProvider {
    override fun onSurfaceRequested(request: SurfaceRequest) {
        val resetTexture = resetPreviewTexture(request.resolution) ?: return
        val surface = Surface(resetTexture)
        request.provideSurface(surface, executor) {
            surface.release()
            if(!fromCameraLensFacing) {
                // 注意:切换前置和后置摄像头的时候,不能release surfaceTexture
                surfaceTexture?.release()
            }
        }
    }
    // 其他细节,可以直接用我们文章末尾的源码
}

根据方法提供的参数设置SurfaceTexture#setDefaultBufferSize,然后用surfaceTexture创建一个Surface,将它提供给SurfaceRequest

后面我们结合CameraX的时候就可以把GLCameraView传给Preview#setSurfaceProvider

四、CameraX + GLCameraView预览

1. 提供一个getCameraProvider()将相机的生命周期绑定到 LifecycleOwner

private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
    suspendCoroutine { continuation ->
        ProcessCameraProvider.getInstance(this).also { cameraProvider ->
            cameraProvider.addListener(
                {
                  continuation.resume(cameraProvider.get())
                }, ContextCompat.getMainExecutor(this)
            )
        }
    }

2. 配置相机视图

@Composable
fun CameraXView(
    modifier: Modifier,
    lensFacing: Int,
    content: @Composable (GLCameraView) -> Unit
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    val glCameraPreview = remember {
        // 配置9:16
        GLCameraView(context, 9 / 16F)
    }
    val scope = remember {
        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    }
    LaunchedEffect(lensFacing) {
        val cameraProvider = context.getCameraProvider()
        // 解除生命周期绑定,并从 CameraX 中移除
        cameraProvider.unbindAll()

        val preview = Preview.Builder().apply {
            // 设置成9:16
            setTargetAspectRatio(AspectRatio.RATIO_16_9)
        }.build()

        // 需要在preview.setSurfaceProvider前面调用
        glCameraPreview.switchCameraLensFacing(true,lensFacing)
        preview.setSurfaceProvider(glCameraPreview)

        // 设置前置、后置摄像头切换
        val cameraSelector = CameraSelector.Builder()
            .requireLensFacing(lensFacing)
            .build()
            
        kotlin.runCatching {
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview
            )
        }
    }
    Box(...) {
        DisposableEffect(
            AndroidView(
                factory = { glCameraPreview },
                modifier = Modifier.fillMaxSize()
            )
        ) {
            onDispose {
                scope.launch {
                   glCameraPreview.switchCameraLensFacing(false,lensFacing)
                   val cameraProvider = context.getCameraProvider()
                   // 解除生命周期绑定,并从 CameraX 中移除
                   cameraProvider.unbindAll()
                }
            }
        }
        content(glCameraPreview)
    }
}

从上面的代码片段中,我们可以看到以下内容:

  1. 初始化ProcessCameraProvider的实例,将相机的生命周期绑定到LifecycleOwner
  2. LaunchedEffect(lensFacing) 只有切换前置/后置摄像头会触发里面的逻辑,需要调用cameraProvider.unbindAll() 解除生命周期绑定,并从 CameraX 中移除。
  3. 初始化Preview设置surfaceProvider,这里需要注意的是调用切换摄像头方向的方法需要在setSurfaceProvider前面调用。
  4. 通过CameraSelector构建切换的前置/后置摄像头。
  5. 调用cameraProvider.bindToLifecycle
  6. AndroidViewfactory里面返回我们的glCameraPreview
  7. onDispose里面我们需要调用cameraProvider.unbindAll()解除生命周期绑定,并从 CameraX 中移除。

五、CameraXView三方app如何使用

上面我们给出了CameraXView的实现,我们在content: @Composable (GLCameraView) -> Unit 这里面可以放我们的拍照/拍视频/其他菜单视图,如果是拍视频,需要控制slideGpuFilterGroupEnable,没有拍摄视频前,可以水平切换滤镜,拍摄中是不可以切换的。

1. 拍摄视频用法:

CameraXView(
    modifier = modifier,
    lensFacing = lensFacingProvider()
) { glCameraPreview ->
    LaunchedEffect(recordingStateProvider()) {
       if (null != recordingStateProvider()) {
          // 拍视频
          glCameraPreview.takeVideo(videoRecordingFileUri?.path ?: "")
       }
       // 为null的状态,才可以滑动切换滤镜
       glCameraPreview.slideGpuFilterGroupEnable = (recordingStateProvider() == null)
   }
   
   // TakeVideoOptionsBar里面显示的视频拍摄的时间
   val recordingTimeState = glCameraPreview.getRecordingTimeState().collectAsState()

   TakeVideoOptionsBar(
       // 用于控制里面按钮状态
       recordingState = recordingStateProvider(),
       // 视频拍摄时长(实时的,单位:分钟:秒:毫秒)
       recordingTime = recordingTimeState.value,
       onTakeVideo = {
           // 回调当前状态,再根据viewModel返回的state去做ui数据展示
           onTakeVideoClick.invoke(glCameraPreview.isRecording())
       },
       ....
    )
}

2. 拍照片用法:

CameraXView(
  modifier = modifier,
  lensFacing = lensFacingProvider()
) { glCameraPreview ->
    TakePhotoOptionsBar(
        onTakePhoto = {
            // OpenGL里面去取一帧图片数据,这个数据是秒生成的
            glCameraPreview.takePicture(takePictureFileUri?.path ?: "")
            // 直接通知ViewModel播放一个“快门声音”,并打开需要接收照片数据的页面
            currentOnTakePhotoClick.invoke()
        }
    )
}

这里再插一段,怎么播放快门声音,这里给大家介绍一下: Ringtone 提供了一种快速播放铃声、通知或其他类似类型声音的方法,我们可以看到官方文档里面提示我们去看RingtoneManager,当然,你也可以用SoundPool

我们在RingtoneManager找到getRingtone可以根据音频文件的Uri返回一个Ringtone,然后可以拿Ringtone#play去播放一段音频,还没结束,我们还需要配置一下音频属性AudioAttributes:

AudioAttributes.Builder().setUsage(...).build()

我们可以看到AudioAttributes.Builder#setUsage,有下面这么多usage可配置

QQ20220828-065851@2x.png

篇幅问题,更多细节,大家可以仔细查看官方的文档,这里我们用的是:AudioAttributes.USAGE_MEDIA

当使用是媒体(如音乐或电影配乐)时使用的值

所以:只要我们的设备播放音乐或者视频能听到声音,那么我们这个快门播放也同样可以听到声音。

从上面的用法,我们可以看出:

  1. 可以在CameraXViewcontent: @Composable (GLCameraView) -> Unit 这里面放我们相机页面的其他菜单和操作视图
  2. 拍照基本上是秒生成返回图片数据,比CameraX直接去拍照快的不止一点两点
  3. 照片和视频都支持水平滑动切换滤镜,同样,外部可以通过当前拍摄视频状态,来控制“水平滑动切换滤镜”功能是否可用
  4. 拍照完成有快门声音播放。

六、处理grafik视频保存问题

无论是AndroidCamera还是grafika它们两个用的都是同一套视频录制方法,同样的在源码中一开始注释的内容是:

// We don't know when these will actually finish (or even start). 
// We don't want to delay the UI thread though, so we return immediately.

很显然,它并不知道这些何时会真正完成,那么怎么去解决呢?

点击停止录制的时候,肯定需要找到stopRecording对吧,我们进这里面看看

QQ20220827-214301@2x.png

这个骚操作,我们得治治,我们先调用:

mHandler.sendMessage(mHandler.obtainMessage(MSG_QUIT))

然后再直接调用handleStopRecording方法,这里需要注意

需要将耗时操作移动到gl线程外面,否则OpenGL ES所在的线程被阻塞或者被挂起,导致渲染设备上下文丢失(直接表现就是:画面不会再动,无法继续绘制)

切记切记!!

所以这里我们可以放在子线程里面去执行handleStopRecording方法。

我们稍微修改了一下handleStopRecording方法里面的内容

private fun handleStopRecording() {
        Log.d(TAG, "handleStopRecording")
        kotlin.runCatching {
            mVideoEncoder?.drainEncoder(true)
        }
        mVideoEncoder?.stopAudRecord()
        releaseEncoder()
}

我们可以看到多了一个try/catch,为什么?drainEncoder方法的注释中写到它:

从编码器中提取所有待处理的数据并将其转发到复用器

CameraDrawer#onDrawFrame,系统会在每次重新绘制GLTextureView时调用此方法。 然后会触发我们的TextureMovieEncoder#handleFrameAvailable

QQ20220827-220104@2x.png

这里我们针对上面红色框区域的代码做调整:

QQ20220827-220412@2x.png

强行停止结束,会导致drainEncoder异常终止,需要捕获一下异常,如果发现异常同样需要停止后面的代码执行

我们在TextureMovieEncoder里面看到fun handleFrameAvailable的调用上一级来自TextureMovieEncoder#frameAvailable

同样我们需要调整这个方法里面的代码,调整前:

mHandler.sendMessage(mHandler.obtainMessage(MSG_FRAME_AVAILABLE,
     (int) (timestamp >> 32), (int) timestamp, transform));

调整后:

if(isRecording){
    mHandler?.apply {
        sendMessage(
            obtainMessage(
                MSG_FRAME_AVAILABLE,
                (timestamp shr 32).toInt(),
                timestamp.toInt(),
                transform
            )
        )
    }
}

我们再看一下TextureMovieEncoder的源码,里面只有触发了Looper#quitisRecording才为false,因为我们上面修改了TextureMovieEncoder#stopRecording方法,提前调用了Looper#quit,所以这里需要同步停止发送MSG_FRAME_AVAILABLE消息。

核心解决处理的关键代码,全部讲完。

下面就来看看我们最终提供给大家的项目功能视频吧,拍照和拍视频都支持水平滑动切换滤镜功能

SVID_20220828_070549_1.gif
录屏速度过快,可以下载源码体验

目前遗留有个问题是:切换前置摄像头和后置摄像头会有个颠倒的问题,除了这个其他功能,一切正常满足生产开发需求。

源码地址github.com/TheMelody/C…

点赞❤️+关注❤️+收藏❤️+评论❤️,划走了可就找不到了哦