CameraX实战:移动端相机预览与拍照开发指南
发布时间:2026/10/15 21:13:19 锦皓数字建站

1. 为什么移动端相机开发总让人头疼做过移动端相机功能的人大概都有类似的体验打开系统相机拍一张照片看起来只是调个API的事但真到自己动手实现预览加拍照的完整链路坑就一个接一个冒出来了。预览画面拉伸变形、旋转角度对不上、拍出来的照片和预览方向不一致、切换前后摄像头直接黑屏、不同品牌设备表现千差万别——这些问题几乎每个做过多设备适配的开发者都踩过。传统方案里要么用Intent拉起系统相机应用简单但完全不可控UI和交互都没法定制要么用android.hardware.Camera这个老API功能是够用但它的状态机设计极其反直觉回调分散、生命周期管理混乱代码写出来又长又难维护。后来有了Camera2能力确实强了很多支持手动控制曝光、对焦、帧率等高级特性但它的使用复杂度也跟着上了一个台阶——光是理解CaptureSession、CaptureRequest、Surface这几层抽象就够喝一壶的更别提还要处理各种设备兼容性问题。CameraX就是在这个背景下出现的。它是构建在Camera2之上的一层封装库把常见的相机使用场景抽象成了几个简单的UseCase预览用Preview拍照用ImageCapture视频录制用VideoCapture图像分析用ImageAnalysis。你不需要再去手动管理CaptureSession的创建和销毁也不需要自己处理设备兼容性差异——这些脏活累活CameraX帮你扛了。它基于Lifecycle感知生命周期和ViewModel、LifecycleOwner配合起来非常自然代码量比裸写Camera2少了不止一个量级。这篇文章要聊的就是用CameraX实现拍照和预览这两个最基础也最核心的功能。我会从整体设计思路讲起把UseCase的绑定逻辑、预览和拍照的配置参数、图像旋转的处理、生命周期管理这些关键环节拆开揉碎讲清楚然后给出可以直接参考的完整实现步骤和代码最后把我自己在多设备调试中遇到过的典型问题和排查方法整理出来。不管你是刚接触移动端相机开发的新手还是从Camera2迁移过来的老手应该都能从中找到有用的东西。2. 整体方案设计与核心思路拆解2.1 为什么选CameraX而不是Camera2或Intent在动手写代码之前先把方案选型的逻辑理清楚。移动端实现相机功能摆在面前的路其实就三条Intent调系统相机、直接用Camera2、用CameraX。Intent方案最简单几行代码就能拉起系统相机拍照然后拿到结果。但它的局限也很明显你没法自定义拍照界面没法在预览画面上叠加任何UI元素没法控制对焦和曝光参数用户体验完全取决于设备厂商的系统相机应用。如果你只是想让用户拍个头像上传这个方案够用但如果你要做的是扫码、证件拍摄、带引导框的拍照这类需要深度定制交互的场景Intent就完全不够了。Camera2方案能力最强几乎能控制相机的每一个参数。但它的学习曲线陡峭代码量大而且不同设备对Camera2的支持程度参差不齐你需要写大量的兼容性判断。一个典型的Camera2预览加拍照实现光是打开相机、创建预览会话、发起拍照请求、保存图片这几步代码量就在三百行以上还不算各种异常处理和状态管理。CameraX的定位正好在两者之间。它把最常见的相机使用场景封装成了开箱即用的UseCase你只需要几行配置代码就能完成预览和拍照。同时它保留了足够的扩展性通过Camera2Interop可以在需要的时候下沉到Camera2层面去控制高级参数。更重要的是CameraX内部处理了大量的设备兼容性问题Google官方也在持续维护它的兼容性测试套件这意味着你写出来的代码在不同品牌、不同型号的设备上表现会更加一致。对于绝大多数应用场景——社交类App的拍照发帖、电商类App的商品拍摄、工具类App的文档扫描——CameraX都是性价比最高的选择。只有当你需要极其精细的相机参数控制比如专业摄影类App需要手动调节ISO、快门速度、白平衡时才需要考虑直接使用Camera2或者通过Camera2Interop做深度定制。2.2 CameraX的核心抽象UseCase模型CameraX的设计核心是UseCase这个概念。你可以把它理解成“相机的一种用途”——预览是一种用途拍照是一种用途分析图像帧也是一种用途。每个UseCase负责一件事你可以把多个UseCase绑定到同一个Camera上让它们同时工作。这种设计的好处是职责清晰。Preview只负责把相机画面渲染到Surface上ImageCapture只负责拍出高质量的照片ImageAnalysis只负责把帧数据喂给你的分析算法。它们之间互不干扰你可以根据需要自由组合。比如做一个扫码功能你可以同时绑定Preview和ImageAnalysis前者给用户看画面后者在后台识别二维码做一个拍照应用绑定Preview和ImageCapture就够了。UseCase的绑定是通过ProcessCameraProvider来完成的。这个类是CameraX的入口点你通过它拿到可用的相机列表然后把配置好的UseCase绑定到指定的LifecycleOwner上。绑定之后CameraX会自动根据生命周期状态来启停相机——ON_RESUME时打开ON_PAUSE时释放你不需要手动去管这些。这里有一个关键点需要理解CameraX的UseCase绑定是异步的通过ListenableFuture来返回结果。这意味着你不能在调用bindToLifecycle之后立刻假设相机已经就绪而是要在回调里处理绑定成功或失败的逻辑。这个设计初看有点绕但它避免了在主线程上做耗时操作对流畅度是有好处的。2.3 预览与拍照的数据流走向理解数据流对于排查问题非常重要。当你把Preview和ImageCapture绑定到相机后数据流大致是这样的相机硬件采集到图像数据后CameraX会根据绑定的UseCase把数据分发到不同的Surface。Preview的Surface通常关联到一个PreviewView这是CameraX提供的专用预览控件图像数据经过渲染后显示在屏幕上。ImageCapture则在收到拍照指令后从相机管线中获取一帧高质量图像经过编码后写入你指定的输出位置。这里有个容易混淆的地方预览画面和拍照结果走的是不同的数据通道。预览为了流畅性通常会使用较低的分辨率而拍照为了画质会使用传感器支持的最高分辨率。这就导致了一个常见问题——预览画面和拍出来的照片在视野范围、宽高比上可能不一致。CameraX提供了一些配置选项来缓解这个问题但完全消除差异需要你在布局和参数上做精细调整。另外图像旋转是另一个需要特别关注的点。相机传感器的方向是固定的但设备可以旋转所以你需要根据设备方向来计算图像的旋转角度。CameraX的ImageCapture会自动在Exif信息中写入旋转角度但如果你需要手动处理ImageProxy比如在ImageAnalysis中就需要自己计算旋转矩阵。3. 核心细节解析与实操要点3.1 依赖引入与权限配置先把基础环境搭好。在项目的build.gradle文件中添加CameraX的依赖。CameraX的库分为几个模块按需引入即可dependencies { def camerax_version 1.3.0 implementation androidx.camera:camera-core:${camerax_version} implementation androidx.camera:camera-camera2:${camerax_version} implementation androidx.camera:camera-lifecycle:${camerax_version} implementation androidx.camera:camera-view:${camerax_version} }camera-core是核心库camera-camera2提供了基于Camera2的后端实现camera-lifecycle负责生命周期绑定camera-view提供了PreviewView这个预览控件。版本号建议用较新的稳定版老版本可能存在一些已修复的兼容性问题。权限方面拍照需要CAMERA权限如果要把照片保存到外部存储还需要WRITE_EXTERNAL_STORAGEAndroid 10及以上使用分区存储则不需要。在AndroidManifest.xml中声明uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera.any android:requiredtrue /uses-feature这行不是必须的但加上之后应用商店会据此过滤不支持的设备。运行时权限的请求逻辑这里不展开用ActivityResultLauncher配合RequestPermission合约是现在推荐的做法。注意从Android 6.0开始CAMERA是危险权限必须在运行时动态请求。如果用户拒绝了权限再次请求时应该给出解释而不是反复弹窗骚扰用户。3.2 PreviewView的配置与缩放模式选择PreviewView是CameraX提供的专用预览控件它内部处理了Surface的创建、销毁和尺寸适配比你自己用TextureView或SurfaceView要省心得多。在布局文件中加入androidx.camera.view.PreviewView android:idid/previewView android:layout_widthmatch_parent android:layout_heightmatch_parent android:scaleTypefillCenter /scaleType这个属性值得单独说一下。它决定了预览画面如何适配PreviewView的尺寸有三个可选值FILL_CENTER默认值画面会填满整个控件超出部分裁剪掉。适合全屏预览的场景但可能导致画面边缘的内容被裁掉。FIT_CENTER画面完整显示在控件内可能上下或左右留黑边。适合需要看到完整取景范围的场景。FILL_START和FILL_CENTER类似但裁剪时从左上角开始对齐。选择哪个取决于你的业务需求。如果是做文档扫描建议用FIT_CENTER确保文档边缘不会被裁掉如果是做全屏拍照FILL_CENTER视觉上更沉浸。我个人的经验是在预览比例和拍照比例不一致的情况下FILL_CENTER会让用户看到的画面和拍出来的照片有较大差异这时候要么调整Preview的目标分辨率比例要么在拍照后做裁剪对齐。3.3 ImageCapture的参数配置与拍照模式ImageCapture的配置直接决定了拍出来的照片质量。构建ImageCapture实例时可以通过ImageCapture.Builder来设置几个关键参数val imageCapture ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setTargetRotation(Surface.ROTATION_0) .setFlashMode(ImageCapture.FLASH_MODE_AUTO) .build()setCaptureMode有两个选项CAPTURE_MODE_MINIMIZE_LATENCY优先保证拍照速度适合需要快速连拍的场景CAPTURE_MODE_MAXIMIZE_QUALITY优先保证画质可能会引入一些延迟。对于大多数应用场景MINIMIZE_LATENCY是更合适的选择因为用户对拍照延迟的容忍度很低。setTargetRotation设置的是拍照时图像的目标旋转角度。这个值应该根据当前设备的方向来动态设置否则拍出来的照片方向可能不对。后面会详细讲旋转角度的计算逻辑。setFlashMode控制闪光灯行为有FLASH_MODE_AUTO、FLASH_MODE_ON、FLASH_MODE_OFF三个选项。注意不是所有设备的前置摄像头都支持闪光灯设置之前最好检查一下CameraInfo中的hasFlashUnit()。3.4 图像旋转角度的计算逻辑旋转角度是相机开发中最容易出错的地方之一。要理解这个问题需要先搞清楚几个概念传感器方向相机传感器在设备中的物理安装方向通常是一个固定值0、90、180或270度。设备方向设备当前相对于自然方向的旋转角度可以通过Display.getRotation()获取。目标旋转你希望最终图像呈现的方向。CameraX的ImageCapture会根据setTargetRotation设置的值自动在拍出的照片Exif中写入旋转信息。但如果你是在ImageAnalysis中处理ImageProxy就需要自己计算旋转角度val rotation imageProxy.imageInfo.rotationDegrees这个rotationDegrees是CameraX根据设备方向和传感器方向计算好的你直接用它来旋转图像即可。需要注意的是前置摄像头拍出的图像通常是镜像的如果你需要非镜像的图像还需要做水平翻转。实操心得在调试旋转问题时建议在预览界面上叠加一个显示当前旋转角度的文字拍照后对比照片的实际方向。这样能快速定位是配置问题还是计算逻辑问题。4. 完整实操流程与核心环节实现4.1 初始化相机提供者与绑定UseCase整个流程的起点是获取ProcessCameraProvider实例。推荐在Activity或Fragment的onCreate中完成初始化但实际的绑定操作放在onResume之后更稳妥因为这时候PreviewView的Surface已经准备好了。private lateinit var cameraProvider: ProcessCameraProvider private lateinit var preview: Preview private lateinit var imageCapture: ImageCapture private fun setUpCamera() { val cameraProviderFuture ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ cameraProvider cameraProviderFuture.get() bindUseCases() }, ContextCompat.getMainExecutor(this)) }ProcessCameraProvider.getInstance()返回一个ListenableFuture你需要添加监听器来等待它完成初始化。这个过程通常很快但不要在主线程上阻塞等待。接下来配置并绑定UseCaseprivate fun bindUseCases() { preview Preview.Builder() .setTargetAspectRatio(AspectRatio.RATIO_4_3) .build() imageCapture ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setTargetAspectRatio(AspectRatio.RATIO_4_3) .build() val cameraSelector CameraSelector.DEFAULT_BACK_CAMERA try { cameraProvider.unbindAll() val camera cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageCapture ) preview.setSurfaceProvider(previewView.surfaceProvider) } catch (e: Exception) { Log.e(TAG, UseCase绑定失败, e) } }这里有几个关键点。unbindAll()在重新绑定之前调用避免重复绑定导致异常。bindToLifecycle的第一个参数是LifecycleOwner传入thisActivity或Fragment即可CameraX会自动管理生命周期。preview.setSurfaceProvider把PreviewView的SurfaceProvider关联到Preview上这一步必须在绑定之后调用。setTargetAspectRatio设置宽高比RATIO_4_3和RATIO_16_9是最常用的两个值。选择哪个取决于你的UI设计——全屏预览通常用16:9方形或接近方形的预览区域用4:3更合适。注意预览和拍照的宽高比最好保持一致否则会出现前面提到的视野差异问题。4.2 实现拍照并保存图片拍照的核心是调用ImageCapture.takePicture()方法。这个方法有多个重载版本最常用的是传入一个OutputFileOptions和一个OnImageSavedCallbackprivate fun takePhoto() { val name SimpleDateFormat(yyyyMMdd_HHmmss, Locale.US) .format(System.currentTimeMillis()) val contentValues ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, IMG_$name.jpg) put(MediaStore.MediaColumns.MIME_TYPE, image/jpeg) } val outputOptions ImageCapture.OutputFileOptions .Builder( contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues ) .build() imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(output: ImageCapture.OutputFileResults) { val uri output.savedUri Log.d(TAG, 照片已保存: $uri) runOnUiThread { Toast.makeText(thisMainActivity, 拍照成功, Toast.LENGTH_SHORT).show() } } override fun onError(exception: ImageCaptureException) { Log.e(TAG, 拍照失败: ${exception.message}, exception) runOnUiThread { Toast.makeText(thisMainActivity, 拍照失败, Toast.LENGTH_SHORT).show() } } } ) }这里使用MediaStore来保存照片这是Android 10及以上推荐的方式不需要申请存储权限照片会出现在系统相册中。ContentValues中的DISPLAY_NAME和MIME_TYPE是必须的否则MediaStore可能无法正确识别文件类型。如果你需要把照片保存到应用私有目录可以用OutputFileOptions.Builder(File)这个重载版本传入一个File对象即可。这种方式不需要任何存储权限但照片不会出现在系统相册中适合应用内部使用的场景。注意takePicture的回调默认在调用线程的Executor上执行。如果你传入的是主线程的Executor回调就在主线程执行如果传入后台线程的Executor回调就在后台线程执行。保存图片这种IO操作建议放在后台线程避免阻塞UI。4.3 前后摄像头切换的实现切换摄像头需要重新绑定UseCase因为CameraSelector变了。实现思路很简单维护一个变量记录当前使用的是前置还是后置切换时更新这个变量并重新调用绑定逻辑。private var isFrontCamera false private fun switchCamera() { isFrontCamera !isFrontCamera val cameraSelector if (isFrontCamera) { CameraSelector.DEFAULT_FRONT_CAMERA } else { CameraSelector.DEFAULT_BACK_CAMERA } try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageCapture ) } catch (e: Exception) { Log.e(TAG, 切换摄像头失败, e) } }切换之前一定要调用unbindAll()否则会抛出IllegalArgumentException提示UseCase已经绑定到其他相机了。另外前置摄像头拍出的照片默认是镜像的如果你不希望镜像可以在ImageCapture的OutputFileOptions中设置setMetadata来调整或者在保存后做翻转处理。4.4 生命周期管理与资源释放CameraX的一大优势就是自动生命周期管理。通过bindToLifecycle绑定后相机会在ON_START时自动打开在ON_STOP时自动释放。你不需要在onPause或onDestroy中手动调用相机释放逻辑。但有一个例外如果你在onDestroy中需要确保相机已经完全释放比如要立即启动另一个相机实例可以手动调用cameraProvider.unbindAll()。不过大多数情况下不需要这么做CameraX的内部机制会处理好。还有一个需要注意的点PreviewView的SurfaceProvider在Preview重新绑定后需要重新设置。如果你在切换摄像头或重新绑定UseCase后预览黑屏检查一下是否遗漏了preview.setSurfaceProvider(previewView.surfaceProvider)这一步。5. 常见问题与排查技巧实录5.1 预览画面拉伸或变形这是最常见的问题之一。根本原因是预览画面的宽高比和PreviewView控件的宽高比不一致。CameraX默认会尝试匹配但如果PreviewView的尺寸是固定的比如match_parent而相机支持的宽高比和控件比例不匹配就会出现拉伸。解决思路有两个方向。一是调整PreviewView的尺寸让它和相机的宽高比一致。可以在代码中动态计算PreviewView的高度previewView.doOnLayout { val width it.width val height (width * 4f / 3f).toInt() it.layoutParams.height height it.requestLayout() }二是通过setTargetAspectRatio或setTargetResolution来指定Preview的宽高比让CameraX去选择最接近的相机分辨率。两种方式可以结合使用效果更好。5.2 拍出的照片方向不对照片方向问题通常是因为setTargetRotation没有根据设备方向动态设置。正确的做法是在每次拍照前根据当前Display的旋转角度来更新ImageCapture的TargetRotationval rotation previewView.display.rotation imageCapture.targetRotation rotationCameraX会根据这个值自动计算Exif中的旋转信息。如果你是在ImageAnalysis中处理图像ImageProxy.imageInfo.rotationDegrees已经包含了正确的旋转角度直接使用即可。实操心得有些设备的前置摄像头拍出的照片旋转角度和后置不一致这是硬件差异导致的。如果发现前置拍照方向异常可以尝试在保存后手动调整Exif信息或者使用ExifInterface读取并修正方向。5.3 拍照时画面卡顿或延迟明显拍照延迟通常和CaptureMode的设置有关。CAPTURE_MODE_MAXIMIZE_QUALITY会触发多帧合成等处理导致延迟增加。如果对延迟敏感切换到CAPTURE_MODE_MINIMIZE_LATENCY。另一个可能的原因是照片保存的IO操作阻塞了主线程。确保takePicture的回调Executor不是主线程或者把保存逻辑放到后台线程执行。使用MediaStore保存时ContentResolver的写入操作本身是异步的但构建ContentValues和文件名的操作如果在主线程做也会有轻微影响。5.4 切换摄像头后预览黑屏这个问题通常有两个原因。一是忘记调用unbindAll()导致新的绑定失败二是绑定成功后忘记重新设置SurfaceProvider。检查一下切换逻辑中是否包含了这两步。还有一种可能是前置摄像头不支持某些配置参数比如某些设备的前置不支持RATIO_16_9导致绑定失败。可以在绑定前检查CameraInfo中的可用配置或者用try-catch捕获异常并降级到默认配置。5.5 常见问题速查表问题现象可能原因排查方向预览拉伸变形宽高比不匹配检查PreviewView尺寸和setTargetAspectRatio照片方向错误旋转角度未更新检查setTargetRotation是否动态设置拍照延迟高拍照模式或IO阻塞切换CAPTURE_MODE检查回调线程切换摄像头黑屏未解绑或未重设SurfaceProvider检查unbindAll和setSurfaceProvider绑定失败设备不支持配置检查CameraInfo降级配置照片模糊对焦未完成拍照前触发对焦等待对焦完成回调5.6 多设备兼容性的一些经验不同品牌设备对CameraX的支持程度确实有差异但比裸写Camera2要好很多。我遇到过的主要问题集中在几个方面某些设备的前置摄像头不支持高分辨率拍照、部分设备的闪光灯行为不一致、少数设备在快速切换摄像头时会出现短暂黑屏。应对策略是尽量使用CameraX的默认配置不要过度指定分辨率等参数让CameraX自己去协商最合适的值。如果确实需要指定做好降级处理——绑定失败时尝试用更宽松的配置重新绑定。另外在bindToLifecycle的try-catch中记录详细的错误信息方便定位是哪个UseCase或哪个参数导致的失败。6. 一些补充的实操建议关于预览和拍照的宽高比一致性我的建议是尽量让两者保持一致。如果业务上确实需要不同的比例比如预览是方形拍照是4:3可以在拍照后做裁剪或者在预览界面上叠加遮罩来提示用户实际拍照范围。关于ImageCapture的分辨率选择CameraX默认会选择传感器支持的最高分辨率。如果你不需要那么高的分辨率可以通过setTargetResolution来限制这样可以减少拍照延迟和文件大小。但注意不要设置得太低否则画质会明显下降。关于PreviewView的实现模式CameraX支持PERFORMANCE和COMPATIBLE两种模式。PERFORMANCE模式使用SurfaceView性能更好但某些情况下可能有兼容性问题COMPATIBLE模式使用TextureView兼容性更好但性能略低。默认是PERFORMANCE如果遇到预览异常可以尝试切换到COMPATIBLE。最后分享一个调试小技巧在开发阶段可以在预览界面上叠加显示当前的相机信息包括分辨率、宽高比、旋转角度、对焦状态等。这些信息对于快速定位问题非常有帮助比反复看日志要直观得多。等调试完成后再把这些信息隐藏掉即可。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。