为什么推荐使用CameraX?

简介: 为什么推荐使用CameraX?

前言

Android 5.0 时期Camera接口便已弃用,所以一般的做法是使用其替代者Camera2接口。但随着CameraX的出现,这个选择变得不再唯一。

我们先来回顾下图像预览这一简单的需求,使用Camera2接口是如何实现的。

Camera2

抛开回调,异常等附加处理,仍然需要多个步骤才能实现,比较繁琐。※篇幅原因省略代码只概括步骤※

1672144979206.png同样是图像预览采用CameraX的话,实现就非常简洁。

CameraX

图像预览

可以说十几行就可以完成。和Camera2一样需要展示预览的控件PreviewView到布局上,并确保获得了camera权限。差异的地方主要体现在相机的配置步骤上。

    private void setupCamera(PreviewView previewView) {
        ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
                ProcessCameraProvider.getInstance(this);
        cameraProviderFuture.addListener(() -> {
            try {
                mCameraProvider = cameraProviderFuture.get();
                bindPreview(mCameraProvider, previewView);
            } catch (ExecutionException | InterruptedException e) {
                e.printStackTrace();
            }
        }, ContextCompat.getMainExecutor(this));
    }
    private void bindPreview(@NonNull ProcessCameraProvider cameraProvider,
                             PreviewView previewView) {
        mPreview = new Preview.Builder().build();
        mCamera = cameraProvider.bindToLifecycle(this,
                CameraSelector.DEFAULT_BACK_CAMERA, mPreview);
        mPreview.setSurfaceProvider(previewView.getSurfaceProvider());
    }

20200308234636925.jpg

镜头切换

如果想要切换镜头,只要将目标镜头的CameraSelector示例绑定到CameraProvider即可。我们在画面上添加按钮以切换镜头。

    public void onChangeGo(View view) {
        if (mCameraProvider != null) {
            isBack = !isBack;
            bindPreview(mCameraProvider, binding.previewView);
        }
    }
    private void bindPreview(@NonNull ProcessCameraProvider cameraProvider,
                             PreviewView previewView) {
        ...
        CameraSelector cameraSelector = isBack ? CameraSelector.DEFAULT_BACK_CAMERA
                : CameraSelector.DEFAULT_FRONT_CAMERA;
        // 绑定前确保解除了所有绑定,防止CameraProvider重复绑定到Lifecycle发生异常
        cameraProvider.unbindAll(); 
        mCamera = cameraProvider.bindToLifecycle(this, cameraSelector, mPreview);
        ...
    }

20200308234636925.jpg

镜头聚焦

无法聚焦的拍摄是不完整的,我们监听Preview的触摸事件将触摸坐标告知CameraX开始聚焦。

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        binding.previewView.setOnTouchListener((v, event) -> {
            FocusMeteringAction action = new FocusMeteringAction.Builder(
                    binding.previewView.getMeteringPointFactory()
                            .createPoint(event.getX(), event.getY())).build();
            try {
                showTapView((int) event.getX(), (int) event.getY());
                mCamera.getCameraControl().startFocusAndMetering(action);
            }...
        });
    }
    private void showTapView(int x, int y) {
        PopupWindow popupWindow = new PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
        ImageView imageView = new ImageView(this);
        imageView.setImageResource(R.drawable.ic_focus_view);
        popupWindow.setContentView(imageView);
        popupWindow.showAsDropDown(binding.previewView, x, y);
        binding.previewView.postDelayed(popupWindow::dismiss, 600);
        binding.previewView.playSoundEffect(SoundEffectConstants.CLICK);
    }

20200308234636925.jpg

除了图像预览以外还有很多其他使用场景,比如图像拍摄,图像分析和视频录制。CameraX将这些使用场景统一抽象为UseCase,它有四个子类,分别为PreviewImageCaptureImageAnalysisVideoCapture。接下来介绍下它们如何使用。

图像拍摄

