Android中使用Kotlin实现CameraX拍照和录像

3,267 阅读6分钟

Andoird中拍照、录像是很常见的功能,但是系统相机的Api目前发生了很大的变化,有Camera1、Camera2、CameraX三个api,每个api的使用和方法都不一样,如果做过相机开发的小伙伴应该会很头疼这三个api在不同安卓系统手机的适配,由于目前的App有一部分工作涉及到这部分,所以总结了一下,目前由基础到深入慢慢总结.

一.简介:(官方介绍如下)

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

具体内容可以参考官网介绍,网站地址为:

CameraX 概览 | Android 开发者 | Android Developers

二.优势:(参考官网)

易用性

图 1. CameraX 以 Android 5.0(API 级别 21)及更高版本为目标平台,涵盖了大多数 Android 设备

CameraX 引入了多个用例,使您可以专注于需要完成的任务,而无需花时间处理不同设备之间的细微差别。一些基本用例如下所示:

  • 预览:在屏幕上显示图像
  • 图像分析:无缝访问缓冲区中的图像以便在算法中使用,例如将其传入 MLKit
  • 图片拍摄:保存优质图片

这些用例适用于搭载 Android 5.0(API 级别 21)或更高版本的所有设备,从而确保了同样的代码适用于市场中的大多数设备。

三.实战代码如下:

1.项目引入CameraX的依赖如下:

在项目的build.gradle导入如下配置:​

// CameraX 核心库使用 camera2 实现
implementation "androidx.camera:camera-camera2:1.0.0-beta07"
// 可以使用CameraView
implementation "androidx.camera:camera-view:1.0.0-alpha14"
// 可以使用供应商扩展
implementation "androidx.camera:camera-extensions:1.0.0-alpha14"
//camerax的生命周期库
implementation "androidx.camera:camera-lifecycle:1.0.0-beta07"

2.项目的Application:​

/**
 * @auth: njb
 * @date: 2021/10/20 16:19
 * @desc: 描述
 */
public class MyApp extends Application {
    public  static MyApp app = null;

    @Override
    public void onCreate() {
        super.onCreate();
        app = this;
    }

    public static MyApp getInstance(){
        return app;
    }
}

3.MainActivity代码如下:

项目的主要3个功能方法:

3.1、相机预览方法:startCamera()​

    /**
     * 开始相机预览
     */
    private fun startCamera() {
        cameraExecutor = Executors.newSingleThreadExecutor()
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener(Runnable {
            cameraProvider = cameraProviderFuture.get()//获取相机信息

            //预览配置
            preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewFinder.createSurfaceProvider())
                }

            imageCamera = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                .build()

            videoCapture = VideoCapture.Builder()//录像用例配置
//                .setTargetAspectRatio(AspectRatio.RATIO_16_9) //设置高宽比
//                .setTargetRotation(viewFinder.display.rotation)//设置旋转角度
//                .setAudioRecordSource(AudioSource.MIC)//设置音频源麦克风
                .build()

            try {
                cameraProvider?.unbindAll()//先解绑所有用例
                camera = cameraProvider?.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCamera,
                    videoCapture
                )//绑定用例
            } catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(this))
    }

3.2、拍照方法:takePhoto()

    private fun takePhoto() {
        val imageCapture = imageCamera ?: return
       val mFileForMat = SimpleDateFormat(DATE_FORMAT, Locale.US)
val    val file = File(FileUtils.getImageFileName(), mFileForMat.format(Date()).toString() + ".jpg")

        val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()

        imageCapture.takePicture(outputOptions, ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                    ToastUtils.shortToast(" 拍照失败 ${exc.message}")
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val savedUri = Uri.fromFile(file)
                    val msg = "Photo capture succeeded: $savedUri"
                    ToastUtils.shortToast(" 拍照成功 $savedUri")
                    Log.d(TAG, msg)
                }
            })
    }

3.3、录像方法:takeVideo()​

/**
 * 开始录像
 */
