音視訊開發:為什麼推薦使用Jetpack CameraX?

Button123發表於2021-05-07

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

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

作者

TechMerger

前言

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

影像拍攝

藉助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版,需要繼續改進,但並非不能投入生產環境。

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

參考資料

視訊講解

CameraX與手機螢幕採集、CameraX與攝像頭資料採集

B站:https://www.bilibili.com/video/BV1kp4y187C7?p=20

百度雲盤視訊下載:
連結:https://pan.baidu.com/s/1RtvX1Zea6CuJNUJo2iOtHw
提取碼:k3qp

相關文章