一个高性能、功能丰富、可自定义的 Android 相机库 iCamera 的设计和开发过程

1、背景介绍

去年年初的时候写过一篇文章 《CameraX:Android 相机库开发实践》,那时我想自己写一个 Android 相机库,但是因为名字和谷歌关放的 CameraX 冲突了,所以现在我将自己的项目改名为 iCamera.

之前的文章中也交代过一些 Android 相机库的背景,本身集成相机功能到自己的项目中并不复杂,但是如果设计一个功能全面的 Android 相机库就没那么简单了——你要满足更多用户的需求,基本的缩放、闪光灯等这些在日常开发中不会涉及的功能都要支持;此外,你还要处理相机的各种支持尺寸和宽高比的计算问题,满足用户自定义的需求等等。

在 iCamera 之前,为了集成相机功能,我也找过一些开源的相机库。比如 CameraView,虽然是挂名谷歌,但是并不算谷歌官方的库,而且因为代码设计的问题,本身性能并不好。再者 CameraFragment,虽然代码结构清晰得多,但是对很多功能的支持不够完善。还有 CameraX,这个库我没有仔细研究它的代码,但是它只支持 Camera2. Camera2 虽然 API 21 上就可以使用,但实际上很多手机对 Camera2 的支持并不好,就比如在我的手机(OnePlus6, API 29)上 Camera1 的启动速率明显高于 Camera2.

综上,我决定自己写一个性能更好、功能全面并且支持用户自定义的相机库。这个项目去年就开始写了,但是因为工作的问题一直没时间完善。最近有了些自己的时间,于是我解决了之前遗留下来的各种问题并做了系统的测试。现在,第一个版本已经正式发布可用了。

项目地址:iCamera

在这篇文章中,我重点介绍下是如何设计和实现这样一款 Android 相机库的,希望这能够对你的系统设计有所启发,并且希望这能够增进你对 iCamera 的了解。

2、整体的设计与实现

下面是这个项目整体的设计图,相比于第一个版本的设计,在下面的这个版本中我又新增了一些方法并对部分设计细节做了调整:

design.png

链接地址:https://www.processon.com/view/link/5c976af8e4b0d1a5b10a4049

了解了整体的设计,我再来具体介绍下我是如何通过多种设计模式的综合运用来满足用户的自定义等需求的。

2.1 单例的应用:缓存、自定义和预加载

最初我在设计的时候希望通过静态字段缓存一些相机的信息,这样一来可以避免多次从相机属性中读取和计算各种参数,二来可以通过预加载操作来把读取相机参数的操作提前到相机启动之前来达到加快相机启动速度的目的。不过后来我发现使用静态单例一样可以满足这个需求并且应该说更加合理一些。于是,我在项目中使用了单例的 ConfigurationProvider 来缓存计算结果并且提供方法提前读取相机信息。该类如下:

public final class ConfigurationProvider {
    /** The singleton */
    private static volatile ConfigurationProvider configurationProvider;

    /**
     * The sizes map from a int value, which was calculated from:
     * hash = {@link CameraFace} | {@link CameraSizeFor} | {@link CameraType} */
    private SparseArray<List<Size>> sizeMap;

    /**
     * The room ratios map from a int value, which was calculated from:
     * hash = {@link CameraFace} | {@link CameraType} */
    private SparseArray<List<Float>> ratioMap;

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

    private void initWithDefaultValues() {
        // initialize all kinds of parameters
    }

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

对于 Camera2,因为本身它不需要打开相机就能从系统服务中读取相机信息,所以我们可以方便地实现预加载的需求。按照下面这样,我们只需要在打开相机之前调用 prepareCamera2() 就可以提前将相机的信息读取并缓存起来,这样就可以减少相机启动过程中的时间。我测试了下,这样大概可以减少几十毫秒的时间:

public final class ConfigurationProvider {

    // ....

