音视频开发:为什么推荐使用Jetpack CameraX?
阅读原文时间:2021年05月08日阅读:1

我们的生活已经越来越离不开相机,从自拍直播扫码再到VR等等。相机的优劣自然就成为了厂商竞相追逐的赛场。对于app开发者来说,如何快速驱动相机,提供优秀的拍摄体验,优化相机的使用功耗,是一直以来追求的目标。

本文可能是当下最新最全的CameraX解读,篇幅较长,慢慢享用。

作者

TechMerger

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

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

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

同样是图像预览采用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());
    }

镜头切换

如果想要切换镜头,只要将目标镜头的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);
        ...
    }

镜头聚焦

无法聚焦的拍摄是不完整的,我们监听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);
    }

除了图像预览以外还有很多其他使用场景,比如图像拍摄,图像分析和视频录制。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);
        ...
    }

图像分析

图像分析指的是对预览的图像实时分析,将色彩,内容等信息识别出来,应用在机器学习二维码识别等业务场景。继续对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);
        }
    }

视频录制

依托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();
        }
    }

小插曲

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

点击视频录制按钮的时候,如果此刻尚未获得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支持将图像数据进行转换后输出,比如应用于人像识别后绘制人脸框图

developer.android.google.cn/training/ca…

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

developer.android.google.cn/training/ca…

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

developer.android.google.cn/training/ca…

使用注意

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

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

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

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

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

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

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.

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

但是在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;
    }
}

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

  • 易用性 采用封装的API可以高效达到目标
  • 设备一致性 不用在乎版本,忽略设备硬件差异带来的开发区别,达到一致的开发体验
  • 新的相机体验 通过效果扩展可以实现和原生相机一样的美颜等拍摄功能

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

github.com/ellisonchan…

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

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

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

视频讲解

CameraX与手机屏幕采集、CameraX与摄像头数据采集

B站:https://www.bilibili.com/video/BV1kp4y187C7?p=20

百度云盘视频下载:

链接:https://pan.baidu.com/s/1RtvX1Zea6CuJNUJo2iOtHw

提取码:k3qp

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章