音视频开发:为什么推荐使用Jetpack CameraX?
我们的生活已经越来越离不开相机,从自拍
到直播
,扫码
再到VR
等等。相机的优劣自然就成为了厂商竞相追逐的赛场。对于app开发者来说,如何快速驱动相机,提供优秀的拍摄体验,优化相机的使用功耗,是一直以来追求的目标。
本文可能是当下最新最全的
CameraX
解读,篇幅较长,慢慢享用。
作者
前言
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
支持将图像数据进行转换后输出,比如应用于人像识别
后绘制人脸框图
developer.android.google.cn/training/ca…
- 用例旋转 图像拍摄和分析的过程中屏幕可能发生旋转,学习如何配置使得
CameraX
能够实时获取到屏幕方向和旋转角度,以抓取到正确的图像
developer.android.google.cn/training/ca…
- 配置选项 控制分辨率,自动对焦,取景框形状设置等配置的指导
developer.android.google.cn/training/ca…
使用注意
-
调用
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
,大家可以查阅参考。
结语
CameraX
发布于2019年8月7日,从alpha版到现在的beta版,一直在更新。从上面有趣的Huawei设备兼容性处理可以看到CameraX
一统江湖的决心。
最新仍是beta版,需要继续改进,但并非不能投入生产环境。
这么好用的框架,大家要多多使用并给出建议,这样才能越来越完善,才能给开发者给用户带来福音。
参考资料
CameraX
使用指南:developer.android.google.cn/training/ca…CameraX
的历史版本:developer.android.google.cn/jetpack/and…CameraX
的兼容和效果扩展支持的设备:developer.android.google.cn/training/ca…CameraX
的官方示例:github.com/android/cam…
视频讲解
CameraX与手机屏幕采集、CameraX与摄像头数据采集
B站:https://www.bilibili.com/video/BV1kp4y187C7?p=20
百度云盘视频下载:
链接:https://pan.baidu.com/s/1RtvX1Zea6CuJNUJo2iOtHw
提取码:k3qp