3. 相机预览链路搭建

相机预览链路是相机应用最基础也最容易出问题的一环。很多开发者第一次接入 CameraX 或 Camera2 时,代码能编译、能打开相机,但屏幕上要么黑屏,要么画面拉伸变形,要么旋转 90 度,要么首帧迟迟不出现。这些现象几乎都指向同一个核心问题:预览数据的承载视图选错了,或者视图与相机输出之间的尺寸、方向、生命周期没有对齐。本节先把三种主流预览视图的底层机制讲清楚,再给出一条可复现的首帧渲染实战路径。

三种预览视图的本质差异

SurfaceView、TextureView、PreviewView 并不是三个平级的“控件选项”,它们处在不同的抽象层级上。理解这一点,后面的选型就不会纠结。

SurfaceView 拥有一个独立于应用窗口的 Surface,由 SurfaceFlinger 单独合成。相机把预览帧直接写入这个 Surface,不经过应用主线程的 View 绘制流程。它的优点是功耗低、延迟小、对高分辨率和高帧率友好;缺点是它本质上是“挖洞”显示,视图层级、圆角、透明度、动画变换支持很差,早期版本在滚动列表中还会出现黑边或残影。

TextureView 则把相机输出当作一个 OpenGL 纹理,再通过硬件加速的 View 绘制流程合成到窗口里。它支持平移、缩放、旋转、透明度等常规 View 变换,能很好地嵌入复杂布局。代价是每一帧都要经过一次纹理合成,功耗和延迟高于 SurfaceView,在 4K 或高帧率场景下更容易掉帧。

PreviewView 是 CameraX 提供的封装视图。它内部会根据设备能力、API 级别和当前配置,自动在 SurfaceView 与 TextureView 之间选择实现,并内置了预览画面的缩放与旋转适配逻辑。开发者只需要设置 scaleType 和 implementationMode,就能获得基本正确的预览比例。

相机预览链路:数据从 Sensor 到屏幕的三条路径 Camera Sensor / ISP 输出帧流 SurfaceView 独立 Surface SurfaceFlinger 直接合成 低延迟 · 低功耗 TextureView OpenGL 纹理 经 View 绘制流程合成 支持变换 · 功耗较高 PreviewView CameraX 封装 自动选择底层实现 内置缩放/旋转适配 屏幕显示 用户可见预览 首帧渲染完成 首帧关键参数 预览尺寸 如 1920×1080 / 1280×720 显示方向 Sensor Orientation + Display Rotation 缩放模式 FILL_CENTER FIT_CENTER 宽高比 16:9 / 4:3 匹配 生命周期 onResume 绑定 onPause 解绑 首帧回调 onPreviewOutputUpdate 或 Surface 回调 选型原则:简单场景优先 PreviewView;需要极致性能用 SurfaceView;需要复杂变换或嵌入动画用 TextureView

PreviewView 的两种实现模式

PreviewView 通过 implementationMode 暴露底层选择:PERFORMANCE 优先使用 SurfaceView,COMPATIBLE 优先使用 TextureView。默认是 PERFORMANCE。如果你的预览界面需要做圆角裁剪、叠加动画、或者放在 RecyclerView 里频繁复用,建议显式设为 COMPATIBLE,否则可能在部分机型上遇到黑边或裁剪异常。

另一个关键属性是 scaleType。FILL_CENTER 会保持宽高比并填满整个 PreviewView,超出部分被裁掉,适合全屏预览;FIT_CENTER 会完整显示画面,可能留下黑边,适合需要看到完整取景范围的场景。很多“画面被拉伸”的问题,其实是 PreviewView 的尺寸和相机输出宽高比不一致,而 scaleType 又没有正确设置导致的。

首帧渲染实战:从绑定到画面出现

下面以 CameraX + PreviewView 为例,走一遍首帧渲染的完整链路。假设我们要在 onCreate 中完成初始化,在 onResume 中启动相机。

