CameraX:Android 相機庫開發實踐

WngShhng發表於2019-04-23

前言

前段時間因為工作的需要對專案中的相機模組進行了優化,我們專案中的相機模組是基於開源庫 CameraView 進行開發的。那次優化主要包括兩個方面,一個是相機的啟動速度,另一個是相機的拍攝的清晰度的問題。因為時間倉促,那次只是在原來的程式碼的基礎之上進行的優化,然而那份程式碼本身存在一些問題,導致相機的啟動速度無法進一步提升。所以,我準備自己開發一款功能完善,並且可擴充的相機庫,於是 CameraX 就誕生了。

雖然去年學習了很多的 Android 的知識,但是這並沒有什麼驕傲的。我覺得如果一個人學習了很多的東西,但是卻沒有辦法做出屬於自己的東西,那麼即使學了也跟沒學一樣。相比於學習能力,我更看重人的創造力。所以我也將開發一個 Android 相機庫作為個人 2019 年在 Android 上面要完成的目標之一。

Android 相加開源庫的現狀

要使用 Android 相機實現圖片拍照功能本身並不複雜,Camera1 + SurfaceView 就可以搞定。但是如果讓相機能夠自由擴充,就需要花費很多的功夫。我所接觸的開源庫包括 Google 非官方的 CameraView,以及 CameraFragment. 兩個庫的設計有各自的優點和缺點。

開源庫 優點 缺點
CameraView 1.支援基本的拍照、縮放等功能;2.支援自定義圖片的寬高比;3.支援多種預覽佈局方式; 1.每次獲取相機支援的尺寸的時候,會先將其組裝到一個有序的 Set 中,這個過程會佔用一定的啟動時間;2.不支援拍攝視訊;3.程式碼堆砌,結構混亂
CameraFragment 1.支援拍攝照片和視訊;2.程式碼結構清晰 1.不支援縮放;2.預設寬高比4:3,無法執行時修改;3.必須基於 Fragment

以上是兩個開源庫的優點和缺點,而我們可以結合它們的優缺點實現一個更加完善的相機庫,同時對效能的優化和使用者自定義配置,我們也提供了更多的可用的介面。

CameraX 整體結構設計

雖然文章的題目是相機開發實踐,但是我們並不打算介紹太多關於如何使用 Camera API 的內容,因為本專案是開源的,讀者可以自行 Fork 程式碼進行閱讀。在這裡,我們只對專案中的一些關鍵部分的設計思路進行說明。

相機整體架構

連結:www.processon.com/view/link/5…

以上是我們相機庫的整體架構的設計圖,這裡筆者使用了 UML 建模進行基礎的架構設計(當然,並非嚴格遵循 UML 建模的語言規則)。下面,我們介紹下專案的關鍵部分的設計思路。

Camera1 還是 Camera2?

瞭解 Android 相機 API 的同學可能知道,在 LoliPop 上面提出了 Camera2 API. 就筆者個人的實踐開發的效果來看,Camera2 相機的效能確實比 Camera1 要好得多,這體現在相機對焦的速率和相機啟動的速率上。當然,這和硬體也有一定的關係。Camera2 比 Camera1 使用起來確實複雜得多,但提供的可以呼叫的 API 也更豐富。Camera2 的另一個問題是國內的很多手機裝置對 Camera2 的支援並不好。

對於這個問題,首先,我們可以根據系統的引數來判斷該裝置是否支援 Camera2:

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public static boolean hasCamera2(Context context) {
        if (context == null) return false;
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return false;
        try {
            CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
            assert manager != null;
            String[] idList = manager.getCameraIdList();
            boolean notNull = true;
            if (idList.length == 0) {
                notNull = false;
            } else {
                for (final String str : idList) {
                    if (str == null || str.trim().isEmpty()) {
                        notNull = false;
                        break;
                    }
                    final CameraCharacteristics characteristics = manager.getCameraCharacteristics(str);

                    Integer iSupportLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL);
                    if (iSupportLevel != null && iSupportLevel == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
                        notNull = false;
                        break;
                    }
                }
            }
            return notNull;
        } catch (Throwable ignore) {
            return false;
        }
    }
複製程式碼

不過,即便上面方法返回的結果標明支援 Camera2,但相機仍然可能在啟動中出現異常。所以 CameraView 的解決方案是,相機啟動的方法返回一個 boolean 型別標明 Camera2 是否啟動成功,如果失敗了,就降級並使用 Camera1。但是降級的過程會浪費一定的啟動時間,因此,有人提出了使用 SharedPreferences 儲存降級的記錄,下次直接使用 Camera1 的解決方案。

