在 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。
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 具有以下最低版本要求:
- Android API 级别 21
- Android 架构组件 1.1.1
对于能够感知生命周期的 Activity,请使用 FragmentActivity 或 AppCompatActivity。
CameraX 相机预览
下面主要看一下 CameraX 如何进行相机预览
创建预览 PreviewView
由于 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
。
绑定生命周期 CameraController
在创建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 拍照
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("ImageScr