音视频开发:为什么推荐使用 Jetpack CameraX?
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()
文档里写着这么一段有趣的注释。
评论