借助ImageCapture提供的takePicture()可以将图像拍摄下来。支持保存到外部存储空间,当然需要获得external storage的读写权限。

    private void takenPictureInternal(boolean isExternal) {
        final ContentValues contentValues = new ContentValues();
        contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, CAPTURED_FILE_NAME
                + "_" + picCount++);
        contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg");
        ImageCapture.OutputFileOptions outputFileOptions = 
                new ImageCapture.OutputFileOptions.Builder(
                        getContentResolver(),
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                .build();
        if (mImageCapture != null) {
            mImageCapture.takePicture(outputFileOptions, CameraXExecutors.mainThreadExecutor(),
                    new ImageCapture.OnImageSavedCallback() {
                        @Override
                        public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
                            Toast.makeText(DemoActivityLite.this, "Picture got"
                                    + (outputFileResults.getSavedUri() != null
                                    ? " @ " + outputFileResults.getSavedUri().getPath()
                                    : "") + ".", Toast.LENGTH_SHORT)
                                    .show();
                        }
                        ...
                    });
        }
    }
    private void bindPreview(@NonNull ProcessCameraProvider cameraProvider,
                             PreviewView previewView) {
        ...
        mImageCapture =  new ImageCapture.Builder()
                .setTargetRotation(previewView.getDisplay().getRotation())
                .build();
        ...
        // 需要将ImageCapture场景一并绑定
        mCamera = cameraProvider.bindToLifecycle(this, cameraSelector, mPreview, mImageCapture);
        ...
    }

20200308234636925.jpg

图像分析

图像分析指的是对预览的图像实时分析,将色彩,内容等信息识别出来,应用在机器学习二维码识别等业务场景。继续对demo做些改造,添加扫描二维码的按钮。点击按钮后进入扫码模式,并在二维码解析成功后弹出解析结果。

    public void onAnalyzeGo(View view) {
        if (!isAnalyzing) {
            mImageAnalysis.setAnalyzer(CameraXExecutors.mainThreadExecutor(), image -> {
               analyzeQRCode(image);
            });
        }
        ...
    }
    // 从ImageProxy取出图像数据,交由二维码框架zxing解析
    private void analyzeQRCode(@NonNull ImageProxy imageProxy) {
        ByteBuffer byteBuffer = imageProxy.getPlanes()[0].getBuffer();
        byte[] data = new byte[byteBuffer.remaining()];
        byteBuffer.get(data);
        ...
        BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
        Result result;
        try {
            result = multiFormatReader.decode(bitmap);
        }
        ...
        showQRCodeResult(result);
        imageProxy.close();
    }
    private void showQRCodeResult(@Nullable Result result) {
        if (binding != null && binding.qrCodeResult != null) {
            binding.qrCodeResult.post(() ->
                    binding.qrCodeResult.setText(result != null ? "Link:\n" + result.getText() : ""));
            binding.qrCodeResult.playSoundEffect(SoundEffectConstants.CLICK);
        }
    }

20200308234636925.jpg

视频录制

依托VideoCapturestartRecording()可以进行视频录制。在demo上添加一个图像拍摄和视频录制模式的切换按钮,切换到视频录制模式的时候将视频拍摄的UseCase綁定到CameraProvider

    public void onVideoGo(View view) {
        bindPreview(mCameraProvider, binding.previewView, isVideoMode);
    }
    private void bindPreview(@NonNull ProcessCameraProvider cameraProvider,
                             PreviewView previewView, boolean isVideo) {
        ...
        mVideoCapture = new VideoCapture.Builder()
                .setTargetRotation(previewView.getDisplay().getRotation())
                .setVideoFrameRate(25)
                .setBitRate(3 * 1024 * 1024)
                .build();
        cameraProvider.unbindAll();
        if (isVideo) {
            mCamera = cameraProvider.bindToLifecycle(this, cameraSelector,
                    mPreview, mVideoCapture);
        } else {
            mCamera = cameraProvider.bindToLifecycle(this, cameraSelector,
                    mPreview, mImageCapture, mImageAnalysis);
        }
        mPreview.setSurfaceProvider(previewView.getSurfaceProvider());
    }

