Jetpack CameraX 最新最全解读-开发实践
本文可能是當下最新最全的CameraX解讀,篇幅較長,慢慢享用。
Pangu-Immortal (Pangu-Immortal) · GitHub
我們的生活已經越來越離不開相機,從自拍到直播,掃碼再到VR等等。相機的優劣自然就成為了廠商競相追逐的賽場。對于app開發者來說,如何快速驅動相機,提供優秀的拍攝體驗,優化相機的使用功耗,是一直以來追求的目標。
前言
Android 5.0 時期Camera接口便已棄用,所以一般的做法是使用其替代者Camera2接口。
但隨著CameraX的出現,這個選擇變得不再唯一。
我們先來回顧下圖像預覽這一簡單的需求,使用Camera2接口是如何實現的。
導包
implementation "androidx.camera:camera-camera2:1.0.0-beta07" implementation "androidx.camera:camera-view:1.0.0-alpha14" implementation "androidx.camera:camera-extensions:1.0.0-alpha14" implementation "androidx.camera:camera-lifecycle:1.0.0-beta07"請求權限
在 Manifest 的 manifest 節點下中加入以下內容:
<!--攝像頭權限--> <uses-permission android:name="android.permission.CAMERA" /> <!--具備攝像頭--> <uses-feature android:name="android.hardware.camera.any" /> <!--存儲圖像或者視頻權限--> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!--錄制音頻權限--> <uses-permission android:name="android.permission.RECORD_AUDIO" />Android Q適配
在 manifest 標簽里面加入一條屬性:
android:requestLegacyExternalStorage="true"畫黃線不理,如果不加這句,在Android Q上會無法往相冊存儲文件。
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() {@Overridepublic 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() {@Overridepublic 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.javapublic 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 devicesfinal 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,大家可以查閱參考。
Pangu-Immortal (Pangu-Immortal) · 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…
總結
以上是生活随笔為你收集整理的Jetpack CameraX 最新最全解读-开发实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Jetpack WorkManager的
- 下一篇: Android 11 限制反射,如何破解