上面兩種方案各自有優缺點,使用第二種方案意味著你要修改相機庫的原始碼,而我們希望以一種更加靈活的方式提供給使用者選擇相機的權力。沒錯,就是策略設計模式

因為雖然 Camera1 和 Camera2 的 API 設計和使用不同,但是我們並不需要知道內部如何實現,我們只需要給使用者提供切換相機、開啟閃光燈、拍照、縮放等的介面即可。在這種情況下,當然使用門面設計模式是最好的選擇。

另外,對於 TextureView 還是 SurfaceView 的選擇,我們也使用了策略模式+門面模式的思路。

即。對於相機的選擇,我們提供門面 CameraManager 介面,Camera1 的實現類 Camera1Manager 以及 Camera2 的實現類 Camera2Manager. Camera1Manager 和 Camera2Manager 又統一繼承自 BaseCameraManager. 這裡的 BaseCameraManager 是一個抽象類,用來封裝一些通用的相機方法。

所以問題到了是 Camera1Manager 還是 Camera2Manager 的問題。這裡我們提供了策略介面 CameraManagerCreator,它返回 CameraManager:

public interface CameraManagerCreator {

    CameraManager create(Context context, CameraPreview cameraPreview);
}
複製程式碼

以及一個預設的實現:

public class CameraManagerCreatorImpl implements CameraManagerCreator {

    @Override
    public CameraManager create(Context context, CameraPreview cameraPreview) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && CameraHelper.hasCamera2(context)) {
            return new Camera2Manager(cameraPreview);
        }
        return new Camera1Manager(cameraPreview);
    }
}
複製程式碼

因此,我們只需要在相機的全域性配置中指定自己的 CameraManager 建立策略就可以使用指定的相機了。

全域性配置

之前考慮指定 CameraManager 建立策略的時候,思路是直接對靜態的變數賦值的方式,不過後來考慮到對相機的支援的尺寸進行快取的問題,所以將其設計了靜態單例項的類:

public class ConfigurationProvider {

    private static volatile ConfigurationProvider configurationProvider;

    private ConfigurationProvider() {
        if (configurationProvider != null) {
            throw new UnsupportedOperationException("U can't initialize me!");
        }
        initWithDefaultValues();
    }

    public static ConfigurationProvider get() {
        if (configurationProvider == null) {
            synchronized (ConfigurationProvider.class) {
                if (configurationProvider == null) {
                    configurationProvider = new ConfigurationProvider();
                }
            }
        }
        return configurationProvider;
    }

    // ... ...
}
複製程式碼

除了指定一些全域性的配置之外,我們還可以在 ConfigurationProvider 中快取一些相機的資訊,比如相機支援的尺寸的問題。因為相機所支援的尺寸屬於相機屬性的一部分,是不變的,我們沒有必要獲取多次,可以將其快取起來,下次直接使用。當然,我們還提供了不使用快取的介面:

public class ConfigurationProvider {

    // ...
    private boolean useCacheValues;
    private List<Size> pictureSizes;

    public List<Size> getPictureSizes(android.hardware.Camera camera) {
        if (useCacheValues && pictureSizes != null) {
            return pictureSizes;
        }
        List<Size> sizes = Size.fromList(camera.getParameters().getSupportedPictureSizes());
        if (useCacheValues) {
            pictureSizes = sizes;
        }
        return sizes;
    }

}
複製程式碼

這樣,我們在獲取相機支援的圖片尺寸資訊的時候只需要傳入 Camera 即可使用快取的資訊。當然,快取資訊在某些極端的情況下可能會帶來問題,比如從 Camera1 切換到 Camera2 的時候,需要清除快取。

注:這裡快取的時候應該使用 SoftReference,但是考慮到資料量不大,沒有這麼設計,以後會考慮修改。

輸出媒體檔案的尺寸的問題

使用 Android 相機一個讓人頭疼的地方是計算尺寸的問題:因為相機支援的尺寸有三種,包括相片的支援尺寸、預覽的支援尺寸和視訊的支援尺寸。預覽的尺寸決定了使用者看到的畫面的清晰程度,但是真正拍攝出圖片的清晰度取決於相片的尺寸,同理輸出的視訊的尺寸取決於視訊的尺寸。

