Android-自定义相机Camera

前言

由于最近一个项目需要自定义相机这块,踩了很多坑,在这里做个记录,以防忘记。

Android Camera 相关API可以说是Android 生态碎片化最严重的一块
目前有两套Camera Api 以android 5.0为分界线,5.0以下的是Camera ,5.0以上的是Camera2,然而Camera2 各个产商支持的各不相同,这就导致我们在相机开发中要花很大的精力去处理兼容性问题。

相机开发的流程

自定义相机开发流程大概可以分为5步

  • 1、检测并访问相机资源,检查手机是否存在相机资源,如果存在则请求访问相机资源。
  • 2、创建预览界面,将预览画面与设计好的用户界面控件融合在一起,实时显示相机的预览图像。
  • 3、设置拍照监听,给用户界面控件绑定监听器,使其能响应用户操作, 开始拍照过程。
  • 4、拍照并保存文件,将拍摄获得的图像转换成位图文件,最终输出保存成各种常用格式的图片。
  • 5、释放相机资源,相机是一个共享资源,当相机使用完毕后,必须正确地将其释放,以免其它程序访问使用时发生冲突。

Camera相关API了解

Camera Api中主要涉及以下几个关键类

  • Camera:操作和管理相机资源,支持相机资源切换,设置预览和拍摄尺寸,设置光圈、曝光等相关参数。
  • SurfaceView:用于绘制相机预览图像,提供实时预览的图像。
  • SurfaceHolder:用于控制Surface的一个抽象接口,它可以控制Surface的尺寸、格式与像素等,并可以监视Surface的变化。
  • SurfaceHolder.Callback:用于监听Surface状态变化的接口。

1、Camera

方法 说明
open(int cameraId) 获取Camera实例 cameraId 的值有两个,一个是CameraInfo.CAMERA_FACING_BACK,CameraInfo.CAMERA_FACING_FRONT 前置摄像头和后置摄像头
setPreviewDisplay 绑定绘制预览图像的surface。
setPrameters 设置相机参数,包括前后摄像头,闪光灯模式、聚焦模式、预览和拍照尺寸等
startPreview() 开始预览,将camera底层硬件传来的预览帧数据显示在绑定的surface上
stopPreview() 停止预览,关闭camra底层的帧数据传递以及surface上的绘制。
release() 释放Camera实例
takePicture(ShutterCallback shutter, PictureCallback raw,PictureCallback jpeg) 这个是实现相机拍照的主要方法,包含了三个回调参数。shutter是快门按下时的回调,raw是获取拍照原始数据的回调,jpeg是获取经过压缩成jpg格式的图像数据的回调。

更多API 可以查看这篇博客

2、SurfaceHolder.Callback

接口 说明
surfaceCreated(SurfaceHolder holder) 在surface第一次创建的时候调用。
surfaceChanged(SurfaceHolder holder, int format, int width, int height) 在surface的format或size等发生变化时调用。
surfaceDestroyed(SurfaceHolder holder) 在surface销毁的时候被调用。

Camera2相关API了解

1、Camera2

  • CameraManager:摄像头管理器,用于打开和关闭系统摄像头
  • CameraCharacteristics:描述摄像头的各种特性,我们可以通过CameraManager的getCameraCharacteristics(@NonNull String cameraId)方法来获取。
  • CameraDevice:描述系统摄像头,类似于早期的Camera。
  • CameraCaptureSession:Session类,当需要拍照、预览等功能时,需要先创建该类的实例,然后通过该实例里的方法进行控制(例如:拍照 capture())。
  • CaptureRequest:描述了一次操作请求,拍照、预览等操作都需要先传入
  • CaptureRequest参数,具体的参数控制也是通过CameraRequest的成员变量来设置。
  • CaptureResult:描述拍照完成后的结果

关于SurfaceView/TextureView

SurfaceView是一个有自己Surface的View。界面渲染可以放在单独线程而不是主线程中。它更像是一个Window,自身不能做变形和动画。
TextureView同样也有自己的Surface。但是它只能在拥有硬件加速层层的Window中绘制,它更像是一个普通View,可以做变形和动画。

更多关于SurfaceView与TextureView区别的内容可以参考这篇文章Android 5.0(Lollipop)中的SurfaceTexture,TextureView, SurfaceView和GLSurfaceView.

封装

API大概熟悉了,那么重点来了。那该如何封装呢?其实我们封装相机无非就是需要以下功能

  • 打开相机
  • 开启预览
  • 拍照
    • 开关闪关灯
    • 聚焦
    • 等等
  • 关闭预览
  • 关闭相机

那么如何封装呢,官方的开源库cameraview给出了方案。

图片

查看源码,大概的就是这几个类。

在这里插入图片描述