@SuppressLint("RestrictedApi", "ClickableViewAccessibility")
private fun takeVideo() {
    val mFileDateFormat = SimpleDateFormat(FILENAME_FORMAT, Locale.US)    //视频保存路径
    val file = File(FileUtils.getVideoName(), mFileDateFormat.format(Date()) + ".mp4")    //开始录像
    videoCapture?.startRecording(
        file,
        Executors.newSingleThreadExecutor(),
        object : OnVideoSavedCallback {
            override fun onVideoSaved(@NonNull file: File) {
                //保存视频成功回调,会在停止录制时被调用
                ToastUtils.shortToast(" 录像成功 $file.absolutePath")
            }

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

    btnVideo.setOnClickListener {
        videoCapture?.stopRecording()//停止录制
        //preview?.clear()//清除预览
        btnVideo.text = "Start Video"
        btnVideo.setOnClickListener {
            btnVideo.text = "Stop Video"
            takeVideo()
        }
        Log.d("path", file.path)
    }
}

3.4、切换前后置摄像头方法:

    btnVideo.setOnClickListener {
        videoCapture?.stopRecording()//停止录制
        //preview?.clear()//清除预览
        btnVideo.text = "Start Video"
        btnVideo.setOnClickListener {
            btnVideo.text = "Stop Video"
            takeVideo()
        }
        Log.d("path", file.path)

}

3.5、完整代码如下:

package com.example.cameraxapp

import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.annotation.NonNull
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.*
import androidx.camera.core.VideoCapture.OnVideoSavedCallback
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.cameraxapp.utils.FileUtils
import com.example.cameraxapp.utils.ToastUtils
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

class MainActivity : AppCompatActivity() {
    private var imageCamera: ImageCapture? = null
    private lateinit var cameraExecutor: ExecutorService
    var videoCapture: VideoCapture? = null//录像用例
    var cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA//当前相机
    var preview: Preview? = null//预览对象
    var cameraProvider: ProcessCameraProvider? = null//相机信息
    var camera: Camera? = null//相机对象

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initPermission()
    }

    private fun initPermission() {
        if (allPermissionsGranted()) {
            // ImageCapture
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }
        btnCameraCapture.setOnClickListener {
            takePhoto()
        }
        btnVideo.setOnClickListener {
            btnVideo.text = "Stop Video"
            takeVideo()
        }
        btnSwitch.setOnClickListener {
            cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) {
                CameraSelector.DEFAULT_FRONT_CAMERA
            } else {
                CameraSelector.DEFAULT_BACK_CAMERA
            }
            startCamera()
        }
    }


    private fun takePhoto() {
        val imageCapture = imageCamera ?: return
       val mFileForMat = SimpleDateFormat(DATE_FORMAT, Locale.US)
val    val file = File(FileUtils.getImageFileName(), mFileForMat.format(Date()).toString() + ".jpg")

        val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()

        imageCapture.takePicture(outputOptions, ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                    ToastUtils.shortToast(" 拍照失败 ${exc.message}")
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val savedUri = Uri.fromFile(file)
                    val msg = "Photo capture succeeded: $savedUri"
                    ToastUtils.shortToast(" 拍照成功 $savedUri")
                    Log.d(TAG, msg)
                }
            })
    }

    /**
     * 开始录像
     */
    @SuppressLint("RestrictedApi", "ClickableViewAccessibility")
    private fun takeVideo() {
        val mDateFormat = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
        //视频保存路径
        val file = File(FileUtils.getVideoName(), mDateFormat.format(Date()) + ".mp4")
        //开始录像
        videoCapture?.startRecording(
            file,
            Executors.newSingleThreadExecutor(),
            object : OnVideoSavedCallback {
                override fun onVideoSaved(@NonNull file: File) {
                    //保存视频成功回调,会在停止录制时被调用
                    ToastUtils.shortToast(" 录像成功 $file.absolutePath")
                }

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

        btnVideo.setOnClickListener {
            videoCapture?.stopRecording()//停止录制
            //preview?.clear()//清除预览
            btnVideo.text = "Start Video"
            btnVideo.setOnClickListener {
                btnVideo.text = "Stop Video"
                takeVideo()
            }
            Log.d("path", file.path)
        }
    }

    /**
     * 开始相机预览
     */
    private fun startCamera() {
        cameraExecutor = Executors.newSingleThreadExecutor()
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener(Runnable {
            cameraProvider = cameraProviderFuture.get()//获取相机信息

            //预览配置
            preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewFinder.createSurfaceProvider())
                }

            imageCamera = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                .build()

            videoCapture = VideoCapture.Builder()//录像用例配置
//                .setTargetAspectRatio(AspectRatio.RATIO_16_9) //设置高宽比
//                .setTargetRotation(viewFinder.display.rotation)//设置旋转角度
//                .setAudioRecordSource(AudioSource.MIC)//设置音频源麦克风
                .build()

            try {
                cameraProvider?.unbindAll()//先解绑所有用例
                camera = cameraProvider?.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCamera,
                    videoCapture
                )//绑定用例
            } catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(this))
    }

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults:
        IntArray
    ) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startCamera()
            } else {
              ToastUtils.shortToast("请您打开必要权限");
                finish()
            }
        }
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
    }

    companion object {
       private const val TAG = "CameraXApp"
       private const val DATE_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"        private const val REQUEST_CODE_PERMISSIONS = 10
        private val REQUIRED_PERMISSIONS = arrayOf(
            Manifest.permission.CAMERA,
            Manifest.permission.WRITE_EXTERNAL_STORAGE,
            Manifest.permission.READ_EXTERNAL_STORAGE,
            Manifest.permission.RECORD_AUDIO
        )
    }

}

