爲何推薦使用Jetpack CameraX?

本文多是當下最新最全的CameraX解讀,篇幅較長,慢慢享用。java

咱們的生活已經愈來愈離不開相機,從自拍直播掃碼再到VR等等。相機的優劣天然就成爲了廠商競相追逐的賽場。對於app開發者來講,如何快速驅動相機,提供優秀的拍攝體驗,優化相機的使用功耗,是一直以來追求的目標。android

前言

Android 5.0 時期Camera接口便已棄用,因此通常的作法是使用其替代者Camera2接口。但隨着CameraX的出現,這個選擇變得再也不惟一。git

咱們先來回顧下圖像預覽這一簡單的需求,使用Camera2接口是如何實現的。github

Camera2

拋開回調,異常等附加處理,仍然須要多個步驟才能實現,比較繁瑣。※篇幅緣由省略代碼只歸納步驟※web

graph TD
   佈局裏展現TextureView控件 --> 事先啓動HandlerThread並取得其Handler實例供相機回調 --> 確保app擁有camera權限 --> 監聽TextureView的可用的時機並配置相機 --> 獲取並保存目標鏡頭的ID和參數 --> 將尺寸/縮放比率/屏幕方向等參數反映給TextureView --> 經過CameraManager啓動相機 --> 在相機啓動成功的回調裏將其CameraDevice和Surface創建鏈接

一樣是圖像預覽採用CameraX的話,實現就很是簡潔。markdown

CameraX

圖像預覽

能夠說十幾行就能夠完成。和Camera2同樣須要展現預覽的控件PreviewView到佈局上,並確保得到了camera權限。差別的地方主要體如今相機的配置步驟上。app

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,它有四個子類,分別爲PreviewImageCaptureImageAnalysisVideoCapture。接下來介紹下它們如何使用。ide

圖像拍攝

藉助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);
        }
    }
複製代碼

圖像分析

視頻錄製

依託VideoCapturestartRecording()能夠進行視頻錄製。在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…

使用注意

  1. 調用CameraProviderbindToLifecycle()前記得先調用unbindAll(),不然可能發生重複綁定的exception

  2. ImageAnalyzeranalyze()在分析完圖片以後應當即調用ImageProxyclose()釋放圖像,以便後續圖像能繼續傳送過來。不然將阻塞回調。於是也要注意分析圖像的耗時問題

  3. 每一個ImageProxy實例在關閉後不要存儲它的引用,由於一旦調用close(),這些圖像將變得不合法

  4. 圖像分析結束後應當調用ImageAnalysisclearAnalyzer()以告知不用將圖像流傳輸過來避免性能的浪費

  5. 視頻錄製場景必定不要忘記得到audio權限

有趣的兼容性處理

實現圖像拍攝功能的時候發現ImageCapturetakePicture()文檔裏寫着這麼一段有趣的註釋。

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.

大意是拍攝保存的UriMediaStore的話,將插入一行以驗證保存路徑是否合法並可寫。驗證結束後會刪除該測試行。

可是在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的優點

源於CameraXCamera2的基礎上進行了高度的封裝和對大量設備進行了兼容性的處理,使得CameraX擁有了不少優點。

  • 易用性 採用封裝的API能夠高效達到目標
  • 設備一致性 不用在意版本,忽略設備硬件差別帶來的開發區別,達到一致的開發體驗
  • 新的相機體驗 經過效果擴展能夠實現和原生相機同樣的美顏等拍攝功能

本文demo

demo的源碼已經開源至Github,你們能夠查閱參考。

github.com/ellisonchan…

結語

CameraX發佈於2019年8月7日,從alpha版到如今的beta版,一直在更新。從上面有趣的Huawei設備兼容性處理能夠看到CameraX一統江湖的決心。

最新還是beta版,須要繼續改進,但並不是不能投入生產環境。

這麼好用的框架,你們要多多使用並給出建議,這樣才能愈來愈完善,才能給開發者給用戶帶來福音。

參考資料

相關文章
相關標籤/搜索