    private int numberOfCameras;
    private AtomicBoolean camera2Prepared = new AtomicBoolean();
    private SparseArray<String> cameraIdCamera2 = new SparseArray<>();
    private SparseArray<CameraCharacteristics> cameraCharacteristics = new SparseArray<>();
    private SparseIntArray cameraOrientations = new SparseIntArray();
    private SparseArray<StreamConfigurationMap> streamConfigurationMaps = new SparseArray<>();

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public ConfigurationProvider prepareCamera2(Context context) {
        if (!camera2Prepared.get()) {
            CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
            try {
                assert cameraManager != null;
                final String[] ids = cameraManager.getCameraIdList();
                numberOfCameras = ids.length;
                for (String id : ids) {
                    final CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id);
                    final Integer facing = characteristics.get(CameraCharacteristics.LENS_FACING);
                    if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
                        cameraIdCamera2.put(CameraFace.FACE_FRONT, id);
                        Integer iFrontCameraOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
                        cameraOrientations.put(CameraFace.FACE_FRONT, iFrontCameraOrientation == null ? 0 : iFrontCameraOrientation);
                        cameraCharacteristics.put(CameraFace.FACE_FRONT, characteristics);
                        streamConfigurationMaps.put(CameraFace.FACE_FRONT, characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP));
                    } else if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK){
                        cameraIdCamera2.put(CameraFace.FACE_REAR, id);
                        Integer iRearCameraOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
                        cameraOrientations.put(CameraFace.FACE_REAR, iRearCameraOrientation == null ? 0 : iRearCameraOrientation);
                        cameraCharacteristics.put(CameraFace.FACE_REAR, characteristics);
                        streamConfigurationMaps.put(CameraFace.FACE_REAR, characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP));
                    }
                }
                camera2Prepared.set(true);
            } catch (Exception e) {
                XLog.e(TAG, "initCameraInfo error " + e);
            }
        }
        return this;
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public StreamConfigurationMap getStreamConfigurationMap(Context context, @CameraFace int cameraFace) {
        return prepareCamera2(context).streamConfigurationMaps.get(cameraFace);
    }

    // ...
}

此外,后面会提到我们满足用户自定义需求的设计逻辑——策略接口,所以,我们又可以通过 ConfigurationProvider 暴露的方法设置全局的计算策略,比如具体使用 Camera1 还是 Camera2,使用 TextureView 还是 SurfaceView,各种宽高比、支持的尺寸如何进行计算等:

public final class ConfigurationProvider {

    /** The creator for {@link me.shouheng.icamera.manager.CameraManager}. */
    private CameraManagerCreator cameraManagerCreator;

    /** The creator for {@link me.shouheng.icamera.preview.CameraPreview}. */
    private CameraPreviewCreator cameraPreviewCreator;

    /** The calculator for camera size. */
    private CameraSizeCalculator cameraSizeCalculator;

    // ... setters & getters
}

用户只需要实现这些接口并通过 ConfigurationProvider 暴露的方法在相机启动之前将其赋值给这个单例就可以实现相机默认算法的替换。

2.2 策略模式应用:满足用户自定义需求

之前我们也说过自己开发一个相机库一个难点就是处理各种尺寸的计算问题。因为,一个相机支持的宽高比从 2:1 到 1:1 不等,每个比例下有多个支持的尺寸。另外,相机的尺寸又分为预览的支持尺寸、照片的支持尺寸和视频的支持尺寸。所以,要处理的数据比较多,问题是你如何整合这些需求。另外还有 Camera1 还是 Camera2,使用 TextureView 还是 SurfaceView 的问题。对于一般的相机开发,可能默认就是 4:3 了,所以我觉得这才是开发一个库和一个应用相比复杂的地方。

这里我的设计是使用策略接口提供接口给用户进行算法自定义。

1. CameraManager:相机的实现逻辑

对于 Camera1 和 Camera2 的逻辑,我们提供了 CameraManager 这个接口以及两者分别的实现 Camera1Manager 和 Camera2Manager,而两者的实现又共同继承自一个父类 BaseCameraManager. 因为我们的库整体上还是将 CameraView 以一个控件的形式交付给用户,所以 CameraView 中引用了 CameraManager. 可以说 CameraView 的每个方法都是直接或者间接调用了 CameraManager 的方法,因此 CameraManager 的方法非常多,我想一般人应该不会想自己实现一个 CameraManager 接口。所以,这个接口我不多介绍了。

