CameraX:Android 相機庫開發實踐

前言

前段時間因爲工作的需要對項目中的相機模塊進行了優化,我們項目中的相機模塊是基於開源庫 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 代碼進行閱讀。在這裏,我們只對項目中的一些關鍵部分的設計思路進行說明。

相機整體架構

鏈接:https://www.processon.com/view/link/5c976af8e4b0d1a5b10a4049

以上是我們相機庫的整體架構的設計圖,這裏筆者使用了 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. 項目地址:https://github.com/Shouheng88/CameraX
  2. UML 建模圖地址:https://www.processon.com/view/link/5c976af8e4b0d1a5b10a4049
  3. 筆者翻譯的Camera2 文檔:https://github.com/Shouheng88/Android-notes/blob/master/性能優化/Android相機Camera2資料.md
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章