我們的生活已經越來越離不開相機,從自拍
到直播
,掃碼
再到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