27、LiveData与CameraX:相机预览帧数据、使用LiveData传递图片数据、实战:拍照应用

说实话,CameraX 这个库刚出来的时候,我内心是拒绝的。毕竟之前用 Camera2 写预览、调参数,那叫一个酸爽——回调套回调,状态机写到手抽筋。后来项目里要做一个扫码功能,我硬着头皮试了试 CameraX,嗯,真香。

这一章咱们就聊聊,怎么把 LiveData 和 CameraX 结合起来,做一个完整的拍照应用。说白了,就是用 LiveData 这把「数据快递」的利器,把相机预览帧和拍下来的图片,安全高效地送到 UI 层。

27.1 CameraX 的核心概念

先简单过一下 CameraX 的几个关键角色,不然你后面看代码可能会懵。

组件 作用
ProcessCameraProvider 生命周期绑定器,把相机和 LifecycleOwner 绑在一起
Preview 预览用例,把画面显示到 PreviewView 上
ImageCapture 拍照用例,拍一张照片
ImageAnalysis 分析用例,每一帧回调给你处理

我个人习惯把这三个用例看成「三兄弟」:Preview 负责显示,ImageCapture 负责拍照,ImageAnalysis 负责分析。它们可以同时运行,互不干扰。

27.2 用 LiveData 传递预览帧数据

先来看一个常见场景:实时分析相机画面,比如扫码、人脸检测。这时候 ImageAnalysis 就派上用场了。

我记得第一次做实时帧分析时,直接在分析器里更新 UI,结果卡成 PPT。后来才意识到,分析线程和主线程是两码事,必须用 LiveData 做桥梁。

核心思路:在 ImageAnalysis.Analyzer 的 analyze() 方法中,把帧数据 post 到 LiveData 上,UI 层观察这个 LiveData 即可。

class FrameAnalyzer : ImageAnalysis.Analyzer {

    private val _frameData = MutableLiveData<Bitmap>()
    val frameData: LiveData<Bitmap> = _frameData

    override fun analyze(image: ImageProxy) {
        // 把 ImageProxy 转成 Bitmap
        val bitmap = imageProxyToBitmap(image)
        // 发到主线程
        _frameData.postValue(bitmap)
        // 别忘了关闭 image,否则会卡死
        image.close()
    }

    private fun imageProxyToBitmap(image: ImageProxy): Bitmap {
        // 这里省略 YUV 转 Bitmap 的细节
        // 实际项目中可以用 RenderScript 或 YuvImage
        return Bitmap.createBitmap(/* ... */)
    }
}

小提示:postValue() 和 setValue() 的区别你肯定知道——postValue 可以在子线程调用,setValue 只能在主线程。分析器运行在后台线程,所以必须用 postValue。

然后在 ViewModel 里暴露这个 LiveData:

class CameraViewModel : ViewModel() {

    private val analyzer = FrameAnalyzer()

    // 对外暴露不可变的 LiveData
    val frameData: LiveData<Bitmap> = analyzer.frameData

    fun getAnalyzer(): ImageAnalysis.Analyzer = analyzer
}

UI 层观察:

viewModel.frameData.observe(viewLifecycleOwner) { bitmap ->
    // 更新到 ImageView 或者做其他处理
    binding.ivPreview.setImageBitmap(bitmap)
}

你看,这样就把相机帧数据和 UI 层解耦了。分析器只管生产数据,UI 层只管消费数据,中间通过 LiveData 自动切换线程。

27.3 用 LiveData 传递拍照图片

拍照的逻辑其实更简单。ImageCapture 拍完照会回调一个 OnImageCapturedCallback,我们在回调里把图片数据塞进 LiveData。

我曾经踩过一个坑:拍照回调里直接更新 UI,结果 Activity 重建后图片丢了。用 LiveData 就能完美解决——数据跟着 ViewModel 走,配置变更也不怕。

class CameraViewModel : ViewModel() {

    private val _capturedImage = MutableLiveData<Bitmap>()
    val capturedImage: LiveData<Bitmap> = _capturedImage

    private val imageCapture = ImageCapture.Builder()
        .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
        .build()

    fun takePhoto() {
        imageCapture.takePicture(
            ContextCompat.getMainExecutor(appContext),
            object : ImageCapture.OnImageCapturedCallback() {
                override fun onCaptureSuccess(image: ImageProxy) {
                    val bitmap = imageProxyToBitmap(image)
                    _capturedImage.postValue(bitmap)
                    image.close()
                }

                override fun onError(exception: ImageCaptureException) {
                    // 错误处理
                }
            }
        )
    }
}

注意:takePicture 的回调可以指定执行线程。我建议用主线程执行器,这样 setValue 和 postValue 都可以用。但如果你在回调里做耗时操作,记得切到后台线程。

27.4 实战:完整拍照应用

好,理论说完了,咱们动手写一个完整的拍照应用。麻雀虽小五脏俱全,包含预览、拍照、显示结果三个功能。

27.4.1 项目结构

先看看整体架构,我画了一张图帮你理解:

拍照应用架构图 UI 层 MainActivity PreviewView ImageView ViewModel 层 CameraViewModel LiveData<Bitmap> LiveData<Bitmap> CameraX 层 Preview ImageCapture ImageAnalysis 观察 调用 数据流向: 1. CameraX 产生帧数据 / 拍照图片 2. ViewModel 通过 LiveData 暴露数据 3. UI 层观察 LiveData,自动更新界面 4. 配置变更(旋转屏幕)时数据不丢失

27.4.2 核心代码实现

首先是布局文件,很简单:一个 PreviewView 用于预览,一个 Button 用于拍照,一个 ImageView 用于显示拍好的照片。

<!-- activity_main.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.camera.view.PreviewView
        android:id="@+id/previewView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/btnCapture" />

    <Button
        android:id="@+id/btnCapture"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="拍照"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent" />

    <ImageView
        android:id="@+id/ivResult"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:scaleType="centerCrop"
        android:layout_margin="16dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintRight_toRightOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

然后是 ViewModel,我们把前面两个功能合并到一起:

class CameraViewModel : ViewModel() {

    // 预览帧数据
    private val _frameData = MutableLiveData<Bitmap>()
    val frameData: LiveData<Bitmap> = _frameData

    // 拍照结果
    private val _capturedImage = MutableLiveData<Bitmap>()
    val capturedImage: LiveData<Bitmap> = _capturedImage

    // 相机相关
    private var imageCapture: ImageCapture? = null
    private var cameraProvider: ProcessCameraProvider? = null

    fun startCamera(context: Context, lifecycleOwner: LifecycleOwner, previewView: PreviewView) {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
        cameraProviderFuture.addListener({
            cameraProvider = cameraProviderFuture.get()

            // 预览
            val preview = Preview.Builder().build()
            preview.setSurfaceProvider(previewView.surfaceProvider)

            // 拍照
            imageCapture = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                .build()

            // 分析
            val analysis = ImageAnalysis.Builder()
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()
            analysis.setAnalyzer(Executors.newSingleThreadExecutor(), createFrameAnalyzer())

            // 绑定生命周期
            cameraProvider?.bindToLifecycle(
                lifecycleOwner,
                CameraSelector.DEFAULT_BACK_CAMERA,
                preview,
                imageCapture,
                analysis
            )
        }, ContextCompat.getMainExecutor(context))
    }

    private fun createFrameAnalyzer(): ImageAnalysis.Analyzer {
        return ImageAnalysis.Analyzer { image ->
            val bitmap = imageProxyToBitmap(image)
            _frameData.postValue(bitmap)
            image.close()
        }
    }

    fun takePhoto() {
        imageCapture?.takePicture(
            ContextCompat.getMainExecutor(appContext),
            object : ImageCapture.OnImageCapturedCallback() {
                override fun onCaptureSuccess(image: ImageProxy) {
                    val bitmap = imageProxyToBitmap(image)
                    _capturedImage.postValue(bitmap)
                    image.close()
                }

                override fun onError(exception: ImageCaptureException) {
                    // 处理错误
                }
            }
        )
    }

    override fun onCleared() {
        super.onCleared()
        cameraProvider?.unbindAll()
    }
}

最后是 Activity 里的调用:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val viewModel: CameraViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 启动相机
        viewModel.startCamera(this, this, binding.previewView)

        // 观察拍照结果
        viewModel.capturedImage.observe(this) { bitmap ->
            binding.ivResult.setImageBitmap(bitmap)
        }

        // 拍照按钮
        binding.btnCapture.setOnClickListener {
            viewModel.takePhoto()
        }
    }
}

个人经验:startCamera 方法里我用了 STRATEGY_KEEP_ONLY_LATEST 策略。什么意思呢?就是如果分析器处理不过来,就丢掉旧的帧,只保留最新的。这样能保证 UI 上看到的是最新的画面,不会越积越多。

27.5 避坑指南

嗯,这里要重点说说我踩过的几个坑,你遇到了可以少走弯路。

  • ImageProxy 必须 close:我曾经忘记在 analyze 方法里调用 image.close(),结果相机预览卡死,报错说「ImageProxy 未关闭」。记住,每次分析完或者拍完照,一定要 close。
  • 生命周期绑定:CameraX 虽然会自动处理生命周期,但如果你在 onPause 之后还去调用 takePhoto,可能会 crash。建议在 ViewModel 里加一个状态判断。
  • Bitmap 转换性能:YUV 转 Bitmap 是个耗时操作,我建议用 RenderScript 或者直接使用 CameraX 的 ImageProxy 的 planes 数据,不要每次都 new 一个 Bitmap。
  • LiveData 的粘性事件:如果你用 LiveData 传递拍照结果,旋转屏幕后可能会再次触发观察者。这时候可以用 SingleLiveEvent 或者 Kotlin 的 Channel 来解决。

特别注意:CameraX 的 ImageAnalysis 分析器运行在后台线程,千万不要在里面更新 UI。所有 UI 更新必须通过 LiveData 切回主线程。这是新手最容易犯的错误。

好了,这一章的内容就到这里。LiveData 和 CameraX 的结合,说白了就是「生产者-消费者」模式的一种体现。你只要记住:CameraX 负责生产数据,LiveData 负责运输数据,UI 层负责消费数据。各司其职,代码自然清晰。

如果你在实际项目中遇到什么问题,欢迎交流。毕竟,相机开发这条路,坑多路滑,咱们一起趟过去。


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