4.项目封装的文件工具类:

/**
 * @auth: njb
 * @date: 2021/10/20 17:47
 * @desc: 文件工具类
 */
object FileUtils {
    /**
     * 获取视频文件路径
     */
    fun getVideoName(): String {
        val videoPath = Environment.getExternalStorageDirectory().toString() + "/CameraX"
        val dir = File(videoPath)
        if (!dir.exists() && !dir.mkdirs()) {
            ToastUtils.shortToast("文件不存在")
        }
        return videoPath
    }

    /**
     * 获取图片文件路径
     */
    fun getImageFileName(): String {
        val imagePath = Environment.getExternalStorageDirectory().toString() + "/images"
        val dir = File(imagePath)
        if (!dir.exists() && !dir.mkdirs()) {
            ToastUtils.shortToast("文件不存在")
        }
        return imagePath
    }
}

5.项目的ToastUtils工具类代码:

package com.example.cameraxapp.utils;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.view.Gravity;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.StringRes;


import com.example.cameraxapp.app.MyApp;

import org.jetbrains.annotations.NotNull;

import java.lang.reflect.Field;

/**
 * toast工具类
 */
public final class ToastUtils {
    private static final String TAG = "ToastUtil";
    private static Toast mToast;
    private static Field sField_TN;
    private static Field sField_TN_Handler;
    private static boolean sIsHookFieldInit = false;
    private static final String FIELD_NAME_TN = "mTN";
    private static final String FIELD_NAME_HANDLER = "mHandler";

    private static void showToast(final Context context, final CharSequence text,
                                  final int duration, final boolean isShowCenterFlag) {
        ToastRunnable toastRunnable = new ToastRunnable(context, text, duration, isShowCenterFlag);
        if (context instanceof Activity) {
            final Activity activity = (Activity) context;
            if (!activity.isFinishing()) {
                activity.runOnUiThread(toastRunnable);
            }
        } else {
            Handler handler = new Handler(context.getMainLooper());
            handler.post(toastRunnable);
        }
    }

    public static void shortToast(Context context, CharSequence text) {
        showToast(context, text, Toast.LENGTH_SHORT, false);
    }

    public static void longToast(Context context, CharSequence text) {
        showToast(context, text, Toast.LENGTH_LONG, false);
    }

    public static void shortToast(String msg) {
        showToast(MyApp.getInstance(), msg, Toast.LENGTH_SHORT, false);
    }

    public static void shortToast(@StringRes int resId) {
        showToast(MyApp.getInstance(), MyApp.getInstance().getText(resId),
                Toast.LENGTH_SHORT, false);
    }

    public static void centerShortToast(@NonNull String msg) {
        showToast(MyApp.getInstance(), msg, Toast.LENGTH_SHORT, true);
    }

    public static void centerShortToast(@StringRes int resId) {
        showToast(MyApp.getInstance(), MyApp.getInstance().getText(resId),
                Toast.LENGTH_SHORT, true);
    }

    public static void cancelToast() {
        Looper looper = Looper.getMainLooper();
        if (looper.getThread() == Thread.currentThread()) {
            mToast.cancel();
        } else {
            new Handler(looper).post(() -> mToast.cancel());
        }
    }

    private static void hookToast(Toast toast) {
        try {
            if (!sIsHookFieldInit) {
                sField_TN = Toast.class.getDeclaredField(FIELD_NAME_TN);
                sField_TN.setAccessible(true);
                sField_TN_Handler = sField_TN.getType().getDeclaredField(FIELD_NAME_HANDLER);
                sField_TN_Handler.setAccessible(true);
                sIsHookFieldInit = true;
            }
            Object tn = sField_TN.get(toast);
            Handler originHandler = (Handler) sField_TN_Handler.get(tn);
            sField_TN_Handler.set(tn, new SafelyHandlerWrapper(originHandler));
        } catch (Exception e) {
            Log.e(TAG, "Hook toast exception=" + e);
        }
    }

    private static class ToastRunnable implements Runnable {
        private Context context;
        private CharSequence text;
        private int duration;
        private boolean isShowCenter;