点击录制按钮后首先确保获得外部存储和audio权限,之后再开始视频的录制。

    public void onCaptureGo(View view) {
        if (isVideoMode) {
            if (!isRecording) {
                // Check permission first.
                ensureAudioStoragePermission(REQUEST_STORAGE_VIDEO);
            }
        }
        ...
    }
    private void ensureAudioStoragePermission(int requestId) {
        ...
        if (requestId == REQUEST_STORAGE_VIDEO) {
            if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                    != PackageManager.PERMISSION_GRANTED
                    || ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
                    != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(...);
                return;
            }
            recordVideo();
        }
    }
    private void recordVideo() {
       try {
            mVideoCapture.startRecording(
                    new VideoCapture.OutputFileOptions.Builder(getContentResolver(),
                            MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
                            .build(),
                    CameraXExecutors.mainThreadExecutor(),
                    new VideoCapture.OnVideoSavedCallback() {
                        @Override
                        public void onVideoSaved(@NonNull VideoCapture.OutputFileResults outputFileResults) {
                            // Notify user...
                        }
                    }
            );
        } 
        ...
        toggleRecordingStatus();
    }
    private void toggleRecordingStatus() {
        // Stop recording when toggle to false.
        if (!isRecording && mVideoCapture != null) {
            mVideoCapture.stopRecording();
        }
    }

image.png

小插曲

实现视频录制功能的时候发现一个问题。


点击视频录制按钮的时候,如果此刻尚未获得audio权限,那么将申请该权限。即便此后获得了权限调用拍摄接口仍将发生异常。日志显示AudioRecorder实例为null引发了NPE。


仔细查看相关逻辑发现,demo现在的处理是在切换为视频录制模式的时候,就将VideoCapture绑定到了CameraProvider。这个时间点如果还未获得audio权限的话,那么将无法初始化AudioRecorder。其实日志里也会给出相应提示:VideoCapture: AudioRecord object cannot initialized correctly。


可是后面获得了权限再去调用VideoCapture的拍摄接口为何还是会发生NPE?


因为拍摄接口startRecording()的内部处理是AudioRecorder实例为null的话将直接终止请求。后面无论调用多少遍也无济于事。事实上该函数的后段存在再次获取AudioRecorder实例的逻辑,但因为前面发生了NPE而没有机会执行。

    // VideoCapture.java
    public void startRecording(
            @NonNull OutputFileOptions outputFileOptions, @NonNull Executor executor,
            @NonNull OnVideoSavedCallback callback) {
        ...
        try {
            // mAudioRecorder为null将引发NPE终止录制的请求
            mAudioRecorder.startRecording();
        } catch (IllegalStateException e) {
            postListener.onError(ERROR_ENCODER, "AudioRecorder start fail", e);
            return;
        }
        ...
        mRecordingFuture.addListener(() -> {
            ...
            if (getCamera() != null) {
                // 前面发生了NPE,那么将失去此处再次获得AudioRecorder实例的机会
                setupEncoder(getCameraId(), getAttachedSurfaceResolution());
                notifyReset();
            }
        }, CameraXExecutors.mainThreadExecutor());
        ...
    }

不知道这是VideoCapture实现上的漏洞还是开发者有意为之。但是在明明已经获得了audio权限的情况下调用录製接口却仍然发生NPE貌似并不合理。


当下只能采取一些回避方案,或者说开发者本该就这么做?


现在是在获得了audio权限前执行了VideoCapture的绑定,这存在发生上述反复NPE的可能。所以改成获得audio权限后再绑定VideoCapture即可回避。


话说回来,在VideoCaptue的文档里加上需要获得audio的权限的说明是不是更好一些呢?


相机效果扩展

光有上述几个场景的使用并不能满足日益丰富的拍摄需求,人像,夜拍,美颜等相机效果是必不可少的。幸好CameraX是支持效果扩展的。但不是所有设备都能兼容这种扩展,具体可在官网的设备兼容列表里查询到。


可供扩展的效果主要分为两大类,一个是用于图像预览时效果扩展的PreviewExtender,另一个是用于图像拍摄时效果扩展的ImageCaptureExtender。


每个大类都包含几个典型的效果。


NightPreviewExtender 夜拍预览

BokehPreviewExtender 人像预览

BeautyPreviewExtender 美顔预览

HdrPreviewExtender HDR预览

AutoPreviewExtender 自动预览

开启这些效果的实现也非常简单。

    private void bindPreview(@NonNull ProcessCameraProvider cameraProvider,
                             PreviewView previewView, boolean isVideo) {
        Preview.Builder previewBuilder = new Preview.Builder();
        ImageCapture.Builder captureBuilder = new ImageCapture.Builder()
                .setTargetRotation(previewView.getDisplay().getRotation());
        ...
        setPreviewExtender(previewBuilder, cameraSelector);
        mPreview = previewBuilder.build();
        setCaptureExtender(captureBuilder, cameraSelector);
        mImageCapture =  captureBuilder.build();
        ...
    }
    private void setPreviewExtender(Preview.Builder builder, CameraSelector cameraSelector) {
        BeautyPreviewExtender beautyPreviewExtender = BeautyPreviewExtender.create(builder);
        if (beautyPreviewExtender.isExtensionAvailable(cameraSelector)) {
            // Enable the extension if available.
            beautyPreviewExtender.enableExtension(cameraSelector);
        }
    }
    private void setCaptureExtender(ImageCapture.Builder builder, CameraSelector cameraSelector) {
        NightImageCaptureExtender nightImageCaptureExtender = NightImageCaptureExtender.create(builder);
        if (nightImageCaptureExtender.isExtensionAvailable(cameraSelector)) {
            // Enable the extension if available.
            nightImageCaptureExtender.enableExtension(cameraSelector);
        }
    }

遗憾的是笔者手中的Redmi 6A不在支持OEM效果扩展的设备列表里,无法给大家展示成功扩展效果的样图。


高阶用法

除了上述常见相机使用场景外还有其他可选的配置方法。篇幅限制不再详细展开,感兴趣者可参考官网进行尝试。


转换输出 CameraX支持将图像数据进行转换后输出,比如应用于人像识别后绘制人脸框图

https://developer.android.google.cn/training/camerax/transform-output?hl=zh-cn


用例旋转 图像拍摄和分析的过程中屏幕可能发生旋转,学习如何配置使得CameraX能够实时获取到屏幕方向和旋转角度,以抓取到正确的图像

https://developer.android.google.cn/training/camerax/orientation-rotation?hl=zh-cn


配置选项 控制分辨率,自动对焦,取景框形状设置等配置的指导

https://developer.android.google.cn/training/camerax/configuration?hl=zh-cn

使用注意

调用CameraProvider的bindToLifecycle()前记得先调用unbindAll(),否则可能发生重复绑定的exception


ImageAnalyzer的analyze()在分析完图片之后应立即调用ImageProxy的close()释放图像,以便后续图像能继续传送过来。否则将阻塞回调。因而也要注意分析图像的耗时问题


每个ImageProxy实例在关闭后不要存储它的引用,因为一旦调用close(),这些图像将变得不合法


图像分析结束后应当调用ImageAnalysis的clearAnalyzer()以告知不用将图像流传输过来避免性能的浪费


视频录制场景一定不要忘记获得audio权限

有趣的兼容性处理

实现图像拍摄功能的时候发现ImageCapture的takePicture()文档里写着这么一段有趣的注释。


Before triggering the image capture pipeline, if the save location is a File or MediaStore, it is first verified to ensure it’s valid and writable.


A File is verified by attempting to open a FileOutputStream to it, whereas a location in MediaStore is validated by ContentResolver#insert() creating a new row in the user defined table, retrieving a Uri pointing to it, then attempting to open an OutputStream to it.

The newly created row is ContentResolver#delete() deleted at the end of the verification.


On Huawei devices, this deletion results in the system displaying a notification informing the user that a photo has been deleted. In order to avoid this, validating the image capture save location in MediaStore is skipped on Huawei devices.


大意是拍摄保存的Uri为MediaStore的话,将插入一行以验证保存路径是否合法并可写。验证结束后会删除该测试行。


但是在Huawei设备上删除行的操作将触发一条删除照片的通知。所以为避免困扰用户,CameraX将会在Huawei设备上跳过路径的验证。

class ImageSaveLocationValidator {
  // 将判断设备品牌是否为华为或荣耀,是则直接跳过验证
    static boolean isValid(final @NonNull ImageCapture.OutputFileOptions outputFileOptions) {
        ...
        if (isSaveToMediaStore(outputFileOptions)) {
            // Skip verification on Huawei devices
            final HuaweiMediaStoreLocationValidationQuirk huaweiQuirk =
                    DeviceQuirks.get(HuaweiMediaStoreLocationValidationQuirk.class);
            if (huaweiQuirk != null) {
                return huaweiQuirk.canSaveToMediaStore();
            }
            return canSaveToMediaStore(outputFileOptions.getContentResolver(),
                    outputFileOptions.getSaveCollection(), outputFileOptions.getContentValues());
        }
        return true;
    }
    ...
}
public class HuaweiMediaStoreLocationValidationQuirk implements Quirk {
    static boolean load() {
        return "HUAWEI".equals(Build.BRAND.toUpperCase())
                || "HONOR".equals(Build.BRAND.toUpperCase());
    }
    /**
     * Always skip checking if the image capture save destination in
     * {@link android.provider.MediaStore} is valid.
     */
    public boolean canSaveToMediaStore() {
        return true;
    }
}

CameraX的优势

源于CameraX在Camera2的基础上进行了高度的封裝和对大量设备进行了兼容性的处理,使得CameraX拥有了很多优势。


易用性 采用封装的API可以高效达到目标

设备一致性 不用在乎版本,忽略设备硬件差异带来的开发区别,达到一致的开发体验

新的相机体验 通过效果扩展可以实现和原生相机一样的美颜等拍摄功能

本文demo

demo的源码已经开源至Github,大家可以查阅参考。

https://github.com/ellisonchan/JetpackDemo

结语

CameraX发布于2019年8月7日,从alpha版到现在的beta版,一直在更新。从上面有趣的Huawei设备兼容性处理可以看到CameraX一统江湖的决心。


最新仍是beta版,需要继续改进,但并非不能投入生产环境。


这么好用的框架,大家要多多使用并给出建议,这样才能越来越完善,才能给开发者给用户带来福音。

参考资料

CameraX的历史版本:https://developer.android.google.cn/jetpack/androidx/releases/camera?hl=zh-cn

CameraX的兼容和效果扩展支持的设备:https://developer.android.google.cn/training/camerax/devices?hl=zh-cn

CameraX的官方示例:https://github.com/android/camera-samples/tree/main/CameraXBasic


相关文章
|
4月前
|
编解码 测试技术 Android开发
Android经典实战之用 CameraX 库实现高质量的照片和视频拍摄功能
本文详细介绍了如何利用CameraX库实现高质量的照片及视频拍摄功能,包括添加依赖、初始化、权限请求、配置预览与捕获等关键步骤。此外,还特别针对不同分辨率和帧率的视频拍摄提供了性能优化策略,确保应用既高效又稳定。
413 1
Android经典实战之用 CameraX 库实现高质量的照片和视频拍摄功能
|
7月前
|
Java 开发工具 Android开发
OpenCV(一):Android studio jni配置OpenCV(亲测有效,保姆级)
OpenCV(一):Android studio jni配置OpenCV(亲测有效,保姆级)
860 0
|
4月前
|
存储 传感器 缓存
Nvidia Isaac Sim安装与配置 入门教程 2024(2)
本文是Nvidia Isaac Sim安装与配置的入门教程,指导用户如何检查系统配置、安装Omniverse环境、配置Nucleus服务器、安装Isaac Sim软件包、设置命令行环境和编辑器环境,以及如何启动Isaac Sim仿真和加载机器人与环境。
791 0
|
Android开发 API
Android Camera2 拍照(四)——对焦模式
原文:Android Camera2 拍照(四)——对焦模式 本篇将重点介绍使用Camera2 API进行手动对焦的设置,以及在手动对焦与自动对焦模式之间切换。
3707 0
|
安全 测试技术 API
Android 14适配指南
Android 14应用适配
2346 0
|
Android开发
关于安卓DialogFragment使用(三)
关于安卓DialogFragment使用(三)
287 0
|
XML 缓存 监控
Android 性能优化(一): 启动优化理论与实践
本文章总结了目前市面上常见的一些启动优化常用手段,**开发和面试必备哦**
|
API 开发者
HarmonyOS学习路之开发篇—多媒体开发(相机开发 二)
Camera操作类,包括相机预览、录像、拍照等功能接口。
|
开发框架 自然语言处理 Java
妈!Jetpack Compose太难学了,别怕,这里帮你理清几个概念(一)
妈!Jetpack Compose太难学了,别怕,这里帮你理清几个概念
365 0
|
索引
Ant Design:表格自定义渲染
Ant Design:表格自定义渲染
218 0