6. 预览Surface配置:使用TextureView作为预览载体、配置SurfaceTexture、设置预览尺寸

好,我们接着往下走。上一节我们把Camera打开,拿到了数据流。但数据流往哪送?你得有个“画布”吧。Android里做相机预览,最常见的两个选择是SurfaceView和TextureView。我个人更偏爱TextureView,原因很简单——它本身就是一个View,可以随便加动画、做变换,甚至能直接当普通控件用。SurfaceView虽然性能好一点,但它在WMS里是独立窗口,很多UI操作受限。

今天我们就来聊聊,怎么用TextureView搭起预览的“舞台”。

为什么选TextureView?

说白了,TextureView就是一个带了SurfaceTexture的View。它不像SurfaceView那样需要独立的窗口,而是直接参与View树的绘制流程。这意味着你可以对它做平移、缩放、旋转,甚至用setAlpha做透明效果。

我在项目中遇到过这样一个场景:需要把相机预览画面缩小到屏幕右下角,同时左上角显示一个AR叠加层。用SurfaceView的话,我得处理窗口层级和Z-order,麻烦得很。换成TextureView,直接一个setScaleX和setTranslationX就搞定了。

核心结论:如果你需要灵活的UI交互,选TextureView。如果追求极致性能和低延迟,选SurfaceView。

TextureView的初始化

在布局里加一个TextureView很简单,但有几个关键点要注意。

<TextureView
    android:id="@+id/preview_texture"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

嗯,这里要注意:宽高不要写死。因为相机输出的尺寸和屏幕尺寸往往不一样,我们需要在代码里动态调整。

接下来,在Activity或Fragment里拿到这个View,并设置监听器:

TextureView textureView = findViewById(R.id.preview_texture);
textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        // SurfaceTexture准备好了,可以打开相机了
        openCamera(surface, width, height);
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
        // 尺寸变了,可能需要重新配置
        configureTransform(width, height);
    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
        // 销毁了,记得释放相机
        releaseCamera();
        return true;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {
        // 每帧更新时回调,一般不用管
    }
});

你想想看,这个回调的顺序很重要。onSurfaceTextureAvailable只触发一次,之后如果尺寸变化,走的是onSurfaceTextureSizeChanged。我曾经踩过一个坑:在onSurfaceTextureAvailable里直接开了相机,但当时TextureView的尺寸还没最终确定,导致预览画面被拉伸。后来我改成在onSurfaceTextureSizeChanged里再做一次尺寸校正,问题就解决了。

配置SurfaceTexture

SurfaceTexture是TextureView背后的“幕后英雄”。它接收Camera输出的图像数据,然后交给TextureView渲染。我们需要做的是:把SurfaceTexture传给Camera,告诉它“往这里画”。

