使用CameraX开发一款你自己的相机App

CameraX官方文档:https://developer.android.google.cn/training/camerax
CameraX官方Demo:https://github.com/android/camera-samples/tree/main/CameraXBasic

CameraX 概览

CameraX 是一个 Jetpack 支持库,旨在帮助您简化相机应用的开发工作。它提供一致且易用的 API 接口,适用于大多数 Android 设备,并可向后兼容至 Android 5.0(API 级别 21)。

虽然 CameraX 利用了 camera2 的功能,但采取了一种具有生命周期感知能力且基于用例的更简单方式。它还解决了设备兼容性问题,因此您无需在代码库中添加设备专属代码。这些功能减少了将相机功能添加到应用时需要编写的代码量。

最后,借助 CameraX,开发者只需两行代码就能实现与预安装的相机应用相同的相机体验和功能。CameraX Extensions 提供了一些可选的插件,让您可以在支持的设备上添加各种特效,这些特效包括焦外成像(人像)、HDR、夜间和脸部照片修复。

>>今天我们就根据最新版CameraX官方文档手撸一个相机App<<

话不多说,先看效果图
照片列表

拍照页面

视频列表

录视频页面
写在前面

本文虽然是讲如何使用CameraX开发一款相机App,但这是基于Android Jetpack开发的,同时也会涉及很多新框架,这里先罗列一下涉及到的核心知识点:

1.Kotlin
2.ViewPager2
3.TabLayout与ViewPager2的联动
4.RxJava,RxAndroid,RxPermissions
5.DataViewBinding 和 MVVM
6.androidx.lifecycle
7.ActivityResultLauncher
8.ConstraintLayout
9.RecyclerView(如果你还在用ListView或者GridView,真是Out了,RecyclerView什么都能做,包括最新的ViewPager2也是用RecyclerView实现的)

第1步 添加依赖

创建Android项目并添加CameraX依赖,在app目录下的build.gradle文件内添加CameraX依赖,截止2021年12月01日,CameraX官方最新版本是1.1.0-alpha11

// CameraX core library using the camera2 implementation
def camerax_version = "1.1.0-alpha11"
// The following line is optional, as the core library is included indirectly by camera-camera2
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
// If you want to additionally use the CameraX Lifecycle library
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
// If you want to additionally use the CameraX View class
implementation "androidx.camera:camera-view:1.0.0-alpha31"
// If you want to additionally use the CameraX Extensions library
// implementation "androidx.camera:camera-extensions:1.0.0-alpha31"
第2步 权限声明

在AndroidManifest.xml中声明权限和硬件使用信息

<!-- 具备摄像头 -->
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.autofocus" />
<uses-feature android:name="android.hardware.camera.any" />

<!-- 相机权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 录音权限 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 读写外部存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
第3步 权限申请

这是一个相册相机App,进入首页就需要读取已拍摄的照片列表,这里需要获取外部存储读写权限,使用RxPermissions实现

    /**
     * 使用by lazy来对rxPermissions进行初始化, 
     * 由于我们这里不涉及多线程,所以加上LazyThreadSafetyMode.NONE
     */
    val rxPermissions: RxPermissions by lazy(LazyThreadSafetyMode.NONE) {
        RxPermissions(this)
    }

    @SuppressLint("CheckResult")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.lifecycleOwner = this
        setContentView(binding.root)

        rxPermissions.request(
            Manifest.permission.READ_EXTERNAL_STORAGE,
            Manifest.permission.WRITE_EXTERNAL_STORAGE
        ).subscribe {
            if (it) {// 授权成功,初始化ViewPager2
                initViewPager2()
            } else {
                // 授权失败,弹窗并提供退出按钮(后期可增加必要权限说明弹窗)
                showMessage("授权失败,无法读取相册。", withExit = true)
            }
        }
    }
第4步 启动相机

