Android开发又一新挑战!CameraX 即将一统江湖?

你可能没想到 CameraX也是 Jetpack 的一部分。

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

前言

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

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

Camera2

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

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

镜头切换

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

它有四个子类,分别为Preview,ImageCapture,ImageAnalysis和VideoCapture。接下来介绍下它们如何使用。

图像拍摄

借助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);
    }
}

视频录制

依托VideoCapture的startRecording()可以进行视频录制。

在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。

其实日志里也会给出相应提示: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

使用注意

  1. 调用CameraProvider的bindToLifecycle()前记得先调用unbindAll(),否则可能发生重复绑定的exception。
  2. ImageAnalyzer的analyze()在分析完图片之后应立即调用ImageProxy的close()释放图像,以便后续图像能继续传送过来。否则将阻塞回调。因而也要注意分析图像的耗时问题。
  3. 每个ImageProxy实例在关闭后不要存储它的引用,因为一旦调用close(),这些图像将变得不合法。
  4. 图像分析结束后应当调用ImageAnalysis的clearAnalyzer()以告知不用将图像流传输过来避免性能的浪费。
  5. 视频录制场景一定不要忘记获得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可以高效达到目标。
  • 设备一致性 不用在乎版本,忽略硬件差异,达到一致的开发体验。
  • 新的相机体验 通过效果扩展可以实现和原生相机一样的美颜等拍摄功能。

结语

CameraX发布于2019年8月7日,从alpha版到现在的beta版,一直在更新。

从上面有趣的Huawei设备兼容性处理可以看到CameraX一统江湖的决心。

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

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

猜你喜欢

转载自blog.csdn.net/A_pyf/article/details/115149091