        public ToastRunnable(Context context, CharSequence text, int duration, boolean isShowCenter) {
            this.context = context;
            this.text = text;
            this.duration = duration;
            this.isShowCenter = isShowCenter;
        }

        @Override
        @SuppressLint("ShowToast")
        public void run() {
            if (mToast == null) {
                mToast = Toast.makeText(context, text, duration);
            } else {
                mToast.setText(text);
                if (isShowCenter) {
                    mToast.setGravity(Gravity.CENTER, 0, 0);
                }
                mToast.setDuration(duration);
            }
            hookToast(mToast);
            mToast.show();
        }
    }

    private static class SafelyHandlerWrapper extends Handler {
        private Handler originHandler;

        public SafelyHandlerWrapper(Handler originHandler) {
            this.originHandler = originHandler;
        }

        @Override
        public void dispatchMessage(@NotNull Message msg) {
            try {
                super.dispatchMessage(msg);
            } catch (Exception e) {
                Log.e(TAG, "Catch system toast exception:" + e);
            }
        }

        @Override
        public void handleMessage(@NotNull Message msg) {
            if (originHandler != null) {
                originHandler.handleMessage(msg);
            }
        }
    }
}

6.项目的Manifest代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.cameraxapp">
    <uses-feature android:name="android.hardware.camera.any" />
    <uses-permission android:name="android.permission.CAMERA"/>
    <!--存储图像或者视频权限-->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <!--录制音频权限-->
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <application
        android:name=".app.MyApp"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:requestLegacyExternalStorage="true"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity"
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true"
            tools:replace="android:authorities">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
    </application>

</manifest>

四、遇到的问题如下:

1.拍照成功但后台打印日志图片文件写入失败。

2.在Android 10及以上系统提示读写文件失败。

3.录像后屏幕黑屏,预览失败。

4.Android11需要适配所有文件权限。

五、解决方法如下:

1.拍照成功,图片文件写入失败,根据以前项目的经验没有配置FileProvider。

2.在项目的res目录下配置file_paths​

file_paths代码如下:

<external-path name="external_storage_root"

path="." />

3.在manifest配置FileProvider,代码如下:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true"
    tools:replace="android:authorities">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>​

4.Android10外部存储文件权限适配:

在AndroidManifest的application中设置android:requestLegacyExternalStorage="true"。

5.Android11所有文件访问权限适配:

在第4步的基础上添加android:preserveLegacyExternalStorage="true"。

 

 ​

<application    
android:name=".MainApplication"    
android:allowBackup="true"   
 android:icon="@mipmap/app_icon"   
 android:label="@string/app_name"   
 android:requestLegacyExternalStorage="true"    
android:roundIcon="@mipmap/app_icon"    
android:supportsRtl="true"    
android:theme="@style/FullScreenTheme"    
android:usesCleartextTraffic="true"    
tools:ignore="AllowBackup,UnusedAttribute"   
 tools:replace="android:name,android:label">

/**
 * 是否有访问所有文件的权限 
*
* @param activity 
* @return 
*/
public static boolean isRequestAllFileManager(Activity activity) { 
 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {        
//手机版本为Android11且没有申请权限跳转新页面申请权限,有权限则去做相应工作        
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.setData(Uri.parse("package:" + AppUtils.getPackageName(activity)));        
activity.startActivity(intent);        
return false;
}
 return true;
}

6.解决录像后屏幕黑屏,预览失败的方法:

由于我在录像成功后主动调用了清除预览的方法,所以导致黑屏,预览失败,注销此方法即可。

btnVideo.setOnClickListener {

videoCapture?.stopRecording()//停止录制

//preview?.clear()//清除预览

btnVideo.text = "开始录像"

btnVideo.setOnClickListener {

btnVideo.text = "停止录像"

takeVideo()

}

Log.d("path", file.path)

}

7.以上就是今天的CameraXApi的使用,测试了小米、华为、三星、google、oppo、vivo等几款主流机型,Android 9、Android 10、Android11的系统,Android11手机的所有文件权限也适配过,不过由于上架被打回所以又去掉了所有文件权限的申请,至于原因可以去官网看看Android11的具体适配和更新.

参考地址:developer.android.google.cn/about/versi…

主逻辑全部使用的是kotlin,实现了预览、拍照、录像、切换前后置摄像头等功能,当然本文没有仔细展开讲解和Camera1、Camera2的区别,因为这块内容很多,所以后面有时间整理一下,本文还有很多不足之处,望大家谅解,有问题及时提出,共同学习进步。

最后,项目的源码如下:

CameraXApp: Android CameraX相机Api的使用实例