2. 预览边界适配核心概念:ViewPort、ScaleType、TransformMatrix、坐标映射、预览输出分辨率
预览边界适配是 CameraX 开发中最容易出坑的环节。理解 ViewPort、ScaleType、TransformMatrix、坐标映射 与 预览输出分辨率 这五个核心概念之间的协作关系,是写出零闪烁、零裁剪预览画面的前提。
本章从原理出发,逐一拆解每个概念的定义、作用范围以及它们在实际代码中的配置顺序。最后通过一张整体结构图把这些概念串联成完整的适配链路。
2.1 概念总览与职责划分
| 概念 | 所属模块 | 核心职责 |
|---|---|---|
| ViewPort | CameraX 核心 | 定义相机传感器的有效采样区域(裁剪矩形) |
| ScaleType | PreviewView | 控制预览画面在 View 内的显示策略(填充/居中/裁剪) |
| TransformMatrix | PreviewView + Surface | 提供 4x4 矩阵,将传感器坐标系映射到 View 坐标系 |
| 坐标映射 | CameraX 内部 + 开发者代码 | 将点击坐标、UI 标注坐标与传感器坐标相互转换 |
| 预览输出分辨率 | CameraX.Preview | 设定相机输出的实际像素尺寸,直接影响清晰度与性能 |
分辨率 → ViewPort → TransformMatrix → ScaleType → 坐标映射。
改变任何一个上游参数,下游参数都必须重新计算。
2.2 ViewPort:定义传感器的“可见窗口”
ViewPort 通过 PreviewView.setViewPort() 或 UseCaseGroup 中的 ViewPort 对象来设定。它使用归一化坐标(0~1)描述传感器阵列中哪些区域参与最终输出。
原理:默认 ViewPort 覆盖整个传感器区域,值为 Rect(0, 0, 1, 1)。当需要从传感器中央裁剪一个 4:3 区域时,ViewPort 的 left/top 不再为 0。
// 示例:从 16:9 传感器中央裁剪 4:3 区域
val viewPort = ViewPort.Builder(
Rational(4, 3), // 目标宽高比
Surface.ROTATION_0 // 预览显示方向
)
.setScaleType(ViewPort.FILL_CENTER) // 裁剪策略
.build()
// 通过 ProcessCameraProvider 绑定到 UseCaseGroup
val useCaseGroup = UseCaseGroup.Builder()
.addUseCase(preview)
.addUseCase(imageAnalysis)
.setViewPort(viewPort)
.build()
provider.bindToLifecycle(lifecycleOwner, cameraSelector, useCaseGroup)
preview.setViewPort() 会导致运行时异常。ViewPort 必须在 bindToLifecycle 之前通过 UseCaseGroup 一次性传入。
2.3 ScaleType:控制预览画面的显示策略
PreviewView 提供四种 ScaleType:FILL_START、FILL_CENTER、FILL_END、FIT。它们决定了当 View 的宽高比与传感器输出比不一致时,画面如何缩放与对齐。
| ScaleType | 缩放行为 | 裁剪情况 | 适用场景 |
|---|---|---|---|
| FILL_START | 填满 View,保持比例 | 可能裁剪顶部或左侧 | 视频通话(对齐左上角) |
| FILL_CENTER | 填满 View,保持比例 | 从中心裁剪 | 默认行为,最常用 |
| FILL_END | 填满 View,保持比例 | 可能裁剪底部或右侧 | 固定底部对齐 |
| FIT | 完整展示画面,保持比例 | 不裁剪,产生上下/左右黑边 | 显示全画面预览(如扫码) |
// XML 布局中设置 ScaleType
// 或代码中动态设置
previewView.scaleType = PreviewView.ScaleType.FILL_CENTER
previewView.scaleType 的变换日志观察裁剪区域变化:Log.d("Preview", "Final matrix: ${previewView.getTransformationMatrix()}")
2.4 TransformMatrix:传感器到 View 的坐标变换
TransformMatrix 是一个 4×4 矩阵(Matrix 对象),它封装了从传感器缓冲区到 PreviewView 显示区域的完整映射。CameraX 自动根据 ViewPort、ScaleType、设备方向、表面尺寸计算该矩阵,开发者通常不需要手动构造,但在坐标映射时需获取它。
// 获取当前变换矩阵(应在 onSurfaceTextureUpdated 或 surface 有效时调用)
val previewViewTransform = previewView.getTransformationMatrix(null)
// 使用矩阵将传感器坐标映射到 View 坐标
val sensorPoint = floatArrayOf(0.5f, 0.5f) // 传感器中心归一化坐标
val viewPoint = floatArrayOf(0f, 0f)
previewViewTransform.mapPoints(viewPoint, sensorPoint)
// viewPoint[0], viewPoint[1] 即为 PreviewView 内的像素坐标
getTransformationMatrix() 每次调用都会触发一次跨进程计算(SurfaceFlinger 参与),不要在每帧中频繁调用。建议在 ProcessCameraProvider 回调完成 bindToLifecycle 后只读一次,或者缓存结果。
2.5 坐标映射:连接传感器与 UI 的桥梁
坐标映射是预览适配中最常手写的代码段。典型场景:用户在预览画面上点击一个目标,需要将点击坐标(View 坐标系)转换为传感器坐标系(用于 ImageAnalysis 分析或取景框标注)。
- 获取
PreviewView的当前 TransformMatrix。 - 将 View 坐标(像素)通过
invert()获取逆矩阵。 - 使用逆矩阵将 View 点映射到传感器归一化坐标。
- 乘以传感器分辨率的宽高,得到传感器像素坐标。
// 示例:将 View 点击坐标转为传感器像素坐标
fun viewToSensor(viewX: Float, viewY: Float, sensorWidth: Int, sensorHeight: Int): PointF {
val matrix = previewView.getTransformationMatrix(null)
val invMatrix = Matrix()
matrix.invert(invMatrix)
val src = floatArrayOf(viewX, viewY)
val dst = floatArrayOf(0f, 0f)
invMatrix.mapPoints(dst, src)
// dst[0], dst[1] 是传感器归一化坐标 0~1
return PointF(dst[0] * sensorWidth, dst[1] * sensorHeight)
}
2.6 预览输出分辨率:性能与清晰度的平衡
CameraX 默认会选择与 PreviewView 尺寸最匹配的分辨率,但开发者可以通过 Preview.Builder.setTargetResolution() 手动指定。分辨率越大,预览越清晰,但 GPU/内存压力也越大。
// 指定预览输出为 1920×1080
val preview = Preview.Builder()
.setTargetResolution(Size(1920, 1080))
.build()
选择原则:
- 优先选择目标宽高比与 PreviewView 宽高比一致的分辨率,避免 ViewPort 额外裁剪。
- 避免设置 sensor 本身不支持的分辨率(否则 CameraX 会退回到最接近的匹配)。
- 设备方向变化时会触发分辨率重新协商,建议在生命周期中保持 setTargetResolution 不变。
2.7 整体适配链路流程图
2.8 常见错误与稳定性优化
- 矩阵未更新:在
onSurfaceTextureUpdated回调之前调用getTransformationMatrix会得到上一个配置的旧矩阵,导致坐标映射偏移。应在Preview.SurfaceProvider.onSurfaceRequested回调 或PreviewView的surfaceTextureReady之后读取。 - 分辨率不一致导致闪烁:ViewPort 宽高比与屏幕旋转方向不匹配时,CameraX 内部会触发重新配置 Surface,产生短暂黑屏。解决办法:始终保持 ViewPort 的宽高比与 PreviewView 的显示宽高比一致(考虑方向)。
- ScaleType 与 ViewPort 冲突:ViewPort 的
setScaleType(ViewPort.FILL_CENTER)与 PreviewView 的scaleType独立运行。不要同时设置两者为不同的裁剪策略,否则最终预览可能出现意外的黑边或过度裁剪。
1. 预览分辨率设置为低于传感器最大分辨率的一档(如 1920×1080 而非 3840×2160),可降低 GPU 解码压力。
2. 不在每帧中动态修改 ScaleType 或 ViewPort;若需切换,应重建 UseCaseGroup 并重新 bind。
3. 使用
setTargetAspectRatio() 代替 setTargetResolution() 可以让 CameraX 自动选择最佳分辨率,减少额外裁剪。
2.9 调试清单:快速定位预览边界问题
- 打印 PreviewView 尺寸:
previewView.width × previewView.height - 打印 TransformMatrix 内容:
previewView.getTransformationMatrix(null) - 检查 ViewPort 的宽高比是否与 PreviewView 比例一致。
- 对比 ScaleType 效果:分别设为 FIT 和 FILL_CENTER 观察黑边位置。
- 验证坐标映射结果:在传感器中心点(0.5, 0.5)映射到 View 后是否落在 View 中心。
// 在 PreviewView 上叠加 View 绘制辅助线
class OverlayView(context: Context) : View(context) {
override fun onDraw(canvas: Canvas) {
// 绘制传感器中心映射到 View 的位置
val matrix = previewView.getTransformationMatrix(null)
val sensorCenter = floatArrayOf(0.5f, 0.5f)
val viewCenter = floatArrayOf(0f, 0f)
matrix.mapPoints(viewCenter, sensorCenter)
canvas.drawCircle(viewCenter[0], viewCenter[1], 10f, paint)
}
}