2. CameraPreview:相机展示控件的逻辑

除了 Camera1 和 Camera2 的选择,相机预览控件也要在 TextureView 和 SurfaceView 之间进行选择。跟 CameraManager 一样,我们提供了 CameraPreview 接口来满足用户的自定义需求。

在 CameraManager 和 CameraPreview 之上我们又定义了两个工厂接口:CameraManagerCreator 和 CameraPreviewCreator,用户可以通过实现这两个接口并将其设置到 ConfigurationProvider 来实现定义自己的策略。

public interface CameraManagerCreator {

    /**
     * Method used to create {@link CameraManager}.
     *
     * @param context the context
     * @param cameraPreview the {@link CameraPreview}
     * @return CameraManager object.
     */
    CameraManager create(Context context, CameraPreview cameraPreview);
}

public interface CameraPreviewCreator {

    /**
     * Method used to create {@link CameraPreview}.
     *
     * @param context the context to create the preview view.
     * @param parent the parent view of the preview.
     * @return CameraPreview object.
     */
    CameraPreview create(Context context, ViewGroup parent);
}

比如,我们默认提供的 CameraManagerCreator 的实现是:只要系统 API≥21 并且支持 Camera2,我们就使用 Camera2Manager 作为自己的相机逻辑实现。如果你测试发现这无法满足自己的需求,可以使用 ConfigurationProvider 的方法,将 Camera1OnlyCreator 作为自己的策略,这样无论什么场景都使用 Camera1. 当然,你也可以添加自定义的策略,只要实现自己的接口即可并塞到 ConfigurationProvider 中即可。

3. CameraSizeCalculator:综合处理各种尺寸和宽高比的问题

与 CameraManager 类似,CameraSizeCalculator 是提供给用户来自定义各种尺寸计算规则的接口。这个接口是我最近改动比较大的接口,经过调整之后接口内的各个方法简单清晰得多了:

public interface CameraSizeCalculator {

    void init(@NonNull AspectRatio expectAspectRatio,
              @Nullable Size expectSize,
              @MediaQuality int mediaQuality,
              @NonNull List<Size> previewSizes,
              @NonNull List<Size> pictureSizes,
              @NonNull List<Size> videoSizes);

    void changeExpectAspectRatio(@NonNull AspectRatio expectAspectRatio);
    void changeExpectSize(@Nullable Size expectSize);
    void changeMediaQuality(@MediaQuality int mediaQuality);

    Size getPictureSize(@CameraType int cameraType);
    Size getPicturePreviewSize(@CameraType int cameraType);
    Size getVideoSize(@CameraType int cameraType);
    Size getVideoPreviewSize(@CameraType int cameraType);
}

用户可以通过这里的方法含义如下:

  • 我们通过 init() 方法将当前系统相机的各种尺寸信息告知你
  • 当用户改变了相机的参数的时候,我们通过这里三个以 change 开头的方法进行通知
  • 这里的四个以 get 开头的方法是需要你实现计算规则并返回计算结果的方法

这里我们要处理的参数包括:

  • 系统支持的所有预览尺寸列表:previewSizes
  • 系统支持的所有照片尺寸列表:pictureSizes
  • 系统支持的所有视频尺寸列表:videoSizes
  • 用户期望得到的视频或者照片的尺寸:expectSize
  • 用户期望得到的视频或者照片的宽高比:expectAspectRatio
  • 用户期望得到的视频或者照片的质量:mediaQuality

该接口的一个默认实现在 CameraSizeCalculatorImpl 中。在新版本中,我做了三处调整:

  • 增加了尺寸的缓存信息,用户期望的尺寸、宽高比和输出质量不变的时候只需要计算一次,以后可以复用,来实现相机启动速率的提升

  • 实现了缓存的尺寸的隔离,之前我没有将缓存的尺寸信息隔离开,导致在拍摄视频和照片之间切换的时候出现画面扭曲的问题,现在解决了这个问题

  • 增加了新的计算算法,目前该库中包含两种尺寸计算算法:

    • 第一种适用于输出的图片或者视频的尺寸,我们会整合期望的输出的尺寸、期望的输出的宽高比和期望的输出的图片质量来选择一个最符合要求的输出尺寸:首先寻找最接近期望的宽高比,然后寻找最接近的尺寸,如果没有指定期望的尺寸会根据期望的输出的质量将所有支持尺寸划分为不同的品质之后选择符合要求的尺寸。

    • 第二种算法适用于预览的尺寸。相比于输出的图片和视频的尺寸,预览的尺寸可能没那么重要。我们只需要寻找一个符合接近于输出的图片或者视频的尺寸的尺寸即可。因此,这里的算法是,首先匹配宽高比,其次匹配尺寸。

