音视频技巧: 为什么我推荐你使用CameraX?

共 15015字,需浏览 31分钟

 ·

2023-01-03 15:21

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

我们的生活已经越来越离不开相机,从自拍直播扫码再到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。
它有四个子类,分别为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
其实日志里也会给出相应提示:AudioRecord object cannot initialized correctly可是后面获得了权限再去调用VideoCapture的拍摄接口为何还是会发生NPE?
因为拍摄接口startRecording()的内部处理是AudioRecorder实例为null的话将直接终止请求。后面无论调用多少遍也无济于事。事实上该函数的后段存在再次获取AudioRecorder实例的逻辑,但因为前面发生了NPE而没有机会执行。
// VideoCapture.javapublic 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. 调用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; }}

CameraX的优势

源于CameraXCamera2的基础上进行了高度的封裝和对大量设备进行了兼容性的处理,使得CameraX拥有了很多优势。
  • 易用性 采用封装的API可以高效达到目标
  • 设备一致性 不用在乎版本,忽略硬件差异,达到一致的开发体验
  • 新的相机体验 通过效果扩展可以实现和原生相机一样的美颜等拍摄功能

本文demo

demo的源码已经开源至Github,大家可以查阅参考。
https://github.com/ellisonchan/JetpackDemo

结语

CameraX发布于2019年8月7日,从alpha版到现在的beta版,一直在更新。从上面有趣的Huawei设备兼容性处理可以看到CameraX一统江湖的决心。
最新仍是beta版,需要继续改进,但并非不能投入生产环境。
这么好用的框架,大家要多多使用并给出建议,这样才能越来越完善,才能给开发者给用户带来福音。

「点击关注,Carson每天带你学习一个Android知识点。」

最后福利:学习资料赠送

  • 福利:本人亲自整理的「Android学习资料」
  • 数量:10名
  • 参与方式:「点击右下角”在看“并回复截图到公众号,随机抽取」

    点击就能升职、加薪水!



浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报