音视频技巧: 为什么我推荐你使用CameraX?
本文可能是时下最新最全的
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。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() {@Overridepublic 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);...}

图像分析
机器学习,二维码识别等业务场景。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()可以进行视频录制。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() {@Overridepublic 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。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
使用注意
调用 CameraProvider的bindToLifecycle()前记得先调用unbindAll(),否则可能发生重复绑定的exceptionImageAnalyzer的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 devicesfinal 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
Github,大家可以查阅参考。结语
CameraX发布于2019年8月7日,从alpha版到现在的beta版,一直在更新。从上面有趣的Huawei设备兼容性处理可以看到CameraX一统江湖的决心。beta版,需要继续改进,但并非不能投入生产环境。「点击关注,Carson每天带你学习一个Android知识点。」
最后福利:学习资料赠送

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