创建一个CameraXActivity,并启动这个Activity,由于进入相机后需要第一时间展示摄像头预览画面,所以我们选择在启动相机前进行权限申请。

    // 新的知识点:ActivityResultLauncher,用于取代startActivityForResult
    lateinit var cameraResultLauncher: ActivityResultLauncher<Intent>
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // ActivityResultLauncher必须在页面创建的时候初始化
        cameraResultLauncher =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
                if (it.resultCode == Activity.RESULT_OK) {
                    val intent: Intent? = it.data
                    val path = intent?.getStringExtra("path")
                    if (!TextUtils.isEmpty(path)) {
                        LogUtil.d("拍摄成功:$path")
                        notifyItemInserted(path!!)
                    }
                }
            }
        // 子类重写此方法即可
        onInit()
    }

    @SuppressLint("CheckResult")
    fun openCamera(type: Int) {
        rxPermissions.request(
            Manifest.permission.CAMERA,
            Manifest.permission.WRITE_EXTERNAL_STORAGE
        ).subscribe {
            if (it) {
                // 授权成功
                LogUtil.d("授权成功,启动相机")
                val intent = Intent(requireContext(), CameraXActivity::class.java)
                intent.putExtra("type", type)// 这个type用于区分拍照还是摄像,后面会用到
                cameraResultLauncher.launch(intent)
            } else {
                // 授权失败
                showMessage("授权失败,无法启动相机。")
            }
        }
    }
第5步 拍照页面布局文件
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <!--实时镜头预览画面,不再需要SurfaceView或者TextureView-->
        <androidx.camera.view.PreviewView
            android:id="@+id/surfacePreview"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:background="@color/colorPrimary" />

        <View
            android:id="@+id/bgControl"
            android:layout_width="0dp"
            android:layout_height="144dp"
            android:background="@color/black_20p"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

        <!--切换前置/后置摄像头-->
        <com.zcs.app.camerax.widget.ScalableImageView
            android:id="@+id/btnSwitch"
            android:layout_width="54dp"
            android:layout_height="54dp"
            android:layout_margin="10dp"
            android:onClick="switchCamera"
            android:padding="10dp"
            android:src="@mipmap/ic_switch_camera"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <!--可缩放的白色大圆,点击变小并执行拍照-->
        <com.zcs.app.camerax.widget.ScalableImageView
            android:id="@+id/takePhoto"
            android:layout_width="60dp"
            android:layout_height="60dp"
            android:onClick="takePhoto"
            android:src="@drawable/bg_circle_white"
            app:layout_constraintBottom_toBottomOf="@id/bgControl"
            app:layout_constraintEnd_toEndOf="@id/bgControl"
            app:layout_constraintStart_toStartOf="@id/bgControl"
            app:layout_constraintTop_toTopOf="@id/bgControl" />

        <!--取消按钮-->
        <com.zcs.app.camerax.widget.ScalableImageView
            android:id="@+id/btnClose"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:onClick="closeCamera"
            android:padding="10dp"
            android:src="@mipmap/ic_arrow_down"
            app:layout_constraintBottom_toBottomOf="@id/bgControl"
            app:layout_constraintEnd_toStartOf="@id/takePhoto"
            app:layout_constraintStart_toStartOf="@id/bgControl"
            app:layout_constraintTop_toTopOf="@id/bgControl" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
第6步 拍照功能实现
    // 使用by lazy初始化摄像头预览Preview
    private val preview by lazy(LazyThreadSafetyMode.NONE) {
        Preview.Builder()
            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
            .build()
    }

    // 使用by lazy来初始化ImageCapture
    private val imageCapture by lazy(LazyThreadSafetyMode.NONE) {
        ImageCapture.Builder()
            // 设置照片比例,目前只有16:9和4:3
            .setTargetAspectRatio(AspectRatio.RATIO_16_9)
            // 设置预览方向,默认手机方向,可以不用修改
            // .setTargetRotation(binding.surfacePreview.display.rotation)
            // 设置照片压缩质量[1,100],值越大越清晰,默认值:95或100,根据模式而定
            .setJpegQuality(100)
            .build()
    }

    // Camerax实现拍照的核心
    private var cameraProvider: ProcessCameraProvider? = null
    // 默认使用后置摄像头
    var cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA//当前相机

    // 页面初始化
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.lifecycleOwner = this
        setContentView(binding.root)
        // 启动相机
        startCamera()
    }
    /**
     * 打开相机
     */
    private fun startCamera() {
        if (cameraProvider == null) {
            val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
            cameraProviderFuture.addListener({
                cameraProvider = cameraProviderFuture.get()
                startPreview()
            }, ContextCompat.getMainExecutor(this))
        } else {
            startPreview()
        }
    }

    /**
     * 开启预览
     */
    private fun startPreview() {
        try {
            // 解除相机之前的所有绑定
            cameraProvider?.unbindAll()
            // 绑定前面用于预览和拍照的UseCase到相机上
            cameraProvider?.bindToLifecycle(this, cameraSelector, preview, imageCapture)
            // 设置用于预览的view
            preview.setSurfaceProvider(binding.surfacePreview.surfaceProvider)
        } catch (exc: Exception) {
            exc.printStackTrace()
            showMessage("相机启动失败,${exc.message}")
        }
    }

    /**
     * 切换前置/后置摄像头
     */
    fun switchCamera(v: View) {
        cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) {
            CameraSelector.DEFAULT_FRONT_CAMERA
        } else {
            CameraSelector.DEFAULT_BACK_CAMERA
        }
        // 切换摄像头后,需要重新开启预览
        startPreview()
    }

    /**
     * 拍照
     */
    fun takePhoto(v: View) {
        // 照片保存路径
        val imagePath = "${externalCacheDir?.absolutePath}/Pic_${System.currentTimeMillis()}.jpg"
        val file = File(imagePath)
        val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
        ImageCapture.OutputFileOptions.Builder(file)
        // 开始拍照
        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    // 拍照失败
                    showMessage("Photo capture failed: ${exc.message}")
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    LogUtil.d("图片保存成功 -->${output.savedUri}")
                    LogUtil.d("图片保存成功 -->$imagePath")
                    // 拍照成功后,关闭相机并将照片绝对路径返回相册列表
                    val intent = Intent()
                    intent.putExtra("path", imagePath)
                    setResult(RESULT_OK, intent)
                    finish()
                }
            })
    }

    override fun onDestroy() {
        super.onDestroy()
        // 退出时记得关闭相机
        cameraProvider?.shutdown()
    }