类不多,大致使用的设计模式是抽象工厂模式。这里也只是简单的拍照功能。
如果你不是重度的去定制相机的话,大概也只会用到预览界面,保存图片,以及闪光灯,聚焦这几个,这几个中最容易出现适配问题的就是预览界面失真以及保存图片的方向问题。保存图片方向的问题其实也可以在最后输出结果时进行转向,这样的话又要多一步操作,能够一步到位的事情,坚决不多一步。

那么接下来根据分析一下预览界面失真问题。

public class CameraView extends FrameLayout {

    public CameraView(Context context) {
        this(context, null);
    }

    public CameraView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    @SuppressWarnings("WrongConstant")
    public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
        ....
        依据不同版本实例化不同的camera
           // Internal setup
        final PreviewImpl preview = createPreviewImpl(context);
        mCallbacks = new CallbackBridge();
        if (Build.VERSION.SDK_INT < 21) {
            mImpl = new Camera1(mCallbacks, preview);
        } else if (Build.VERSION.SDK_INT < 23) {
            mImpl = new Camera2(mCallbacks, preview, context);
        } else {
            mImpl = new Camera2Api23(mCallbacks, preview, context);
        }
        .....
        
    }
    
    //获取相应的预览界面
    @NonNull
    private PreviewImpl createPreviewImpl(Context context) {
        PreviewImpl preview;
        if (Build.VERSION.SDK_INT >= 23) {
            preview = new SurfaceViewPreview(context, this);
        } else {
            preview = new TextureViewPreview(context, this);
        }
        return preview;
    }
    
    //根据设计稿设计的预览界面尺寸去获取相应的
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (isInEditMode()) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            return;
        }
        // Handle android:adjustViewBounds
        //需要调整预览界面。这个设计和UI设计的预览会有点不同,可能过高或者过矮。如果底部按钮会相应的适配可采用此方式
        if (mAdjustViewBounds) {
            if (!isCameraOpened()) {
                mCallbacks.reserveRequestLayoutOnOpen();
                super.onMeasure(widthMeasureSpec, heightMeasureSpec);
                return;
            }
            final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            if (widthMode == MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) {
                final AspectRatio ratio = getAspectRatio();
                assert ratio != null;
                int height = (int) (MeasureSpec.getSize(widthMeasureSpec) * ratio.toFloat());
                if (heightMode == MeasureSpec.AT_MOST) {
                    height = Math.min(height, MeasureSpec.getSize(heightMeasureSpec));
                }
                super.onMeasure(widthMeasureSpec,
                        MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
            } else if (widthMode != MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
                final AspectRatio ratio = getAspectRatio();
                assert ratio != null;
                int width = (int) (MeasureSpec.getSize(heightMeasureSpec) * ratio.toFloat());
                if (widthMode == MeasureSpec.AT_MOST) {
                    width = Math.min(width, MeasureSpec.getSize(widthMeasureSpec));
                }
                super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
                        heightMeasureSpec);
            } else {
                super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            }
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
        // Measure the TextureView
        int width = getMeasuredWidth();
        int height = getMeasuredHeight();
        //获取当前设置的比例,当前设置的比例也是通过预览尺寸计算出来的
        AspectRatio ratio = getAspectRatio();
        if (mDisplayOrientationDetector.getLastKnownDisplayOrientation() % 180 == 0) {
            ratio = ratio.inverse();
        }
        assert ratio != null;
        //根据当前的比例计算预览View相应的宽高。同比例放大或缩小。保证preview不会出现图形压扁或者拉伸的情况
        if (height < width * ratio.getY() / ratio.getX()) {
            mImpl.getView().measure(
                    MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
                    MeasureSpec.makeMeasureSpec(width * ratio.getY() / ratio.getX(),
                            MeasureSpec.EXACTLY));
        } else {
            mImpl.getView().measure(
                    MeasureSpec.makeMeasureSpec(height * ratio.getX() / ratio.getY(),
                            MeasureSpec.EXACTLY),
                    MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
        }
    }
    
}


class Camera1 extends CameraViewImpl {

    .....
    
    void adjustCameraParameters() {
        SortedSet<Size> sizes = mPreviewSizes.sizes(mAspectRatio);
        if (sizes == null) { // Not supported
            mAspectRatio = chooseAspectRatio();
            sizes = mPreviewSizes.sizes(mAspectRatio);
        }
        Size size = chooseOptimalSize(sizes);

        // Always re-apply camera parameters
        // Largest picture size in this ratio
        final Size pictureSize = mPictureSizes.sizes(mAspectRatio).last();
        if (mShowingPreview) {
            mCamera.stopPreview();
        }
        mCameraParameters.setPreviewSize(size.getWidth(), size.getHeight());
        mCameraParameters.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight());
        mCameraParameters.setRotation(calcCameraRotation(mDisplayOrientation));
        setAutoFocusInternal(mAutoFocus);
        setFlashInternal(mFlash);
        mCamera.setParameters(mCameraParameters);
        if (mShowingPreview) {
            mCamera.startPreview();
        }
    }
    
    //获取最佳预览尺寸。
    @SuppressWarnings("SuspiciousNameCombination")
    private Size chooseOptimalSize(SortedSet<Size> sizes) {
        //如果预览界面的尺寸是0,0 那么就随便取一个尺寸。
        if (!mPreview.isReady()) { // Not yet laid out
            return sizes.first(); // Return the smallest size
        }
        //等到View的绘制完成(onMeasure())后拿到预览界面的view宽高去获取相近的预览宽高。
        int desiredWidth;
        int desiredHeight;
        final int surfaceWidth = mPreview.getWidth();
        final int surfaceHeight = mPreview.getHeight();
        if (isLandscape(mDisplayOrientation)) {
            desiredWidth = surfaceHeight;
            desiredHeight = surfaceWidth;
        } else {
            desiredWidth = surfaceWidth;
            desiredHeight = surfaceHeight;
        }
        Size result = null;
        for (Size size : sizes) { // Iterate from small to large
            if (desiredWidth <= size.getWidth() && desiredHeight <= size.getHeight()) {
                return size;

            }
            result = size;
        }
        return result;
    }
    .....
}

