音影片開發:為什麼推薦使用Jetpack CameraX?
- 2021 年 5 月 7 日
- 筆記
我們的生活已經越來越離不開相機,從自拍
到直播
,掃碼
再到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站://www.bilibili.com/video/BV1kp4y187C7?p=20
百度雲盤影片下載:
鏈接://pan.baidu.com/s/1RtvX1Zea6CuJNUJo2iOtHw
提取碼:k3qp