Jetpack Compose + CameraX
考虑创建相机应用程序还是需要在应用程序中录制视频? CameraX 库是一个很好的方法。 今天,我将向您解释如何使用 Google 推荐的 CameraX 库创建相机应用程序。
“CameraX 是一个 Jetpack 库,旨在帮助简化相机应用程序的开发。”
您可以在以下几个用例中使用 CameraX:
- 图像捕获 - 保存图像
- 视频捕获 - 保存视频和音频
- 预览 - 在显示器上查看图像
- 图像分析 - 无缝访问缓冲区以用于您的算法
在本文中,我们将介绍视频捕获,因为它不是那么受欢迎的主题。
视频截取
首先,让我们添加一些依赖项:
// CameraX
cameraxVersion = '1.2.0-beta01'
implementation "androidx.camera:camera-lifecycle:$cameraxVersion"
implementation "androidx.camera:camera-video:$cameraxVersion"
implementation "androidx.camera:camera-view:$cameraxVersion"
implementation "androidx.camera:camera-extensions:$cameraxVersion"
// Accompanist
accompanistPermissionsVersion = '0.23.1'
implementation "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion"
现在,我们的主屏幕将录制视频,但首先,我们需要请求摄像头和音频权限。 正如我在之前的一篇文章中已经解释的那样,不会详细介绍,如果您需要更多解释,请查看。
val permissionState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
LaunchedEffect(Unit) {
permissionState.launchMultiplePermissionRequest()
}
PermissionsRequired(
multiplePermissionsState = permissionState,
permissionsNotGrantedContent = { /* ... */ },
permissionsNotAvailableContent = { /* ... */ }
) {
// Rest of the compose code will be here
}
现在我们将创建几个录制视频所需的对象。
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var recording: Recording? = remember { null }
val previewView: PreviewView = remember { PreviewView(context) }
val videoCapture: MutableState<VideoCapture<Recorder>?> = remember { mutableStateOf(null) }
val recordingStarted: MutableState<Boolean> = remember { mutableStateOf(false) }
val audioEnabled: MutableState<Boolean> = remember { mutableStateOf(false) }
val cameraSelector: MutableState<CameraSelector> = remember {
mutableStateOf(CameraSelector.DEFAULT_BACK_CAMERA)
}
LaunchedEffect(previewView) {
videoCapture.value = context.createVideoCaptureUseCase(
lifecycleOwner = lifecycleOwner,
cameraSelector = cameraSelector.value,
previewView = previewView
)
}
Recording 是一个允许我们控制当前活动记录的对象。它将允许我们停止、暂停和恢复当前录制。我们在开始录制时创建该对象。
PreviewView 是一个自定义视图,将显示相机源。我们将它绑定到生命周期,将它添加到 AndroidView 中,它会向我们展示我们当前正在录制的内容。
VideoCapture 是一个通用类,它提供适用于视频应用程序的摄像头流。这里我们传递了 Recorder 类,它是 VideoOutput 接口的实现,它允许我们开始录制。
recordingStarted 和 audioEnabled 是我们将在此屏幕中使用的辅助变量,我认为它们几乎是不言自明的。
CameraSelector 是一组要求和优先级,用于选择相机或返回一组过滤的相机。在这里,我们将只使用默认的前后摄像头。
在 LaunchedEffect 中,我们调用了一个函数,该函数将为我们创建一个视频捕获用例。该函数如下所示:
suspend fun Context.createVideoCaptureUseCase(
lifecycleOwner: LifecycleOwner,
cameraSelector: CameraSelector,
previewView: PreviewView
): VideoCapture<Recorder> {
val preview = Preview.Builder()
.build()
.apply { setSurfaceProvider(previewView.surfaceProvider) }
val qualitySelector = QualitySelector.from(
Quality.FHD,
FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
)
val recorder = Recorder.Builder()
.setExecutor(mainExecutor)
.setQualitySelector(qualitySelector)
.build()
val videoCapture = VideoCapture.withOutput(recorder)
val cameraProvider = getCameraProvider()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
videoCapture
)
return videoCapture
}
首先,我们创建一个 Preview,它是一个提供用于在屏幕上显示的相机预览流的用例。我们可以在这里设置多个东西,比如纵横比、捕获处理器、图像信息处理器等等。我们不需要它们,所以我们创建普通的 Preview 对象。
接下来是选择我们视频的质量。为此,我们使用 QualitySelector 来定义所需的质量设置。我们想要全高清质量,所以我们将通过 Quality.FHD。有些手机的质量可能不理想,因此您应该始终有一个备份计划,就像我们在这里通过 FallbackStrategy 所做的那样。有几个策略:
- HigherQualityOrLowerThan — 选择最接近并高于输入质量的质量。如果这不能产生受支持的质量,请选择最接近并低于输入质量的质量
- HigherQualityThan - 选择最接近并高于输入质量的质量
- lowerQualityOrHigherThan — 选择最接近并低于输入质量的质量。如果这不能产生受支持的质量,请选择最接近并高于输入质量的质量
- lowerQualityThan - 选择最接近并高于输入质量的质量
另一种方法是通过 Quality.LOWEST 或 Quality.HIGHEST,这可能是更简单的方法,但我也想展示这个。
现在我们创建一个 Recorder 并使用它通过调用 VideoCapture.withOutput(recorder) 来获取 VideoCapture 对象。
相机提供者是 ProcessCameraProvider 单例的对象,它允许我们将相机的生命周期绑定到应用程序进程中的任何 LifecycleOwner。我们用来获取相机提供程序的函数是:
suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation ->
ProcessCameraProvider.getInstance(this).also { future ->
future.addListener(
{
continuation.resume(future.get())
},
mainExecutor
)
}
}
ProcessCameraProvider.getInstance(this) 正在返回我们需要等待完成以获取实例的未来。
接下来,我们需要将所有内容绑定到生命周期,并传递lifecycleOwner、cameraSelector、preview 和videoCapture。
现在是时候完成剩下的撰写代码了,我希望你还在我身边!
在 PermissionsRequired 内容块中,我们添加了 AndroidView 和用于录制的按钮。 像这样:
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
IconButton(
onClick = {
if (!recordingStarted.value) {
videoCapture.value?.let { videoCapture ->
recordingStarted.value = true
val mediaDir = context.externalCacheDirs.firstOrNull()?.let {
File(it, context.getString(R.string.app_name)).apply { mkdirs() }
}
recording = startRecordingVideo(
context = context,
filenameFormat = "yyyy-MM-dd-HH-mm-ss-SSS",
videoCapture = videoCapture,
outputDirectory = if (mediaDir != null && mediaDir.exists()) mediaDir else context.filesDir,
executor = context.mainExecutor,
audioEnabled = audioEnabled.value
) { event ->
// Process events that we get while recording
}
}
} else {
recordingStarted.value = false
recording?.stop()
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(if (recordingStarted.value) R.drawable.ic_stop else R.drawable.ic_record),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
AndroidView 将显示我们的预览。
至于按钮,我们将使用它来开始和停止录制。 当我们要开始录制时,我们首先获取将放置视频的媒体目录,如果该目录不存在,我们只需创建它。 接下来是调用 startRecordingVideo 函数,如下所示:
fun startRecordingVideo(
context: Context,
filenameFormat: String,
videoCapture: VideoCapture<Recorder>,
outputDirectory: File,
executor: Executor,
audioEnabled: Boolean,
consumer: Consumer<VideoRecordEvent>
): Recording {
val videoFile = File(
outputDirectory,
SimpleDateFormat(filenameFormat, Locale.US).format(System.currentTimeMillis()) + ".mp4"
)
val outputOptions = FileOutputOptions.Builder(videoFile).build()
return videoCapture.output
.prepareRecording(context, outputOptions)
.apply { if (audioEnabled) withAudioEnabled() }
.start(executor, consumer)
}
一个创建文件、准备录音并启动它的简单函数。 如果启用了音频,我们还将在启用音频的情况下开始录制。 此函数返回的对象,我们将使用它来停止录制。 消费者参数是一个回调,将在每个事件上调用。 您可以在视频录制完成后使用它来获取文件的 URI。
让我们添加音频和相机选择器的逻辑。
if (!recordingStarted.value) {
IconButton(
onClick = {
audioEnabled.value = !audioEnabled.value
},
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(if (audioEnabled.value) R.drawable.ic_mic_on else R.drawable.ic_mic_off),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
}
if (!recordingStarted.value) {
IconButton(
onClick = {
cameraSelector.value =
if (cameraSelector.value == CameraSelector.DEFAULT_BACK_CAMERA) CameraSelector.DEFAULT_FRONT_CAMERA
else CameraSelector.DEFAULT_BACK_CAMERA
lifecycleOwner.lifecycleScope.launch {
videoCapture.value = context.createVideoCaptureUseCase(
lifecycleOwner = lifecycleOwner,
cameraSelector = cameraSelector.value,
previewView = previewView
)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_switch_camera),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
}
它们是两个按钮,可以启用-禁用音频并在前后摄像头之间切换。 当我们在摄像机之间切换时,我们需要创建一个新的 videoCapture 对象来更改预览显示的内容。
这就是这个屏幕的内容,但现在很高兴看到我们录制的内容对不对? 当然,为此,我们将创建另一个屏幕并使用 ExoPlayer 显示视频。
让我们首先在我们的消费者回调中添加逻辑:
if (event is VideoRecordEvent.Finalize) {
val uri = event.outputResults.outputUri
if (uri != Uri.EMPTY) {
val uriEncoded = URLEncoder.encode(
uri.toString(),
StandardCharsets.UTF_8.toString()
)
navController.navigate("${Route.VIDEO_PREVIEW}/$uriEncoded")
}
}
如果 event 是 VideoRecordEvent.Finalize,则表示录制完成,我们可以获取视频的 URI。 有几个视频记录事件,您可以使用其中任何一个,但在这里我们只需要 Finalize:
- Start
- Finalize
- Status
- Pause
- Resume
如果视频太短,URI 可以为空,比如不到半秒或类似的东西,这就是我们需要 if 语句的原因。
应该对 URI 进行编码以将其作为导航参数传递。
这个屏幕的最终代码如下所示:
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VideoCaptureScreen(
navController: NavController
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val permissionState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
var recording: Recording? = remember { null }
val previewView: PreviewView = remember { PreviewView(context) }
val videoCapture: MutableState<VideoCapture<Recorder>?> = remember { mutableStateOf(null) }
val recordingStarted: MutableState<Boolean> = remember { mutableStateOf(false) }
val audioEnabled: MutableState<Boolean> = remember { mutableStateOf(false) }
val cameraSelector: MutableState<CameraSelector> = remember {
mutableStateOf(CameraSelector.DEFAULT_BACK_CAMERA)
}
LaunchedEffect(Unit) {
permissionState.launchMultiplePermissionRequest()
}
LaunchedEffect(previewView) {
videoCapture.value = context.createVideoCaptureUseCase(
lifecycleOwner = lifecycleOwner,
cameraSelector = cameraSelector.value,
previewView = previewView
)
}
PermissionsRequired(
multiplePermissionsState = permissionState,
permissionsNotGrantedContent = { /* ... */ },
permissionsNotAvailableContent = { /* ... */ }
) {
Box(
modifier = Modifier.fillMaxSize()
) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
IconButton(
onClick = {
if (!recordingStarted.value) {
videoCapture.value?.let { videoCapture ->
recordingStarted.value = true
val mediaDir = context.externalCacheDirs.firstOrNull()?.let {
File(it, context.getString(R.string.app_name)).apply { mkdirs() }
}
recording = startRecordingVideo(
context = context,
filenameFormat = "yyyy-MM-dd-HH-mm-ss-SSS",
videoCapture = videoCapture,
outputDirectory = if (mediaDir != null && mediaDir.exists()) mediaDir else context.filesDir,
executor = context.mainExecutor,
audioEnabled = audioEnabled.value
) { event ->
if (event is VideoRecordEvent.Finalize) {
val uri = event.outputResults.outputUri
if (uri != Uri.EMPTY) {
val uriEncoded = URLEncoder.encode(
uri.toString(),
StandardCharsets.UTF_8.toString()
)
navController.navigate("${Route.VIDEO_PREVIEW}/$uriEncoded")
}
}
}
}
} else {
recordingStarted.value = false
recording?.stop()
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(if (recordingStarted.value) R.drawable.ic_stop else R.drawable.ic_record),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
if (!recordingStarted.value) {
IconButton(
onClick = {
audioEnabled.value = !audioEnabled.value
},
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(if (audioEnabled.value) R.drawable.ic_mic_on else R.drawable.ic_mic_off),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
}
if (!recordingStarted.value) {
IconButton(
onClick = {
cameraSelector.value =
if (cameraSelector.value == CameraSelector.DEFAULT_BACK_CAMERA) CameraSelector.DEFAULT_FRONT_CAMERA
else CameraSelector.DEFAULT_BACK_CAMERA
lifecycleOwner.lifecycleScope.launch {
videoCapture.value = context.createVideoCaptureUseCase(
lifecycleOwner = lifecycleOwner,
cameraSelector = cameraSelector.value,
previewView = previewView
)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 32.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_switch_camera),
contentDescription = "",
modifier = Modifier.size(64.dp)
)
}
}
}
}
}
ExoPlayer
ExoPlayer 是 Android 的 MediaPlayer API 的替代品,用于在本地和互联网上播放音频和视频。 它更易于使用并提供更多功能。 此外,它很容易定制和扩展。
现在,当我们知道什么是 ExoPlayer 后,让我们创建下一个屏幕。 添加依赖:
//ExoPlayer Library
exoPlayerVersion = '2.18.1'
implementation "com.google.android.exoplayer:exoplayer:$exoPlayerVersion"
我们的屏幕应该是这样的:
@Composable
fun VideoPreviewScreen(
uri: String
) {
val context = LocalContext.current
val exoPlayer = remember(context) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(uri))
prepare()
}
}
DisposableEffect(
Box(
modifier = Modifier.fillMaxSize()
) {
AndroidView(
factory = { context ->
StyledPlayerView(context).apply {
player = exoPlayer
}
},
modifier = Modifier.fillMaxSize()
)
}
) {
onDispose {
exoPlayer.release()
}
}
}
我们将使用构建器创建 ExoPlayer,设置将要加载的视频的 URI,然后准备播放器。
我们使用 AndroidView 来显示我们的视频,并将 StyledPlayerView 附加到它。
StyledPlayerView 是 Player 媒体播放的高级视图。 它在播放期间显示视频、字幕和专辑封面,并使用 StyledPlayerControlView 显示播放控件。
StyledPlayerView 可以通过设置属性(或者调用相应的方法),或者覆盖drawable来自定义。
这就是我们的录像机,我希望你在这篇文章中学到了一些新的东西并且你喜欢它。
关注七爪网,获取更多APP/小程序/网站源码资源!