// 1. 布局中声明 PreviewView
<androidx.camera.view.PreviewView
    android:id="@+id/previewView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:scaleType="fillCenter"
    app:implementationMode="performance" />

// 2. 在 onCreate 中构建 Preview 用例
Preview preview = new Preview.Builder()
        .setTargetAspectRatio(AspectRatio.RATIO_16_9)
        .build();

// 3. 绑定生命周期并连接 PreviewView 的 SurfaceProvider
preview.setSurfaceProvider(previewView.getSurfaceProvider());

// 4. 在 onResume 中启动相机
CameraProviderFuture future = ProcessCameraProvider.getInstance(this);
future.addListener(() -> {
    CameraProvider provider = future.get();
    provider.unbindAll();
    provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview);
}, ContextCompat.getMainExecutor(this));

这段代码看起来简单,但首帧能否顺利出现,取决于几个隐藏条件。第一,PreviewView 必须已经完成布局,getSurfaceProvider() 才能返回有效的 SurfaceProvider。如果在 onCreate 中视图还没测量就调用,可能拿到空 Surface。第二,bindToLifecycle 的 LifecycleOwner 必须是已经进入 STARTED 或 RESUMED 状态的对象,否则相机会等待生命周期推进后才真正打开。第三,setTargetAspectRatio 设置的宽高比要和 PreviewView 的实际显示比例匹配,否则会出现裁剪或黑边。

判断首帧是否真正渲染完成,不能只看 bindToLifecycle 返回成功。更可靠的方式是监听 PreviewView 的 previewStreamState,当状态变为 STREAMING 时,说明第一帧已经到达并开始显示。也可以在 Preview 用例上设置 OnPreviewOutputUpdateListener,在回调中记录时间戳,用于测量从启动相机到首帧出现的耗时。

常见首帧问题与排查方向

如果预览黑屏,先检查相机权限是否授予、bindToLifecycle 是否在 onResume 之后调用、以及是否在 onPause 中正确解绑。如果画面旋转错误,需要同时考虑 Sensor Orientation 和当前屏幕旋转角度:后置摄像头通常需要将预览旋转 90 度,前置摄像头还需要额外做镜像处理。PreviewView 内部已经处理了大部分旋转逻辑,但如果你直接用 SurfaceView 或 TextureView 接 Camera2,就必须自己计算 Matrix 变换。

如果画面拉伸,优先检查 PreviewView 的宽高比和相机输出宽高比是否一致。例如 PreviewView 是 1080×1920 的竖屏全屏,而相机输出是 1920×1080 的横屏 16:9,此时使用 FILL_CENTER 会裁掉左右两侧,使用 FIT_CENTER 则上下留黑边。正确做法是让 PreviewView 的布局比例也接近 16:9,或者接受裁剪并确认取景范围符合预期。

不要在 onCreate 中直接调用 bindToLifecycle 并期望立刻看到画面。相机启动是异步过程,涉及 HAL 打开、流配置、首帧出流等多个阶段。在低端设备上,从调用到首帧显示可能需要 300ms 到 1s 以上。建议在相机启动期间显示一个占位图或加载指示,避免用户看到长时间黑屏。

选型决策表

场景 推荐视图 理由
普通拍照/录像预览 PreviewView 自动适配,代码量最少,首帧逻辑最省心
高帧率/高分辨率预览 SurfaceView 独立合成,延迟和功耗更低
需要圆角、动画、叠加变换 TextureView 支持完整 View 变换,嵌入布局灵活
列表/复用容器中的预览 PreviewView(COMPATIBLE) 避免 SurfaceView 在复用时的黑边和残影

总结一下:PreviewView 是大多数场景的默认答案,SurfaceView 适合追求极致性能,TextureView 适合需要复杂视觉变换。首帧渲染的关键不在于写了多少代码,而在于视图 Surface 是否就绪、生命周期是否对齐、宽高比和旋转是否匹配。把这三点检查清楚,黑屏、拉伸、旋转问题基本都能定位到具体原因。