音影片開發:為什麼推薦使用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
