赞
踩
在 Android 开发的历史中,Camera 的 API 是一直受人诟病的,使用过的人都知道,直观的感觉就是配置复杂、臃肿、难用、不易理解,从官方关于 Camera 的 API 迭代路线可以看出官方也在尝试着不断改进开发者关于Camera的使用体验,Camera 的 API 截止目前经历了 Camera(已废弃)、Camera2、CameraX 三个版本。
初代 Camera API从 5.0 开始已经宣布废弃,而 Camera2 的 API 特别难用,很多人用过之后直呼还不如以前的 Camera,所以就有了 CameraX ,它其实还是基于 Camera2 的,只不过使用上做了一些更人道的优化,它是 Jetpack 组件库的一部分,目前也是官方强推的 Camera 方案。所以,如果你有新项目涉及 Camera 的 API 或者打算对旧的 Camera API 进行升级,建议直接使用 CameraX。
本文主要探索如何在 Jetpack Compose 中使用 CameraX。
首先添加依赖:
dependencies {
def camerax_version = "1.3.0-alpha04"
// implementation "androidx.camera:camera-core:${camerax_version}" // 可选,因为camera-camera2 包含了camera-core
implementation "androidx.camera:camera-camera2:${
camerax_version}"
implementation "androidx.camera:camera-lifecycle:${
camerax_version}"
implementation "androidx.camera:camera-video:${
camerax_version}"
implementation "androidx.camera:camera-view:${
camerax_version}"
implementation "androidx.camera:camera-extensions:${
camerax_version}"
}
注,以上各个库的最新版本信息可以在这里查找:https://developer.android.com/jetpack/androidx/releases/camera?hl=zh-cn
由于使用相机需要进行Camera权限申请,所以还需要添加一个accompanist-permissions
依赖用于在 Compose 中申请权限:
val accompanist_version = "0.31.2-alpha"
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
注,以上库的最新版本信息可以在这里查找:https://github.com/google/accompanist/releases
然后记得在 AndroidManifest.xml
中添加权限声明:
<manifest .. >
<uses-permission android:name="android.permission.CAMERA" />
..
</manifest>
CameraX 具有以下最低版本要求:
对于能够感知生命周期的 Activity,请使用 FragmentActivity 或 AppCompatActivity。
下面主要看一下 CameraX 如何进行相机预览
由于 Jetpack Compose 中目前并没有直接提供一个单独的组件来专门用于Camera预览,因此办法还是使用 AndroidView
这个 Composable 来将原生的预览控件集成到 Compose 中显示。代码如下:
@Composable private fun CameraPreviewExample() { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding: PaddingValues -> AndroidView( modifier = Modifier .fillMaxSize() .padding(innerPadding), factory = { context -> PreviewView(context).apply { setBackgroundColor(Color.White.toArgb()) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) scaleType = PreviewView.ScaleType.FILL_START implementationMode = PreviewView.ImplementationMode.COMPATIBLE } } ) } }
这里的 PreviewView
是 camera-view
库中的一个原生View控件,下面看一下它的几个设置方法:
1.PreviewView.setImplementationMode()
:该方法用于设置适合应用的具体实现模式
实现模式
PreviewView
可以使用以下模式之一将预览流渲染到目标 View
上:
PERFORMANCE
是默认模式,PreviewView
会使用 SurfaceView
显示视频串流,但在某些情况下会回退为使用 TextureView
。SurfaceView
具有专用的绘图界面,该对象更有可能通过内部硬件合成器实现硬件叠加层,尤其是当预览视频上面没有其他界面元素(如按钮)时。通过使用硬件叠加层进行渲染,视频帧会避开 GPU 路径,从而能降低平台功耗并缩短延迟时间。
COMPATIBLE
模式,在此模式下,PreviewView
会使用 TextureView
。不同于 SurfaceView
,该对象没有专用的绘图表面。因此,视频要通过混合渲染,才能显示。在这个额外的步骤中,应用可以执行额外的处理工作,例如不受限制地缩放和旋转视频。
注:对于
PERFORMANCE
是默认模式,如果设备不支持SurfaceView
,则PreviewView
将回退为使用TextureView
。当API级别为24
或更低、相机硬件支持级别为CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
或Preview.getTargetRotation()
与PreviewView
的显示旋转不同时,PreviewView
会返回到TextureView
。
如果Preview.Builder.setTargetRotation(int)
设置为不同于显示器旋转的值,请不要使用此模式,因为SurfaceView
不支持任意旋转。如果“预览视图”需要设置动画,请不要使用此模式。API24
级或更低级别不支持SurfaceView
动画。此外,对于getPreviewStreamState
中提供的预览流状态,如果使用此模式,PreviewView.StreamState.streaming
状态可能会提前发生。
显然如果是为了性能考虑应该使用 PERFORMANCE
模式,但如果是为了兼容性考虑最好使用 COMPATIBLE
模式。
2.PreviewView.setScaleType()
:该方法用于设置最适合应用的缩放类型。
缩放类型
当预览视频分辨率与目标 PreviewView
的尺寸不同时,视频内容需要通过剪裁操作或添加遮幅式黑边来适应视图(保持原始宽高比)。为此,PreviewView
提供了以下 ScaleTypes
:
FIT_CENTER
、FIT_START
和 FIT_END
,用于添加遮幅式黑边。整个视频内容会调整(放大或缩小)为可在目标 PreviewView
中显示的最大尺寸。不过,虽然整个视频帧会完整显示,但屏幕画面中可能会出现空白部分。视频帧会与目标视图的中心、起始或结束位置对齐,具体取决于您在上述三种缩放类型中选择了哪一种。
FILL_CENTER
、FILL_START
和 FILL_END
,用于进行剪裁。如果视频的宽高比与 PreviewView
不匹配,画面中只会显示部分内容,但视频仍会填满整个 PreviewView
。
CameraX
使用的默认缩放类型是 FILL_CENTER
。
注意:缩放类型主要目的是为了保持预览时不会出现拉伸变形问题,如果是使用以前的Camera或Camera2 API,我的一般做法是获取相机支持的预览分辨率列表,选择一种预览分辨率,然后将SurfaceView
或TextureView
控件的宽高比对齐到所选择的预览分辨率的宽高比,这样就不会出现预览时拉伸变形问题,最终的效果其实跟上面的缩放类型如出一辙。幸运的是,现在有了官方API级别的支持,开发者再也不用手动做这些麻烦事了。
例如,下面左图是正常预览显示效果,而右图是拉伸变形的预览显示效果:
这种体验非常不好,最大的问题就是不能做到所见即所得(保存的图片或视频文件跟预览时看到效果不一致)。
以4:3的图片显示到16:9的预览屏为例,如果不做处理,是百分百会出现拉伸变形的:
下图是应用了不同缩放类型的效果:
使用 PreviewView
存在一些限制。使用 PreviewView
时,您无法执行以下任何操作:
SurfaceTexture
,以在 TextureView
和 Preview.SurfaceProvider
上进行设置。TextureView
检索 SurfaceTexture
,并在 Preview.SurfaceProvider
上对其进行设置。SurfaceView
获取 Surface
,并在 Preview.SurfaceProvider
上对其进行设置。如果出现上述任何一种情况,Preview
就会停止将帧流式传输到 PreviewView
。
在创建PreviewView
后,下一步需要为我们创建的实例设置一个CameraController
,它是一个抽象类,其实现是LifecycleCameraController
,然后我们可以使用CameraController
将创建的实例绑定到当前生命周期持有者lifecycleOwner
上。代码如下:
@OptIn(ExperimentalComposeUiApi::class) @Composable private fun CameraPreviewExample() { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraController = remember { LifecycleCameraController(context) } Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding: PaddingValues -> AndroidView( modifier = Modifier .fillMaxSize() .padding(innerPadding), factory = { context -> PreviewView(context).apply { setBackgroundColor(Color.White.toArgb()) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) scaleType = PreviewView.ScaleType.FILL_START implementationMode = PreviewView.ImplementationMode.COMPATIBLE }.also { previewView -> previewView.controller = cameraController cameraController.bindToLifecycle(lifecycleOwner) } }, onReset = { }, onRelease = { cameraController.unbind() } ) } }
请注意,在上面代码中,我们在onRelease
回调中从PreviewView
中解除控制器的绑定。这样,我们可以确保在不再使用AndroidView
时,相机资源能够得到释放。
只有应用获取了Camera授权之后,才显示预览的Composable界面,否则显示一个占位的Composable界面。获取授权的参考代码如下:
@OptIn(ExperimentalPermissionsApi::class) @Composable fun ExampleCameraScreen() { val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) LaunchedEffect(key1 = Unit) { if (!cameraPermissionState.status.isGranted && !cameraPermissionState.status.shouldShowRationale) { cameraPermissionState.launchPermissionRequest() } } if (cameraPermissionState.status.isGranted) { // 相机权限已授权, 显示预览界面 CameraPreviewExample() } else { // 未授权,显示未授权页面 NoCameraPermissionScreen(cameraPermissionState = cameraPermissionState) } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun NoCameraPermissionScreen(cameraPermissionState: PermissionState) { // In this screen you should notify the user that the permission // is required and maybe offer a button to start another camera perission request Column(horizontalAlignment = Alignment.CenterHorizontally) { val textToShow = if (cameraPermissionState.status.shouldShowRationale) { // 如果用户之前选择了拒绝该权限,应当向用户解释为什么应用程序需要这个权限 "未获取相机授权将导致该功能无法正常使用。" } else { // 首次请求授权 "该功能需要使用相机权限,请点击授权。" } Text(textToShow) Spacer(Modifier.height(8.dp)) Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Text("请求权限") } } }
更多关于如何在 Compose 中进行动态权限申请请参考 Jetpack Compose 中的 Accompanist,这里不再赘述。
为了相机预览时全屏展示,没有顶部的状态栏,可以在Activity
的onCreate()
方法中setContent
之前加入以下代码:
if (isFullScreen) { requestWindowFeature(Window.FEATURE_NO_TITLE) //这个必须设置,否则不生效。 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } WindowCompat.setDecorFitsSystemWindows(window, false) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) // 隐藏状态栏 windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()) // 隐藏导航栏 //将底部的navigation操作栏弄成透明,滑动显示,并且浮在上面 windowInsetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE; }
通常这个代码应该会起作用,如果不行,可尝试修改theme主题:
// themes.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MyComposeApplication" parent="android:Theme.Material.Light.NoActionBar.Fullscreen" >
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">true</item>
</style>
</resources>
CameraX 中拍照主要提供了两个重载方法:
takePicture(Executor, OnImageCapturedCallback)
:此方法为拍摄的图片提供内存缓冲区。takePicture(OutputFileOptions, Executor, OnImageSavedCallback)
:此方法将拍摄的图片保存到提供的文件位置。我们添加一个FloatingActionButton
到CameraPreviewExample
中,该按钮将用作点击时触发拍照功能。代码如下:
@OptIn(ExperimentalComposeUiApi::class) @Composable private fun CameraPreviewExample() { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraController = remember { LifecycleCameraController(context) } Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { FloatingActionButton(onClick = { takePhoto(context, cameraController) }) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_camera_24), contentDescription = "Take picture" ) } }, floatingActionButtonPosition = FabPosition.Center, ) { innerPadding: PaddingValues -> AndroidView( modifier = Modifier .fillMaxSize() .padding(innerPadding), factory = { context -> PreviewView(context).apply { setBackgroundColor(Color.White.toArgb()) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) scaleType = PreviewView.ScaleType.FILL_START implementationMode = PreviewView.ImplementationMode.COMPATIBLE }.also { previewView -> previewView.controller = cameraController cameraController.bindToLifecycle(lifecycleOwner) } }, onReset = { }, onRelease = { cameraController.unbind() } ) } } fun takePhoto(context: Context, cameraController: LifecycleCameraController) { val mainExecutor = ContextCompat.getMainExecutor(context) // Create time stamped name and MediaStore entry. val name = SimpleDateFormat(FILENAME, Locale.CHINA) .format(System.currentTimeMillis()) val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE) if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { val appName = context.resources.getString(R.string.app_name) put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/${ appName}") } } // Create output options object which contains file + metadata val outputOptions = ImageCapture.OutputFileOptions .Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) .build() cameraController.takePicture(outputOptions, mainExecutor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { val savedUri = outputFileResults.savedUri Log.d(TAG, "Photo capture succeeded: $savedUri") context.notifySystem(savedUri) } override fun onError(exception: ImageCaptureException) { Log.e(TAG, "Photo capture failed: ${ exception.message}", exception) } } ) context.showFlushAnimation() }
在 OnImageSavedCallback
的 onImageSaved
方法回调中能够通过outputFileResults
获取到保存的图片文件的Uri
, 然后进一步做业务处理。
如果拍照后想自己执行保存逻辑,或者不保存只是用来展示,可以使用另一个回调OnImageCapturedCallback
:
fun takePhoto2(context: Context, cameraController: LifecycleCameraController) { val mainExecutor = ContextCompat.getMainExecutor(context) cameraController.takePicture(mainExecutor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { Log.e(TAG, "onCaptureSuccess: ${ image.imageInfo}") // Process the captured image here try { // The supported format is ImageFormat.YUV_420_888 or PixelFormat.RGBA_8888. val bitmap = image.toBitmap() Log.e(TAG, "onCaptureSuccess bitmap: ${ bitmap.width} x ${ bitmap.height}") } catch (e: Exception) { Log.e(TAG, "onCaptureSuccess Exception: ${ e.message}") } } }) context.showFlushAnimation() }
该回调中可以利用ImageProxy#toBitmap
方法方便的将拍照后的原始数据转成 Bitmap
来显示。不过这里得到的默认格式是ImageFormat.JPEG
,用toBitmap
方法转换会失败,可以参考如下代码来解决:
fun takePhoto2(context: Context, cameraController: LifecycleCameraController) { val mainExecutor = ContextCompat.getMainExecutor(context) cameraController.takePicture(mainExecutor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { Log.e(TAG, "onCaptureSuccess: ${ image.format}") // Process the captured image here try { var bitmap: Bitmap? = null // The supported format is ImageFormat.YUV_420_888 or PixelFormat.RGBA_8888. if (image.format == ImageFormat.YUV_420_888 || image.format == PixelFormat.RGBA_8888) { bitmap = image.toBitmap() } else if (image.format == ImageFormat.JPEG) { val planes = image.planes val buffer = planes[0].buffer // 因为是ImageFormat.JPEG格式,所以 image.getPlanes()返回的数组只有一个,也就是第0个。 val size = buffer.remaining() val bytes = ByteArray(size) buffer.get(bytes, 0, size) // ImageFormat.JPEG格式直接转化为Bitmap格式。 bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } if (bitmap != null) { Log.e(TAG, "onCaptureSuccess bitmap: ${ bitmap.width} x ${ bitmap.height}") } } catch (e: Exception) { Log.e(TAG, "onCaptureSuccess Exception: ${ e.message}") } } }) context.showFlushAnimation() }
如果这里得到的是 YUV 格式,除了直接调用image.toBitmap()
方法外,官方还提供了一个工具类可以将 YUV_420_888
格式转换为 RGB
格式的Bitmap
对象,请参阅 YuvToRgbConverter.kt 。
以上示例的完整代码:
@Composable fun ExampleCameraNavHost() { val navController = rememberNavController() NavHost(navController, startDestination = "CameraScreen") { composable("CameraScreen") { ExampleCameraScreen(navController = navController) } composable("ImageScreen") { ImageScreen(navController = navController) } } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun ExampleCameraScreen(navController: NavHostController) { val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) LaunchedEffect(key1 = Unit) { if (!cameraPermissionState.status.isGranted && !cameraPermissionState.status.shouldShowRationale) { cameraPermissionState.launchPermissionRequest() } } if (cameraPermissionState.status.isGranted) { // 相机权限已授权, 显示预览界面 CameraPreviewExample(navController) } else { // 未授权,显示未授权页面 NoCameraPermissionScreen(cameraPermissionState = cameraPermissionState) } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun NoCameraPermissionScreen(cameraPermissionState: PermissionState) { // In this screen you should notify the user that the permission // is required and maybe offer a button to start another camera perission request Column(horizontalAlignment = Alignment.CenterHorizontally) { val textToShow = if (cameraPermissionState.status.shouldShowRationale) { // 如果用户之前选择了拒绝该权限,应当向用户解释为什么应用程序需要这个权限 "未获取相机授权将导致该功能无法正常使用。" } else { // 首次请求授权 "该功能需要使用相机权限,请点击授权。" } Text(textToShow) Spacer(Modifier.height(8.dp)) Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Text("请求权限") } } } private const val TAG = "CameraXBasic" private const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS" private const val PHOTO_TYPE = "image/jpeg" @OptIn(ExperimentalComposeUiApi::class) @Composable private fun CameraPreviewExample(navController: NavHostController) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraController = remember { LifecycleCameraController(context) } Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { FloatingActionButton(onClick = { takePhoto(context, cameraController, navController) // takePhoto2(context, cameraController, navController) // takePhoto3(context, cameraController, navController) }) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_camera_24), contentDescription = "Take picture" ) } }, floatingActionButtonPosition = FabPosition.Center, ) { innerPadding: PaddingValues -> AndroidView( modifier = Modifier .fillMaxSize() .padding(innerPadding), factory = { context -> cameraController.imageCaptureMode = CAPTURE_MODE_MINIMIZE_LATENCY PreviewView(context).apply { setBackgroundColor(Color.White.toArgb()) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) scaleType = PreviewView.ScaleType.FILL_CENTER implementationMode = PreviewView.ImplementationMode.COMPATIBLE }.also { previewView -> previewView.controller = cameraController cameraController.bindToLifecycle(lifecycleOwner) } }, onReset = { }, onRelease = { cameraController.unbind() } ) } } fun takePhoto(context: Context, cameraController: LifecycleCameraController, navController: NavHostController) { val mainExecutor = ContextCompat.getMainExecutor(context) // Create time stamped name and MediaStore entry. val name = SimpleDateFormat(FILENAME, Locale.CHINA) .format(System.currentTimeMillis()) val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE) if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { val appName = context.resources.getString(R.string.app_name) put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/${ appName}") } } // Create output options object which contains file + metadata val outputOptions = ImageCapture.OutputFileOptions .Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) .build() cameraController.takePicture(outputOptions, mainExecutor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { val savedUri = outputFileResults.savedUri Log.d(TAG, "Photo capture succeeded: $savedUri") context.notifySystem(savedUri) navController.currentBackStackEntry?.savedStateHandle?.set("savedUri", savedUri) navController.navigate("ImageScreen"
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。