本文译自「Compose-Native CameraX Is Now Stable: End-to-End Guide for Jetpack Compose」,原文链接proandroiddev.com/goodbye-and...,由Ioannis Anifantakis发布于20251026。

简介
还记得你在 Jetpack Compose 中的第一个相机页面吗?纯粹的声明式乐趣......直到预览。然后是熟悉的 AndroidView(PreviewView) 绕道。它确实有效,但总感觉不对:composables中间有一个 View 形状的空洞(类似于 _iFrame__ ......),而且点击对焦的数学计算总是让人感觉不太可靠。
在 I/O 25 之后,这种妥协已经结束。
- 不再 使用
**AndroidView(PreviewView)**进行相机预览。 - 新增
**CameraXViewfinder**可组合项,可在 Compose 中直接渲染 CameraXSurfaceRequest。 - 修正了内置坐标变换(点击对焦、叠加层),并建立了更简洁、更具声明性的心智模型。
注意:
_"在 I/O 25 大会上,Compose 支持已发布 alpha/beta 版本,稳定版已于 9 月发布------现在是时候了解一下了。"
配套项目
你可以在 GitHub 上的配套项目 找到本文的配套项目,该项目演示了 CameraX 中 Jetpack Compose 的新功能。
权限用户体验(简要说明)
本文将重点介绍 Compose + CameraX 的功能。配套项目 实现了完整的运行时流程:
- 在预览入口点请求
**CAMERA**。 - 仅在用户开始录制时(按需麦克风)请求
**RECORD_AUDIO**。 - 一个小型的
PermissionGate可组合函数负责处理 Compose 树中的授权/拒绝/重新请求。 - 为了满足 Lint 对
@RequiresPermission的要求,调用点还会在调用与麦克风相关的 API 之前执行显式checkSelfPermission(...)。
请参阅代码库,了解具体的 PermissionGate 以及我们如何将其连接到 Capture 页面。
实际变化是什么?
CameraX 团队放弃了 androidx.camera:camera-compose,取而代之的是看似简单的 API:**CameraXViewfinder**。但这不仅仅是"将 PreviewView 封装在可组合项中"。这是对 Compose 的彻底重写,也是对相机 Surface 与 Compose 集成方式的根本性重新思考。
以下是架构层面的变化:
Compose 目标优先 取景器渲染管道现在将 Compose 视为主要平台。Surface 生命周期、旋转处理和缩放都以 Compose 惯用的方式进行。
开箱即用的正确坐标变换 还记得计算预览中的点击实际映射到相机传感器的位置,并考虑旋转、宽高比裁剪和缩放模式吗?MutableCoordinateTransformer 可以处理这些。点击对焦现在......可以正常工作了。
真正的可组合语义 想要将预览 clip() 转换为自定义形状?应用 graphicsLayer 变换?使用 AnimatedContent 为其添加动画效果?现在,你可以轻松完成所有这些操作,而无需与渲染器冲突。它与其他可组合组件一样。
CameraX 1.5.x 成熟度 整个技术栈都得到了完善:适用于 Kotlin 协程的 ProcessCameraProvider.awaitInstance()、全面稳定的构件以及更完善的文档。这并非 Beta 测试......它已准备好投入生产。
为什么这真的很重要
如果你一直在构建相机功能,你就会知道其中的痛点:
- 心智模型分裂:"用 Compose 思考 UI,用 View 思考相机,并在两者之间不断转换。"
- 手势协调的噩梦:在 Compose 中处理触摸事件,在 View 坐标系中测光对焦,祈祷你的计算准确无误。
- Z 轴顺序难题:"PreviewView" 经常使用在单独图层中渲染的"SurfaceView"。Compose 叠加层无法可靠地位于顶部,因此十字线、参考线和按钮可能会消失在预览层后面。
- 生命周期之舞:使用 CameraX 用例绑定将 Compose 重组与 View 生命周期同步
所有这些摩擦?都消失了。
"现在,你可以像编写其他现代 Android 应用一样编写相机 UI。一个范例。一个心智模型。纯粹的 Compose。"
代码演示
让我们从最基本的开始------一个可以工作的相机预览(固定状态模式:将写入器 MutableStateFlow 与读取器 collectAsState 分离)。
kotlin
@Composable
fun CameraPreview(modifier: Modifier = Modifier) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Writer: MutableStateFlow we can update from CameraX callbacks
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
// Reader: Compose state derived from the flow
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind CameraX use cases once
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
// When CameraX needs a surface, publish it to Compose
setSurfaceProvider { request ->
surfaceRequests.value = request
}
}
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview
)
}
// The actual Compose viewfinder
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
modifier = modifier.fillMaxSize()
)
}
}
就是这样。没有 AndroidView。没有 PreviewView。只有一个可组合组件,它接收 SurfaceRequest 并进行渲染。
***模式很简洁:"***CameraX 发布 Surface 请求,Compose 处理它们。单向。没有回调在各个世界之间来回切换。"
可选:使用镜头切换按钮(FAB)进行预览(前/后)
kotlin
@Composable
fun PreviewWithLensSwitch(modifier: Modifier = Modifier) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// remember current lens
var useFront by rememberSaveable { mutableStateOf(false) }
val selector = if (useFront) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
// bind when camera selector changes (front/back camera)
LaunchedEffect(selector) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, selector, preview)
}
Box(Modifier.fillMaxSize()) {
surfaceRequest?.let { req ->
CameraXViewfinder(surfaceRequest = req, modifier = Modifier.fillMaxSize())
}
FloatingActionButton(
onClick = { useFront = !useFront },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
) { Icon(Icons.Rounded.Cameraswitch, contentDescription = "Switch camera") }
}
}
真正的考验:交互式相机控件
旧方法的失败之处就在这里。让我们实现点击对焦和捏合缩放......这些功能过去需要对视图坐标系进行一些 hack(同样使用固定的写入/读取模式):
kotlin
@Composable
fun InteractiveCameraPreview(
modifier: Modifier = Modifier,
onFocusTap: (success: Boolean) -> Unit = {}) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var camera by remember { mutableStateOf<Camera?>(null) }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind camera once
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview
)
}
// Coordinate transformer: Compose UI → Camera surface
val coordinateTransformer = remember { MutableCoordinateTransformer() }
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
coordinateTransformer = coordinateTransformer,
modifier = modifier
.fillMaxSize()
.pointerInput(camera) {
// Tap-to-focus
detectTapGestures { offset ->
val cam = camera ?: return@detectTapGestures
// Transform Compose coordinates to camera surface
val surfacePoint = with(coordinateTransformer) {
offset.transform()
}
val meteringFactory = SurfaceOrientedMeteringPointFactory(
request.resolution.width.toFloat(),
request.resolution.height.toFloat()
)
val focusPoint = meteringFactory.createPoint(
surfacePoint.x,
surfacePoint.y
)
val action = FocusMeteringAction.Builder(
focusPoint,
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
).setAutoCancelDuration(3, TimeUnit.SECONDS).build()
cam.cameraControl
.startFocusAndMetering(action)
.addListener(
{ onFocusTap(true) },
ContextCompat.getMainExecutor(context)
)
}
}
.pointerInput(camera) {
// Pinch-to-zoom
detectTransformGestures { _, _, zoom, _ ->
val cam = camera ?: return@detectTransformGestures
val zoomState = cam.cameraInfo.zoomState.value ?: return@detectTransformGestures
val newRatio = (zoomState.zoomRatio * zoom).coerceIn(
zoomState.minZoomRatio,
zoomState.maxZoomRatio
)
cam.cameraControl.setZoomRatio(newRatio)
}
}
)
}
}
看看这个点击对焦的实现。注意你没有做的事情:
- 无需手动旋转补偿
- 无需进行坐标映射的宽高比计算
- 无需进行视图 → 表面 → 传感器坐标链计算
- 无需进行"祈祷它在横向模式下能正常工作"的漫长测试
MutableCoordinateTransformer 可以处理所有这些。你点击 Compose 坐标系,它会转换为相机坐标系,就完成了。
这就是"技术上可行"和"实际易于实现"之间的区别。
拍摄照片和视频
添加拍摄功能遵循相同的简洁模式------绑定其他用例,并从 Compose 界面触发它们。
我们还将仅在尝试录制时请求麦克风,并使用简单的"PermissionGate"模式(与我们项目在需要时仅请求音频的方法一致)。
kotlin
@Composable
fun CameraScreen() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var camera by remember { mutableStateOf<Camera?>(null) }
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
var activeRecording by remember { mutableStateOf<Recording?>(null) }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
// Bind all use cases
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.FHD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageCapture!!,
videoCapture!!
)
}
Box(modifier = Modifier.fillMaxSize()) {
// Camera preview
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
modifier = Modifier.fillMaxSize()
)
}
// Compose UI controls
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
) {
// Capture photo button
IconButton(
onClick = { capturePhoto(context, imageCapture) }
) {
Icon(Icons.Default.PhotoCamera, "Take Photo")
}
Spacer(modifier = Modifier.width(32.dp))
// Video record toggle (mic requested only when needed)
PermissionGate(
permission = Permission.RECORD_AUDIO,
// Optional: custom UI if permission is not yet granted
contentNonGranted = { missing, humanReadable, requestPermissions ->
// Minimal, inline UX: re-request directly
Button(onClick = { requestPermissions(missing) }) {
Text("Grant $humanReadable")
}
}
) {
IconButton(
onClick = {
activeRecording = toggleRecording(
context,
videoCapture,
activeRecording
)
}
) {
Icon(
if (activeRecording == null) Icons.Default.RadioButtonUnchecked
else Icons.Default.Stop,
"Record Video"
)
}
}
}
}
}
private fun capturePhoto(context: Context, imageCapture: ImageCapture?) {
val capture = imageCapture ?: return
val name = "IMG_${System.currentTimeMillis()}.jpg"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
// On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
}
val outputOptions = ImageCapture.OutputFileOptions.Builder(
context.contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// Success: output.savedUri
}
override fun onError(exception: ImageCaptureException) {
// Handle error
}
}
)
}
private fun toggleRecording(
context: Context,
videoCapture: VideoCapture<Recorder>?,
currentRecording: Recording?
): Recording? {
val capture = videoCapture ?: return null
// Stop if already recording
if (currentRecording != null) {
currentRecording.stop()
return null
}
// Start new recording
val name = "VID_${System.currentTimeMillis()}.mp4"
val contentValues = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, name)
// On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
}
val outputOptions = MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
).setContentValues(contentValues).build()
return capture.output
.prepareRecording(context, outputOptions)
.withAudioEnabled() // mic permission is ensured by PermissionGate above
.start(ContextCompat.getMainExecutor(context)) { event ->
// Handle recording events (e.g., finalize, error)
}
}
这是纯粹的 Compose UI 构建。你的相机按钮与预览位于同一个可组合树中。没有桥接逻辑。无需管理单独的 View 层次结构。
迁移策略:PreviewView → CameraXViewfinder
如果你现有的相机代码使用"PreviewView",则迁移路径如下:
迁移前(旧方法):
kotlin
@Composable
fun OldCameraPreview() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val previewView = remember { PreviewView(context) }
LaunchedEffect(previewView) {
val provider = ProcessCameraProvider.getInstance(context).get()
val preview = Preview.Builder().build()
preview.setSurfaceProvider(previewView.surfaceProvider)
provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview)
}
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
}
迁移后(Compose 原生方法):
kotlin
@Composable
fun NewCameraPreview() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val selector = CameraSelector.DEFAULT_BACK_CAMERA
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.awaitInstance(context)
val preview = Preview.Builder().build().apply {
setSurfaceProvider { req -> surfaceRequests.value = req }
}
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, selector, preview)
}
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier.fillMaxSize()
)
}
}
关键的思维转变:不再将 View 的"SurfaceProvider"赋予 CameraX,而是将"SurfaceRequest"对象发布到 Compose 状态,并使用"CameraXViewfinder"进行渲染。
所需依赖项
添加到你的 build.gradle.kts 中:
kotlin
val cameraxVersion = "1.5.1"dependencies {
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-video:$cameraxVersion")
// The new Compose-native viewfinder
implementation("androidx.camera:camera-compose:$cameraxVersion")
}
清单权限:
xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
实现模式(性能 vs. 合成)
CameraXViewfinder 可以通过两种方式渲染预览:
EXTERNAL(SurfaceView 支持)
相机帧在其自己的 Surface 上渲染,由系统在 Compose 绘制通道之外合成。可以想象成"UI 背后的实时视频层"。通常启用硬件叠加 → 最佳性能/延迟。非常适合在标准 UI 后方显示全屏矩形预览。由于它是一个单独的层,因此对_相机像素_的逐像素效果(复杂的裁剪/模糊)不适用。
- 优点:延迟更低,GPU 负载更少,非常适合全屏预览/录制。
- 缺点:不受逐像素界面特效(圆角蒙版/模糊)的影响,不会显示在 Compose 屏幕截图中。
嵌入式(TextureView 支持)
相机帧作为GPU 纹理 绘制在 Compose 渲染通道内部 ------类似于可重绘面板,其行为与其他可组合项类似。你可以获得深度裁剪/蒙版/动画/模糊/Z 轴排序,但代价是 GPU 工作量增加,延迟略高。
- 优点:行为类似于普通界面;裁剪、Alpha 通道、模糊、特殊形状和复杂 Z 轴排序均正常。
- 缺点:GPU 工作量增加 → 在繁重的界面或中端设备上,延迟/卡顿风险略高。
经验法则
- 全屏/高性能 → 外部
- 特殊构图/特效 → 嵌入式。
如果你未指定模式,库将选择一个合理的默认模式。强制使用以下方式:
kotlin
import androidx.camera.viewfinder.core.ImplementationMode
CameraXViewfinder(
surfaceRequest = request,
implementationMode = ImplementationMode.EXTERNAL // or ImplementationMode.EMBEDDED
)
实际操作中的陷阱
坐标变换并非可选 不要将原始 Compose 偏移量传递给测量工厂。务必使用坐标变换器。数学运算看起来很简单,直到你在横屏、可折叠设备或非标准宽高比设备上进行测试。
前置摄像头是镜像的 如果你正在绘制叠加层或处理拍摄的图像,请记住前置摄像头预览默认是镜像的,但拍摄的图像不是。在你的界面/处理逻辑中考虑到这一点。
在真实设备上测试 不同 OEM 的相机行为有所不同。在 Pixel 上完美运行的功能在三星或小米上可能存在问题。在代表性硬件上测试你的关键流程。
权限用户体验 在入口点请求 CAMERA;仅在开始录制时请求 RECORD_AUDIO(这是一种良好做法)。上面的内联 PermissionGate 模式将该逻辑保留在你的 Compose 树中。
高级功能:可折叠和自适应 UI
由于 CameraXViewfinder 只是另一个可组合项,因此可折叠支持非常简单。简单的双窗格布局或全屏布局通常就足够了;如果需要,可以使用 AnimatedContent 来在状态之间添加动画。
kotlin
@Composable
fun AdaptiveCameraScreen(surfaceRequest: SurfaceRequest?) {
val expanded = remember { mutableStateOf(false) } // pretend this reflects window size/hinge state
AnimatedContent(targetState = expanded.value, label = "layout") { isExpanded ->
if (isExpanded) {
Row(Modifier.fillMaxSize()) {
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier
.weight(1f)
.aspectRatio(9f / 16f)
)
}
Box(Modifier.weight(1f)) { /* CameraControls(Modifier.align(Alignment.Center)) */ }
}
} else {
Box(Modifier.fillMaxSize()) {
surfaceRequest?.let {
CameraXViewfinder(
surfaceRequest = it,
modifier = Modifier.fillMaxSize()
)
}
/* CameraControls(Modifier.align(Alignment.BottomCenter)) */
}
}
}
}
测试清单(实用)
- 验证纵向/横向以及
ContentScale.Crop/Fit模式下的点击对焦精度。 - 测试缩放限制;确保捏合和程序化缩放过渡流畅。
- 切换摄像头(前/后)并重新验证变换 + 镜像行为。
- 导航离开/后退、旋转和处理配置更改;预览应能够恢复且不闪烁。
- 在对焦/缩放时录制视频;确保没有表面掉落。
全局展望
此版本的重要性不仅在于它带来的功能,还在于它所传递的信息。
多年来,Android 中的相机开发一直感觉像是二等公民。除了相机页面之外,你可以在任何地方使用 Compose 构建现代 UI,而相机页面则需要你勉强才能与 View 进行互操作。虽然 Compose 确实有效,但编写代码时总感觉像是被束缚了一只手。
camera-compose 不仅仅是一个新产物。CameraX 团队曾说过:"Compose 现在是一流的相机开发平台。"
这意味着:
- 未来的相机功能将在设计时充分考虑 Compose,而不是对其进行改造。
- 社区将构建以 Compose 为先的相机库和组件。
- 最佳实践将围绕可组合相机 UI 不断发展。
- 文档和示例将反映现代 Android 开发。
我们在整个 Android 生态系统中都看到了这种模式------最初以 View 为中心的 API 正在逐渐获得 Compose 原生的对应版本。camera-compose 就是迄今为止最具影响力的例子之一。
你现在应该做什么
如果你正在开发一个新的相机功能: 从一开始就使用 CameraXViewfinder。甚至不需要考虑 PreviewView。它的代码更简洁,思维模型更简单,你以后会感谢自己的。
如果你已经有相机代码: 将 camera-compose 添加到你的依赖项中,并一次迁移一个页面。从最简单的相机 UI(可能是基本的纯预览页面)开始,熟悉新的 API。然后再处理复杂的部分。
如果你正在构建一个库: 现在是时候将 Compose 原生相机组件添加到你的 SDK中了。开发者正在寻找可组合的相机解决方案,而生态系统已经为他们做好了准备。
延伸阅读
- CameraX 发行说明 --- 官方更新日志和工件
- Camera Viewfinder 文档 --- 实现模式、缩放和对齐
- CameraX GitHub 示例 --- 真实代码示例
国王已死 / 国王万岁
"AndroidView"相机预览的时代已经结束。如果你要在 2025 年及以后构建相机功能,那么你将使用 Compose 来构建它们。现在终于有了可以正确支持这些功能的工具。
现在,甩掉那个"AndroidView"包装器,编写一些漂亮的相机 UI 吧。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!