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