在 CameraView 中,它允許你指定一個圖片的尺寸,當沒有滿足的要求的尺寸的時候會 Crash…這樣的處理方式是將其不好的,因為使用者根本無法確定相機最大的支援尺寸,而 CameraView 甚至沒有提供獲取相機支援尺寸的介面……

為了解決這個問題,我們首先提供了一系列使用者獲取相機支援尺寸的介面:

    Size getSize(@Camera.SizeFor int sizeFor);

    SizeMap getSizes(@Camera.SizeFor int sizeFor);
複製程式碼

這裡的 SizeFor 是基於註解的列舉,我們通過它來判斷使用者是希望獲取相片、預覽還是視訊的尺寸資訊。這裡的 SizeMap 是一個雜湊表,從相機的寬高比對映到對應的尺寸列表。跟 CameraView 處理方式不同的是,我們只有在呼叫上述方法的時候才計算圖片的寬高比資訊,雖然呼叫下面的方法的時候會花費一丁點兒時間,但是相機的啟動速度大大提升了:

    @Override
    public SizeMap getSizes(@Camera.SizeFor int sizeFor) {
        switch (sizeFor) {
            case Camera.SIZE_FOR_PREVIEW:
                if (previewSizeMap == null) {
                    previewSizeMap = CameraHelper.getSizeMapFromSizes(previewSizes);
                }
                return previewSizeMap;
            case Camera.SIZE_FOR_PICTURE:
                if (pictureSizeMap == null) {
                    pictureSizeMap = CameraHelper.getSizeMapFromSizes(pictureSizes);
                }
                return pictureSizeMap;
            case Camera.SIZE_FOR_VIDEO:
                if (videoSizeMap == null) {
                    videoSizeMap = CameraHelper.getSizeMapFromSizes(videoSizes);
                }
                return videoSizeMap;
        }
        return null;
    }
複製程式碼

獲取了相機的尺寸資訊的目的當然是將其設定到相機上面,所以我們提供了兩個用來設定相機尺寸的介面:

    void setExpectSize(Size expectSize);

    void setExpectAspectRatio(AspectRatio expectAspectRatio);
複製程式碼

它們一個用來指定期望的輸出檔案的尺寸,一個用來指定期望的圖片的寬高比。

OK,既然使用者可以指定計算引數,那麼怎麼計算呢?這當然還是使用者說了算的,因為我們一樣在全域性配置中為使用者提供了計算的策略介面:

public interface CameraSizeCalculator {

    Size getPicturePreviewSize(@NonNull List<Size> previewSizes, @NonNull Size pictureSize);

    Size getVideoPreviewSize(@NonNull List<Size> previewSizes, @NonNull Size videoSize);

    Size getPictureSize(@NonNull List<Size> pictureSizes, @NonNull AspectRatio expectAspectRatio, @Nullable Size expectSize);

    Size getVideoSize(@NonNull List<Size> videoSizes, @NonNull AspectRatio expectAspectRatio, @Nullable Size expectSize);
}
複製程式碼

當然,我們也會提供一個預設的計算策略。在 CameraManager 內部,我們會在需要的地方呼叫上述介面的方法以獲取最終的相機尺寸資訊:

    private void adjustCameraParameters(boolean forceCalculateSizes, boolean changeFocusMode, boolean changeFlashMode) {
        Size oldPreview = previewSize;
        long start = System.currentTimeMillis();
        CameraSizeCalculator cameraSizeCalculator = ConfigurationProvider.get().getCameraSizeCalculator();
        android.hardware.Camera.Parameters parameters = camera.getParameters();
        if (mediaType == Media.TYPE_PICTURE && (pictureSize == null || forceCalculateSizes)) {
            pictureSize = cameraSizeCalculator.getPictureSize(pictureSizes, expectAspectRatio, expectSize);
            previewSize = cameraSizeCalculator.getPicturePreviewSize(previewSizes, pictureSize);
            parameters.setPictureSize(pictureSize.width, pictureSize.height);
            notifyPictureSizeUpdated(pictureSize);
        }

        // ... ...
    }
複製程式碼

效能優化

為了對相機的效能進行優化,筆者可是花了大量的精力。因為在之前進行優化的時候積累了一些經驗,所以這次開發的時候就容易得多。下面是 TraceView 進行分析的圖:

Android 相機 TraceView 分析