上面大概分析的是根据当前设置的比例去计算camera预览界面宽高,然后根据预览界面的宽高去获取相近的预览尺寸,这样可以保证预览时显示的图像不会出现失真。

网上比较多的方案是以下这个方法,这个方式其实是这个根据这个项目open camera进行修改的。是根据surfaceview的宽高比去获取相camera对应相近的预览尺寸,但是这个有一个缺点,就是如果 SurfaceView的宽高比和camera对应预览尺寸的宽高比不一致,有一点点的误差,就会出现一点点失真,而官方的开源库cameraview 宽高比是根据预览尺寸计算出来的,因此官方开源那种方式百分百不会出现失真,当然如果这个SurfaceView的宽高比和相机提供的预览尺寸中的宽高比一致的话,那么也不会失真。因此下面这个方式宽高要设置好。

    /**
     * 获取最佳预览大小
     *
     * @param sizes 所有支持的预览大小
     * @param w     SurfaceView宽
     * @param h     SurfaceView高
     */
    private Camera.Size getOptimalPreviewSize(List<Camera.Size> sizes, int w, int h) {
        final double ASPECT_TOLERANCE = 0.1;
        double targetRatio = (double) w / h;
        if (sizes == null)
            return null;
    
        Camera.Size optimalSize = null;
        double minDiff = Double.MAX_VALUE;
    
        int targetHeight = h;
    
        // Try to find an size match aspect ratio and size
        for (Camera.Size size : sizes) {
            double ratio = (double) size.width / size.height;
            if (Math.abs(ratio - targetRatio) > ASPECT_TOLERANCE)
                continue;
            if (Math.abs(size.height - targetHeight) < minDiff) {
                optimalSize = size;
                minDiff = Math.abs(size.height - targetHeight);
            }
        }
    
        // Cannot find the one match the aspect ratio, ignore the requirement
        if (optimalSize == null) {
            minDiff = Double.MAX_VALUE;
            for (Camera.Size size : sizes) {
                if (Math.abs(size.height - targetHeight) < minDiff) {
                    optimalSize = size;
                    minDiff = Math.abs(size.height - targetHeight);
                }
            }
        }
        return optimalSize;
    }

照片保存方向的问题相对简单一点,只需要给camera设置
setDisplayOrientation()就可以了。以下两种方式均可以。

这个是官方的开源库cameraview 提供的方式

    private int calcDisplayOrientation(int screenOrientationDegrees) {
        if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
            return (360 - (mCameraInfo.orientation + screenOrientationDegrees) % 360) % 360;
        } else {  // back-facing
            return (mCameraInfo.orientation - screenOrientationDegrees + 360) % 360;
        }
    }

这个是根据open camera进行相应修改后的方式。


    /**
     * 照片方向
     */
    public static void onOrientationChanged(Activity activity, Camera.Parameters parameters, int cameraId) {
        Camera.CameraInfo info = new Camera.CameraInfo();
        Camera.getCameraInfo(cameraId, info);
        int camera_orientation = info.orientation;
        int result;
        int device_orientation = getDeviceDefaultOrientation(activity);
        if (device_orientation == Configuration.ORIENTATION_PORTRAIT) {
            // should be equivalent to onOrientationChanged(0)
            result = camera_orientation;
        } else {
            // should be equivalent to onOrientationChanged(90)
            if ((info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT)) {
                result = (camera_orientation + 270) % 360;
            } else {
                result = (camera_orientation + 90) % 360;
            }
        }
        parameters.setRotation(result);
    }

关于Camera2 这个由于项目时间问题,目前没有采用Camera2 相应的API,官方开源库这块也是有点小问题的,在修改对应的预览比例也会出现失真,除了4:3的情况,因此本文没有去分析camera2相应的源码。如果想要使用Camera2新特性的功能,那么建议可以去研究一下Jetpack 新出的CameraX
具体内容请查看官方文档代码示例

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