private void openCamera(SurfaceTexture surfaceTexture, int width, int height) {
    // 设置默认缓冲区大小,避免内存浪费
    surfaceTexture.setDefaultBufferSize(previewWidth, previewHeight);

    // 创建Surface对象,Camera需要这个
    Surface surface = new Surface(surfaceTexture);

    try {
        camera = Camera.open();
        camera.setPreviewDisplay(surface);
        camera.startPreview();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

这里有个细节:setDefaultBufferSize一定要调用。如果不设,SurfaceTexture会使用TextureView的尺寸作为缓冲区大小。但TextureView的尺寸是屏幕像素,而相机输出的尺寸是传感器像素,两者往往不匹配。不设的话,轻则浪费内存,重则画面变形。

小技巧:setDefaultBufferSize的宽高,应该和你最终期望的预览尺寸一致,而不是TextureView的显示尺寸。比如你希望预览是1280x720,那就设成1280x720。

设置预览尺寸

预览尺寸的选择,直接决定了画面清晰度和性能。Camera API里有一个getParameters()方法,可以拿到所有支持的预览尺寸。我们需要从中选一个最合适的。

private Size chooseOptimalSize(List<Camera.Size> supportedSizes, int textureViewWidth, int textureViewHeight) {
    // 先找和TextureView宽高比一致的尺寸
    float targetRatio = (float) textureViewWidth / textureViewHeight;
    Size optimalSize = null;
    float minDiff = Float.MAX_VALUE;

    for (Camera.Size size : supportedSizes) {
        float ratio = (float) size.width / size.height;
        if (Math.abs(ratio - targetRatio) < 0.01f) {
            // 宽高比匹配,再选分辨率最高的
            if (optimalSize == null || size.width > optimalSize.width) {
                optimalSize = size;
            }
        }
    }

    // 如果没有完全匹配的,就选最接近的
    if (optimalSize == null) {
        for (Camera.Size size : supportedSizes) {
            float ratio = (float) size.width / size.height;
            float diff = Math.abs(ratio - targetRatio);
            if (diff < minDiff) {
                minDiff = diff;
                optimalSize = size;
            }
        }
    }

    return optimalSize;
}

这段代码的逻辑是:先找宽高比完全匹配的,找不到就找最接近的。为什么优先匹配宽高比?因为如果宽高比不一致,画面会被拉伸,用户体验很差。

我在项目中遇到过一个问题:某些低端手机支持的预览尺寸列表特别长,里面有很多奇怪的尺寸,比如176x144。如果不加筛选,算法可能会选到这种低分辨率尺寸,画面糊得一塌糊涂。后来我加了一个最小分辨率阈值,低于640x480的直接跳过,效果好了很多。

处理画面方向

嗯,这里还有一个容易忽略的点:方向。手机摄像头传感器默认是横屏的,但我们的App可能是竖屏的。如果不做处理,预览画面会旋转90度。

private void configureTransform(int viewWidth, int viewHeight) {
    if (textureView == null || previewSize == null) {
        return;
    }

    Matrix matrix = new Matrix();
    float scaleX = (float) viewWidth / previewSize.getWidth();
    float scaleY = (float) viewHeight / previewSize.getHeight();

    // 以中心点为基准进行缩放
    float centerX = viewWidth / 2f;
    float centerY = viewHeight / 2f;
    matrix.postScale(scaleX, scaleY, centerX, centerY);

    // 旋转90度(竖屏场景)
    matrix.postRotate(90, centerX, centerY);

    textureView.setTransform(matrix);
}

这段代码做了两件事:缩放和旋转。缩放是为了让预览画面填满TextureView,旋转是为了纠正方向。注意旋转的中心点要设在TextureView的中心,否则画面会偏移。

警告:不要直接在Camera的setDisplayOrientation里设置90度就完事了。那个方法只影响预览数据的输出方向,但TextureView本身的坐标系没变。如果你不配合setTransform做矩阵变换,画面虽然正了,但触摸坐标会错位。比如你点左上角,实际响应的是右上角。

整体流程梳理

说了这么多,我们来画个图,把整个流程串起来。

TextureView预览配置流程 1. 布局添加TextureView 宽高设为match_parent 2. 设置SurfaceTextureListener 监听可用/变化/销毁 3. 选择预览尺寸 匹配宽高比 4. 配置SurfaceTexture setDefaultBufferSize 5. 创建Surface对象 new Surface(surfaceTexture) 6. 打开Camera setPreviewDisplay 7. 配置Transform矩阵 缩放 + 旋转校正方向 8. startPreview 画面开始显示 关键点 • 宽高比优先匹配 • setDefaultBufferSize必调 • 方向校正用Matrix

从图上可以看得很清楚:整个流程是串行的。TextureView准备好之后,我们选尺寸、配Surface、开相机、调方向,一步都不能少。少了哪一步,预览要么出不来,要么画面有问题。

常见问题与避坑

  • 预览画面黑屏:检查SurfaceTexture是否已经可用。onSurfaceTextureAvailable没回调之前,不要尝试打开相机。
  • 画面拉伸变形:预览尺寸和TextureView的宽高比不一致。用我上面写的chooseOptimalSize方法,优先匹配宽高比。
  • 画面旋转不对:Camera的setDisplayOrientation和TextureView的setTransform要配合使用。只设一个,另一个不设,就会出问题。
  • 内存泄漏:在onSurfaceTextureDestroyed里一定要释放Camera。我曾经因为忘记释放,导致App切到后台再切回来,Camera打开失败。

我的经验:如果你发现预览画面有闪烁或者撕裂感,试试在setDefaultBufferSize时把尺寸设小一点。比如从1920x1080降到1280x720,很多时候肉眼根本看不出区别,但性能提升很明显。

好了,TextureView的配置就聊到这里。下一节我们会深入Camera2 API,看看新的架构下怎么玩转预览。不过在那之前,建议你把今天的内容动手敲一遍。光看代码是记不住的,亲手调过尺寸、转过方向,你才能真正理解这些配置的意义。


公众号:蓝海资料掘金营,微信deep3321