为了在 Jetpack Compose 环境下开发一个使用后置摄像头采集视频,并在帧差异检测的基础上自动录制的 Android 应用,我们可以使用 CameraX 和 ViewModel,同时结合 FTP 上传功能。
下面这个完整示例展示了如何使用 Jetpack Compose 和 CameraX 编写这个应用。
添加依赖
在 build.gradle 中添加所需的依赖:
dependencies {
? ? implementation "androidx.compose.ui:ui:1.3.0"
? ? implementation "androidx.compose.material:material:1.3.0"
? ? implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
? ? implementation "androidx.camera:camera-camera2:1.1.0"
? ? implementation "androidx.camera:camera-lifecycle:1.1.0"
? ? implementation "androidx.camera:camera-video:1.0.0-alpha28"
? ? implementation "androidx.camera:camera-view:1.1.0"
? ? implementation 'commons-net:commons-net:3.6'
? ? implementation 'androidx.activity:activity-compose:1.3.1'
}
权限配置
在 AndroidManifest.xml 中添加所需的权限:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
创建 ViewModel
我们需要一个 ViewModel 来管理摄像头的操作、视频帧分析和录像控制。
创建 CameraViewModel.kt:
? ?import android.content.Context
? ? import android.net.Uri
? ? import android.os.Handler
? ? import android.os.Looper
? ? import android.util.Log
? ? import androidx.camera.core.*
? ? import androidx.camera.lifecycle.ProcessCameraProvider
? ? import androidx.camera.video.*
? ? import androidx.compose.runtime.mutableStateOf
? ? import androidx.compose.runtime.remember
? ? import androidx.core.content.ContextCompat
? ? import androidx.lifecycle.ViewModel
? ? import androidx.lifecycle.viewModelScope
? ? import kotlinx.coroutines.Dispatchers
? ? import kotlinx.coroutines.launch
? ? import java.io.File
? ? import java.text.SimpleDateFormat
? ? import java.util.*
? ? import java.util.concurrent.ExecutorService
? ? import java.util.concurrent.Executors
? ? class CameraViewModel : ViewModel() {
? ? ? ? var isRecording = mutableStateOf(false)
? ? ? ? var previewUri = mutableStateOf<Uri?>(null)
? ? ? ? private var videoCapture: VideoCapture<Recorder>? = null
? ? ? ? private var recording: Recording? = null
? ? ? ? private lateinit var cameraExecutor: ExecutorService
? ? ? ? fun startCamera(context: Context, previewView: PreviewView) {
? ? ? ? ? ? val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
? ? ? ? ? ? cameraProviderFuture.addListener({
? ? ? ? ? ? ? ? val cameraProvider = cameraProviderFuture.get()
? ? ? ? ? ? ? ? val preview = Preview.Builder().build().also {
? ? ? ? ? ? ? ? ? ? it.setSurfaceProvider(previewView.surfaceProvider)
? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? val recorder = Recorder.Builder()
? ? ? ? ? ? ? ? ? ? .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
? ? ? ? ? ? ? ? ? ? .build()
? ? ? ? ? ? ? ? videoCapture = VideoCapture.withOutput(recorder)
? ? ? ? ? ? ? ? val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
? ? ? ? ? ? ? ? try {
? ? ? ? ? ? ? ? ? ? cameraProvider.unbindAll()
? ? ? ? ? ? ? ? ? ? cameraProvider.bindToLifecycle(
? ? ? ? ? ? ? ? ? ? ? ? context as androidx.lifecycle.LifecycleOwner,
? ? ? ? ? ? ? ? ? ? ? ? cameraSelector,
? ? ? ? ? ? ? ? ? ? ? ? preview,
? ? ? ? ? ? ? ? ? ? ? ? videoCapture
? ? ? ? ? ? ? ? ? ? )
? ? ? ? ? ? ? ? } catch (exc: Exception) {
? ? ? ? ? ? ? ? ? ? Log.e("CameraViewModel", "Use case binding failed", exc)
? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? detectMotionAndRecord(context) // Start detecting motion and recording
? ? ? ? ? ? }, ContextCompat.getMainExecutor(context))
? ? ? ? ? ? cameraExecutor = Executors.newSingleThreadExecutor()
? ? ? ? }
? ? ? ? private fun detectMotionAndRecord(context: Context) {
? ? ? ? ? ? var prevFrame: ImageProxy? = null
? ? ? ? ? ? val handler = Handler(Looper.getMainLooper())
? ? ? ? ? ? val delayMillis = 100L
? ? ? ? ? ? val captureRunnable = object : Runnable {
? ? ? ? ? ? ? ? override fun run() {
? ? ? ? ? ? ? ? ? ? if (isRecording.value) {
? ? ? ? ? ? ? ? ? ? ? ? recording?.stop()
? ? ? ? ? ? ? ? ? ? ? ? isRecording.value = false
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? // Capture frame
? ? ? ? ? ? ? ? ? ? videoCapture?.takePicture(cameraExecutor, ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageCapturedCallback() {
? ? ? ? ? ? ? ? ? ? ? ? override fun onCaptureSuccess(image: ImageProxy) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? if (prevFrame == null) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? prevFrame = image
? ? ? ? ? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? // Compare frames
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? val isDifferent = compareFrames(prevFrame!!, image)
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? prevFrame?.close()
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? prevFrame = image
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? if (isDifferent) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? if (!isRecording.value) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? startRecording(context)
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? isRecording.value = true
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? if (System.currentTimeMillis() - recordingStartTime > 5000) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? recording?.stop()
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? isRecording.value = false
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? handler.postDelayed(this, delayMillis)
? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? override fun onError(exception: ImageCaptureException) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? Log.e("CameraViewModel", "Image capture failed", exception)
? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? })
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? ? ? handler.post(captureRunnable)
? ? ? ? }
? ? ? ? private var recordingStartTime = 0L
? ? ? ? private fun compareFrames(frame1: ImageProxy, frame2: ImageProxy): Boolean {
? ? ? ? ? ? // Implement frame comparison logic (e.g., by comparing pixel values)
? ? ? ? ? ? return true // example return, implement actual comparison
? ? ? ? }
? ? ? ? private fun startRecording(context: Context) {
? ? ? ? ? ? val fileName = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
? ? ? ? ? ? ? ? .format(System.currentTimeMillis()) + ".mp4"
? ? ? ? ? ? val file = File(context.externalMediaDirs.first(), fileName)
? ? ? ? ? ? val outputOptions = VideoCapture.OutputFileOptions.Builder(file).build()
? ? ? ? ? ? recordingStartTime = System.currentTimeMillis()
? ? ? ? ? ? videoCapture?.startRecording(
? ? ? ? ? ? ? ? outputOptions,
? ? ? ? ? ? ? ? ContextCompat.getMainExecutor(context),
? ? ? ? ? ? ? ? object : VideoCapture.OnVideoSavedCallback {
? ? ? ? ? ? ? ? ? ? override fun onVideoSaved(outputFileResults: VideoCapture.OutputFileResults) {
? ? ? ? ? ? ? ? ? ? ? ? val savedUri = Uri.fromFile(file)
? ? ? ? ? ? ? ? ? ? ? ? previewUri.value = savedUri
? ? ? ? ? ? ? ? ? ? ? ? uploadToFTP(file)
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
? ? ? ? ? ? ? ? ? ? ? ? Log.e("CameraViewModel", "Video capture failed: $message", cause)
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? })
? ? ? ? }
? ? ? ? private fun uploadToFTP(file: File) {
? ? ? ? ? ? viewModelScope.launch(Dispatchers.IO) {
? ? ? ? ? ? ? ? FTPUpload.uploadFile(
? ? ? ? ? ? ? ? ? ? file.path,
? ? ? ? ? ? ? ? ? ? "192.168.10.100", 21,
? ? ? ? ? ? ? ? ? ? "yyy", "xxx"
? ? ? ? ? ? ? ? )
? ? ? ? ? ? }
? ? ? ? }
? ? }
创建UI
在MainActivity.kt中设置UI:
? ? import android.Manifest
? ? import android.os.Bundle
? ? import androidx.activity.ComponentActivity
? ? import androidx.activity.compose.setContent
? ? import androidx.activity.result.contract.ActivityResultContracts
? ? import androidx.activity.viewModels
? ? import androidx.compose.foundation.layout.*
? ? import androidx.compose.material.*
? ? import androidx.compose.runtime.collectAsState
? ? import androidx.compose.runtime.remember
? ? import androidx.compose.ui.Modifier
? ? import androidx.compose.ui.unit.dp
? ? import androidx.core.content.ContextCompat
? ? import com.google.accompanist.permissions.ExperimentalPermissionsApi
? ? import com.google.accompanist.permissions.rememberMultiplePermissionsState
? ? import com.google.accompanist.permissions.shouldShowRationale
? ? import java.util.*
? ? class MainActivity : ComponentActivity() {
? ? ? ? private val cameraViewModel: CameraViewModel by viewModels()
? ? ? ? override fun onCreate(savedInstanceState: Bundle?) {
? ? ? ? ? ? super.onCreate(savedInstanceState)
? ? ? ? ? ? setContent {
? ? ? ? ? ? ? ? JetpackComposeCameraTheme {
? ? ? ? ? ? ? ? ? ? Surface(modifier = Modifier.fillMaxSize()) {
? ? ? ? ? ? ? ? ? ? ? ? val context = remember { this }
? ? ? ? ? ? ? ? ? ? ? ? val permissionState = rememberMultiplePermissionsState(
? ? ? ? ? ? ? ? ? ? ? ? ? ? permissions = listOf(
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Manifest.permission.CAMERA,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Manifest.permission.RECORD_AUDIO,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Manifest.permission.WRITE_EXTERNAL_STORAGE,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Manifest.permission.READ_EXTERNAL_STORAGE
? ? ? ? ? ? ? ? ? ? ? ? ? ? )
? ? ? ? ? ? ? ? ? ? ? ? )
? ? ? ? ? ? ? ? ? ? ? ? if (permissionState.allPermissionsGranted) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? CameraPreview(cameraViewModel)
? ? ? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? Column(
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Modifier
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? .fillMaxSize()
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? .padding(16.dp)
? ? ? ? ? ? ? ? ? ? ? ? ? ? ) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Text("Permissions need to be granted to use the camera")
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Spacer(modifier = Modifier.height(8.dp))
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? if (permissionState.shouldShowRationale) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Text("Please allow the permissions.")
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? OutlinedButton(onClick = { permissionState.launchMultiplePermissionRequest() }) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Text("Allow")
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? }
? ? @Composable
? ? fun CameraPreview(cameraViewModel: CameraViewModel) {
? ? ? ? val context = LocalContext.current
? ? ? ? val isRecording = cameraViewModel.isRecording.collectAsState()
? ? ? ? Column(modifier = Modifier.fillMaxSize()) {
? ? ? ? ? ? Box(modifier = Modifier
? ? ? ? ? ? ? ? .weight(1f)
? ? ? ? ? ? ? ? .fillMaxSize()) {
? ? ? ? ? ? ? ? AndroidView(
? ? ? ? ? ? ? ? ? ? factory = { ctx ->
? ? ? ? ? ? ? ? ? ? ? ? val previewView = PreviewView(ctx)
? ? ? ? ? ? ? ? ? ? ? ? cameraViewModel.startCamera(ctx, previewView)
? ? ? ? ? ? ? ? ? ? ? ? previewView
? ? ? ? ? ? ? ? ? ? },
? ? ? ? ? ? ? ? ? ? modifier = Modifier.fillMaxSize()
? ? ? ? ? ? ? ? )
? ? ? ? ? ? }
? ? ? ? ? ? Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
? ? ? ? ? ? ? ? Button(onClick = { /* Trigger cameraViewModel logic here */ },
? ? ? ? ? ? ? ? ? ? modifier = Modifier.padding(16.dp)) {
? ? ? ? ? ? ? ? ? ? Text("Start Recording")
? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? Button(onClick = { /* Trigger cameraViewModel logic here */ },
? ? ? ? ? ? ? ? ? ? modifier = Modifier.padding(16.dp)) {
? ? ? ? ? ? ? ? ? ? Text("Stop Recording")
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? }
实现FTP上传功能
创建FTPUpload.kt以实现FTP上传功能:
? ? import org.apache.commons.net.ftp.FTP
? ? import org.apache.commons.net.ftp.FTPClient
? ? import java.io.File
? ? import java.io.FileInputStream
? ? import java.io.IOException
? ? object FTPUpload {
? ? ? ? fun uploadFile(filePath: String, server: String, port: Int, user: String, pass: String) {
? ? ? ? ? ? val ftpClient = FTPClient()
? ? ? ? ? ? try {
? ? ? ? ? ? ? ? ftpClient.connect(server, port)
? ? ? ? ? ? ? ? ftpClient.login(user, pass)
? ? ? ? ? ? ? ? ftpClient.enterLocalPassiveMode()
? ? ? ? ? ? ? ? ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
? ? ? ? ? ? ? ? val dateFolder = SimpleDateFormat("yyyyMMdd", Locale.US).format(System.currentTimeMillis())
? ? ? ? ? ? ? ? ftpClient.makeDirectory(dateFolder)
? ? ? ? ? ? ? ? ftpClient.changeWorkingDirectory(dateFolder)
? ? ? ? ? ? ? ? val firstRemoteFile = File(filePath).name
? ? ? ? ? ? ? ? val inputStream = FileInputStream(filePath)
? ? ? ? ? ? ? ? println("Start uploading first file")
? ? ? ? ? ? ? ? val done = ftpClient.storeFile(firstRemoteFile, inputStream)
? ? ? ? ? ? ? ? inputStream.close()
? ? ? ? ? ? ? ? if (done) {
? ? ? ? ? ? ? ? ? ? println("File is uploaded successfully.")
? ? ? ? ? ? ? ? }
? ? ? ? ? ? } catch (ex: IOException) {
? ? ? ? ? ? ? ? ex.printStackTrace()
? ? ? ? ? ? } finally {
? ? ? ? ? ? ? ? try {
? ? ? ? ? ? ? ? ? ? if (ftpClient.isConnected) {
? ? ? ? ? ? ? ? ? ? ? ? ftpClient.logout()
? ? ? ? ? ? ? ? ? ? ? ? ftpClient.disconnect()
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? } catch (ex: IOException) {
? ? ? ? ? ? ? ? ? ? ex.printStackTrace()
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? }
通过这些代码片段,可以使用Jetpack Compose开发一个能够通过手机后置主摄像头录制视频、检测画面差异、实时预览并将视频上传至FTP服务器的Android应用。
个人博客:visen123.github.io