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