至此,简单的拍照流程已经结束,你可以拿着这个图片的绝对路径为所欲为了。

第7步 视频录制
    // 使用by lazy来初始化VideoCapture
    private val videoCapture by lazy(LazyThreadSafetyMode.NONE) {
        VideoCapture.Builder()//录像用例配置
            // 设置视频比特率,720P 大概是2000Kbps  1080P 大概是6000Kbps
            // 如果这里不设置的话,摄像头像素很高的手机,拍出来的视频文件超大
            .setBitRate(1024 * 1024 * 2)
            // 设置音频比特率,可以使用系统默认
            // .setAudioBitRate(1024)
            // 设置视频帧率-高于30帧/秒,视频格式会过大。低于25帧/秒,视频会出现卡屏现象。
            .setVideoFrameRate(30)
            // 设置输出视频比例
            .setTargetResolution(Size(720, 1280))
            // 设置高宽比 不能和setTargetResolution共存
            // .setTargetAspectRatio(CustomCameraConfig.mAspectRatio)
            // 设置旋转角度,默认根据手机方向决定,非必要无需修改
            // .setTargetRotation(binding.surfacePreview.display.rotation)
            .build()
    }

    /**
     * 开启预览
     */
    private fun startPreview() {
        try {
            // 解除相机之前的所有绑定
            cameraProvider?.unbindAll()
            // 绑定前面用于预览和拍照的UseCase到相机上
            cameraProvider?.bindToLifecycle(
                this, cameraSelector, preview, imageCapture, videoCapture
            )
            // 设置用于预览的view
            preview.setSurfaceProvider(binding.surfacePreview.surfaceProvider)
        } catch (exc: Exception) {
            exc.printStackTrace()
            showMessage("相机启动失败,${exc.message}")
        }
    }

    /**
     * 开始视频录制
     */
    fun startRecord(v: View) {
        // 视频保存路径
        val videoPath = "${externalCacheDir?.absolutePath}/V_${System.currentTimeMillis()}.mp4"
        val file = File(videoPath)
        val outputOptions = VideoCapture.OutputFileOptions.Builder(file).build()
        // 开始录制
        videoCapture.startRecording(outputOptions,
            ContextCompat.getMainExecutor(this),
            object : VideoCapture.OnVideoSavedCallback {
                override fun onVideoSaved(output: VideoCapture.OutputFileResults) {
                    LogUtil.d("视频保存成功 -->${output.savedUri}")
                    LogUtil.d("视频保存成功 -->$videoPath")
                    // 关闭视频录制,并将绝对路径返回
                    val intent = Intent()
                    intent.putExtra("path", videoPath)
                    setResult(RESULT_OK, intent)
                    finish()
                }

                override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
                    // 保存失败的回调,可能在开始或结束录制时被调用
                    showMessage("录像失败,$message")
                }
            }
        )
    }

至此,简单的视频拍摄流程已经结束,你可以拿着这个视频的绝对路径为所欲为了。

写在最后

文中只展示了部分核心代码,完整代码请查阅:
GitHub:https://github.com/ZengCS/MyCameraX
Gitee:https://gitee.com/ZengCS/MyCameraX
后期会继续维护,迭代

工程源码截图

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351

推荐阅读更多精彩内容