具体实现可以参考源码,这里就不再一一说明了。

3、细节的优化与设计

3.1 枚举在 iCamera 中的应用

以相机的尺寸为例,它分为预览的尺寸、输出视频的尺寸和输出照片的尺寸,当我们要对外暴露一个获取尺寸的方法的时候,按照一般的思路势必要提供三个方法。但是,我们可以通过整数+注解来取代枚举,从而实现将三个方法合并未一个的目标。比如,

    public Size getSize(@CameraSizeFor int sizeFor) {
        return cameraManager.getSize(sizeFor);
    }

    public SizeMap getSizes(@CameraSizeFor int sizeFor) {
        return cameraManager.getSizes(sizeFor);
    }

此外,我们在应用中还定义了其他的枚举,比如表示前置和后置相机的 CameraFace,表示 Camera1 还是 Camera2 的 CameraType 等,所以,在某些场合我们只需要进行按位取或就可以实现 hash 的键的区分,这样设计使我们缓存隔离的时候的逻辑更加简洁明了:

    public List<Size> getSizes(android.hardware.Camera camera,
                               @CameraFace int cameraFace,
                               @CameraSizeFor int sizeFor) {
        // calculate hash of map
        int hash = cameraFace | sizeFor | CameraType.TYPE_CAMERA1;
        XLog.d(TAG, "getSizes hash : " + Integer.toHexString(hash));
        // try to get sizes from cache first.
        if (useCacheValues) {
            List<Size> sizes = sizeMap.get(hash);
            if (sizes != null) {
                return sizes;
            }
        }
        // get sizes from parameters
        android.hardware.Camera.Parameters parameters = camera.getParameters();
        List<Size> sizes;
        switch (sizeFor) {
            case CameraSizeFor.SIZE_FOR_PICTURE:
                sizes = Size.fromList(parameters.getSupportedPictureSizes());
                break;
            case CameraSizeFor.SIZE_FOR_PREVIEW:
                sizes = Size.fromList(parameters.getSupportedPreviewSizes());
                break;
            case CameraSizeFor.SIZE_FOR_VIDEO:
                sizes = Size.fromList(parameters.getSupportedVideoSizes());
                break;
            default:
                throw new IllegalArgumentException("Unsupported size for " + sizeFor);
        }
        // cache the sizes in memory
        if (useCacheValues) {
            sizeMap.put(hash, sizes);
        }
        return sizes;
    }

3.2 应用内部的耗时分析

在进行相机开发的时候我们在应用内部进行了许多的耗时分析,比如之前用 TraceView 进行方法调用的分析,以及使用各种 Log 输出日志的方式来统计应用耗时的逻辑。上也提到过我们使用预加载和缓存等来实现系统的启动速率的提升等。

Android 相机 TraceView 分析

4、总结

在相机库开发过程中还遇到一些其他的问题,比如横竖屏切换和快门声音处理等,这些都已经反应到了 iCamera 的源码中。本来也想写一下如何使用 Android 提供的 API 实现相机开发的,但是我觉得最好的教程就是源码。既然项目已经开源,直接读源码好了,并且我自认为这个项目的代码还是我比较满意的。本来嘛,我们把别人的东西分析得头头是道但是却做不成自己的东西,那分析了这么多又有什么用呢?其实比学习能力更高级的应该是创造力。有人问我说,你写这些开源项目和博客的目的是什么,我只能说,这是一种表达,而生命本身就是一种表达。我们只不过是通过做这些事情来告诉这个世界,什么是对的,什么是美好的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343