可以看出從相機當中獲取支援尺寸的本身會佔用一定時間的,而這種屬於相機固有的資訊,一般是不會發生變化的,所以我們可以通過將其快取起來來提升下一次開啟相機的速率。

整體上,該專案的優化主要體現在幾個地方:

  1. 使用註解+常量取代列舉:因為列舉佔用的記憶體空間比較大,而單純使用註解無法約束輸入引數的範圍。這在 enums 包下面可以看到,這也是 Android 效能優化最常見的手段之一。

  2. 延遲初始化:我們為了達到只在使用到某些資料的時候才初始化的目的採用了延遲初始化的解決方案,比如 Size 的寬高比的問題:

public class Size {

    // ...

    private double ratio;

    public double ratio() {
        if (ratio == 0 && width != 0) {
            ratio = (double) height / width;
        }
        return ratio;
    }

}
複製程式碼
  1. 資料結構的應用和選擇:選擇合適的資料結構和自定義資料結構往往能起到化腐朽為神奇的作用。比如 SizeMap
public class SizeMap extends HashMap<AspectRatio, List<Size>> {
}
複製程式碼

比如在列表資料結構的應用上面,使用 ArrayList 但是提前指定陣列大小,減小陣列擴容的次數:

    public static List<Size> fromList(@NonNull List<Camera.Size> cameraSizes) {
        List<Size> sizes = new ArrayList<>(cameraSizes.size());
        for (Camera.Size size : cameraSizes) {
            sizes.add(of(size.width, size.height));
        }
        return sizes;
    }
複製程式碼
  1. 快取,這個我們之前已經提到過,除了尺寸資訊我們還快取了一些其他的資訊,具體可以參考原始碼。

  2. 非同步執行緒:這個當然是最能提升應用相應速度的方式。它能夠讓我們不阻塞主執行緒,從而提升介面相應的速度。但是在相機開發的時候存在一個問題,即通常開啟的相機的時候比較耗時,所以放在非同步執行緒中;而開啟預覽處於主執行緒,這很容易因為執行緒執行的順序的問題導致一些難以預測的異常。在之前,筆者的解決方案是使用一個私有鎖來實現執行緒的控制。

總結

本次相機庫開發佔用的時間其實不多,更多的時間花費在了 UML 建模圖的設計和在真正開發之前收集資料資訊。不得不說,如果你開發一個小的專案,不需要做什麼設計,直接就可以上了,但是如果你設計一個比較複雜的庫,花費更多時間在 UML 建模上面是值得的,因為它能讓你的開發思路更加清晰。另外,為了開發 Camera2,筆者不僅找遍了開源庫,還翻譯了相關的官方文件,這在開源專案中會一併奉上。

相機目前支援的功能

編號 功能
1 拍攝照片
2 拍攝視訊
3 指定使用 Camera1 還是 Camera2
4 指定使用 TextureView 還是 SurfaceView
5 閃光燈開啟和關閉
6 自動對焦的選擇
7 前置和後置相機
8 快門聲
9 指定縮放的大小
10 指定期望的圖片大小
11 指定期望的圖片寬高比
12 獲取支援的圖片、預覽和視訊的尺寸資訊
13 相機尺寸發生變化監聽
14 輸出視訊的檔案位置
15 輸出視訊的時間長度
16 手指介面滑動的監聽
17 觸控進行縮放
18 預覽自適應和裁剪等
19 快取相機資訊,清除和不適用快取資訊

最後是關於專案的一些小問題

該專案目前所有功能已經開發完畢,不過仍有一些小的問題需要完善:

  1. Camera2 預覽放大之後拍攝出的圖片沒有放大效果的問題;
  2. Camera1 拍攝出的圖片需要旋轉 90 度;
  3. Camera2 在螢幕旋轉成橫屏之後相機預覽需要同時選擇 90 度的問題;
  4. Camera1 和 Camera2 切換存在一些問題。

另外,由於時間限制,該相機庫目前沒有進行嚴格的測試,所以建議使用的時候進行充分測試之後再使用。

是否會繼續完善該專案?

是的,包括對相機的功能進行充分測試。只是目前的時間結點,筆者有其他的事務需要處理,所以先把它介紹給讀者。當然也希望能夠有更多感興趣的朋友對該專案貢獻程式碼。

專案地址:

  1. 專案地址:github.com/Shouheng88/…
  2. UML 建模圖地址:www.processon.com/view/link/5…
  3. 筆者翻譯的Camera2 文件:github.com/Shouheng88/…

相關文章