Android Camera Develop: orientation/rotation and aspect ratio

概述

接上篇,在实现了相机的基础功能后,着眼于解决预览、拍照与录像时屏幕的旋转,以及预览时的纵横比等问题。上篇实现的相机APP只能以横屏方向预览、拍照和录像,在实际使用时会很不方便;本篇就会实现APP随设备的旋转而旋转,且可以在任意旋转角度上进行预览、拍照和录像。上篇实现的相机APP中,预览画面是充满整个屏幕的,即使调整预览分辨率后,预览画面所占尺寸也不变,这样会造成实际画面被拉长或压扁,十分不友好;本篇会实现预览画面纵横比与分辨率纵横比保持相同,即预览画面不会出现拉长或压扁的情况。

预览画面随设备旋转

如果只是简单地将AndroidManifest中横屏锁去掉,虽然可以实现APP随设备旋转,但预览画面不会随之旋转,如下图所示:

Screenshot Preview Landscape

Screenshot Preview Portrait Old

原因主要在于Camera的预览显示方向需要单独设置,不会随着设备的旋转而自动改变。而通过设置Camera的预览显示方向,可以自由指定旋转的角度。

解除旋转锁定

AndroidManifest.xml中删去

android:screenOrientation="landscape"

这样就解除了之前设定的APP的旋转锁定(注意与设备的旋转锁定不同),现在APP可以随设备旋转而旋转了。

计算旋转角度

相机预览的旋转角度需要根据相机预览目前的旋转角度,以及设备屏幕的旋转角度计算得到,不过还好Android官方给了示例代码。

在CameraPreview中加入

public int getDisplayOrientation() {
    Display display = ((WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
    int rotation = display.getRotation();
    int degrees = 0;
    switch (rotation) {
        case Surface.ROTATION_0:
            degrees = 0;
            break;
        case Surface.ROTATION_90:
            degrees = 90;
            break;
        case Surface.ROTATION_180:
            degrees = 180;
            break;
        case Surface.ROTATION_270:
            degrees = 270;
            break;
    }

    android.hardware.Camera.CameraInfo camInfo =
            new android.hardware.Camera.CameraInfo();
    android.hardware.Camera.getCameraInfo(Camera.CameraInfo.CAMERA_FACING_BACK, camInfo);

    int result = (camInfo.orientation - degrees + 360) % 360;
    return result;
}

getDisplayOrientation()用来获取相机预览需要旋转的角度。前面一部分获得设备屏幕的旋转角度,即由重力传感器自动旋转屏幕的角度;然后得到相机原先的旋转角度camInfo.orientation,最后通过运算得到新的相机预览需要旋转的角度。

预览画面随设备旋转

设备可能经常在旋转,那什么时候将计算得到的旋转角度应用到相机呢?当然是要等待相应事件触发了。还记得之前一直没有派上用场的surfaceChanged()吗?现在就要用到它了,surfaceChanged()会在surface的大小或格式发生改变时调用,而屏幕的旋转恰恰会使得surface大小发生变化(横屏变为竖屏、竖屏变为横屏,surface的长宽都会发生变化),所以调整相机预览旋转角度就在这里了。

surfaceChanged()中加入

int rotation = getDisplayOrientation();
mCamera.setDisplayOrientation(rotation);

surfaceChanged()触发后,计算相机预览需要旋转的角度rotation,再通过setDisplayOrientation()将此角度应用到Camera

运行试试

现在运行APP,试试旋转设备,相机预览也随之旋转了,不会再出现之前的怪异画面了。

Screenshot Preview Portrait New

优化UI布局

有没有发现上图中在竖屏下控制按钮在屏幕右边很别扭?这是因为竖屏下APP仍然应用的横屏下的布局,本意是为了让布局的相对位置不随旋转而改动,但在这里明显不满足我们的需求了。我们考虑为APP配置横屏和竖屏两个布局,让Android根据屏幕方向自动选择。

新建横屏布局文件

双击打开activity_main.xml,点击面板左下角切换到Design界面,在如下图所示处点击Create Landscape Variation

CreateLandscapeLayout

这样在项目文件列表中,activity_main.xml就会变成一个文件夹,其内含有两个activity_main.xml文件,其中有(land)的是横屏布局文件,另一个是竖屏布局文件。

加入竖屏布局

新建的横屏布局文件自动填充了之前的布局内容,所以横屏布局就不用修改了。

竖屏布局文件内容修改为:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <FrameLayout
        android:id="@+id/camera_preview"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_weight="1" />

    <RelativeLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/button_settings"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:text="设置" />

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerHorizontal="true"
            android:orientation="horizontal">

            <Button
                android:id="@+id/button_capture_photo"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="拍照" />

            <Button
                android:id="@+id/button_capture_video"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="录像" />
        </LinearLayout>

        <ImageView
            android:id="@+id/media_preview"
            android:layout_width="60dp"
            android:layout_height="60dp"
            android:layout_alignParentLeft="true"
            android:layout_centerVertical="true"
            android:background="#000"
            android:visibility="visible" />
    </RelativeLayout>
</LinearLayout>

按照标准,不应该在android:text中直接写入文字,而应当在res/values/strings.xml中写入,在layout中引用。本APP实际也是这样写的,但此处为演示方便,还是直接在layout中写入

运行试试

运行APP,此时在横屏下还是之前的布局,但在竖屏下已经应用新的布局了,如下图所示

Screenshot Preview Portrait New Layout

拍照与录像随预览旋转

现在预览和界面没问题了,但如果你拍张照,或者录像的话会发现照片或视频的方向没有随着预览方向而改变,也就是说不是“所见即所得”,现在来着手解决这个问题。

这个问题不是bug,而是Android故意的,我们也是需要手动指定拍照和录像的旋转角度。

修改拍照旋转角度

照片的旋转角度是在CameraParameters中指定的。这里有一个很困惑的地方,我们之前设置的setDisplayOrientation()是直接对Camera操作的,指定预览的旋转角度;而在Parameters中,有个方法setRotation()同样也是设置旋转角度,这两个有什么区别呢?这里我们不作深入追究,可以理解为setRotation()是设置预览帧数据,以及拍摄照片的方向,而setDisplayOrientation()仅设置预览显示的方向。

所以现在要做的就是在修改预览显示旋转角度时,同时设置拍照旋转角度。在surfaceChanged()中加入

Camera.Parameters parameters = mCamera.getParameters();
parameters.setRotation(rotation);
mCamera.setParameters(parameters);

即首先获取Parameters,设置旋转角度,再将Parameters应用到Camera

这样拍照得到的照片就和预览的方向一致了。

修改录像旋转角度

因为录像是交给MediaRecorder实际实现的,所以应当是给MediaRecorder设置旋转参数。

prepareVideoRecorder()中,mMediaRecorder.prepare()之前加入

int rotation = getDisplayOrientation();
mMediaRecorder.setOrientationHint(rotation);

首先得到旋转角度,然后通过setOrientationHint()应用到MediaRecorder,只需要注意在prepare()前应用就好了。

这样录像得到的视频就和预览的方向一致了。

注意正如setOrientationHint()中的Hint所表示的,视频的旋转并不是编码层面的旋转,视频帧数据并没有发生旋转,而只是在视频中增加了参数,希望播放器按照指定的旋转角度旋转后播放,所以具体效果因播放器而异

实时调整预览纵横比

Aspect Ratio

如上图所示,黑色矩形框为屏幕,灰色矩形框为SurfaceView的父级FrameLayout。我们的APP现在是SurfaceView充满整个FrameLayout即灰色矩形框,而这样会造成实际画面被拉长或压扁。解决方法是将SurfaceView的纵横比与预览分辨率的纵横比调整为相同,这样从屏幕上看到拍摄到的物体与实际情况就只是产生了缩放,而不会出现变形。为了达到最好的显示效果,我们希望SurfaceView能尽可能充满FrameLayout,如图中红色和绿色矩形框所示。通过观察可以很快发现,如果预览分辨率的纵横比大于FrameLayout的纵横比,则将SurfaceView的纵向长度设定为FrameLayout的纵向长度,而SurfaceView的横向长度由纵横比计算得到,如图中绿色矩形框所示;反之如红色矩形框所示。

计算尺寸

CameraPreview中加入

private void adjustDisplayRatio(int rotation) {
    ViewGroup parent = ((ViewGroup) getParent());
    Rect rect = new Rect();
    parent.getLocalVisibleRect(rect);
    int width = rect.width();
    int height = rect.height();
    Camera.Size previewSize = mCamera.getParameters().getPreviewSize();
    int previewWidth;
    int previewHeight;
    if (rotation == 90 || rotation == 270) {
        previewWidth = previewSize.height;
        previewHeight = previewSize.width;
    } else {
        previewWidth = previewSize.width;
        previewHeight = previewSize.height;
    }

    if (width * previewHeight > height * previewWidth) {
        final int scaledChildWidth = previewWidth * height / previewHeight;

        layout((width - scaledChildWidth) / 2, 0,
                (width + scaledChildWidth) / 2, height);
    } else {
        final int scaledChildHeight = previewHeight * width / previewWidth;
        layout(0, (height - scaledChildHeight) / 2,
                width, (height + scaledChildHeight) / 2);
    }
}

首先得到SurfaceView的父级parent(在这里就是FrameLayout),记录父级的长和宽;然后得到预览分辨率(需要注意处理横屏和竖屏的问题),通过比较两者的纵横比,确定SurfaceView的调整方法,计算出需要调整的长度,并使SurfaceView居中;最后通过layout()将新的SurfaceView的位置应用到布局中,完成纵横比的调整。

应用新的尺寸

预览分辨率的变化也会触发surfaceChanged(),所以调用adjustDisplayRatio()也是在surfaceChanged()中。在surfaceChanged()最后加入

adjustDisplayRatio(rotation);

运行试试

运行APP,马上就能发现相机预览不再是充满整个屏幕了,而是在边界有一定的空白,这样就保持了与预览分辨率相同的纵横比。另外,如果在预览的设置中修改了预览分辨率,新的纵横比也会立即应用到屏幕显示中。如下所示

Screenshot Aspect Ratio

美化

以上完成了本篇需要实现的全部功能。这里提一点APP的美化,这里只是指出进行美化的地方,具体细节参见DEMO。

布局

首先可以将整个布局背景设置为黑色,使布局空白地方为黑色,像电影一样。在activity_main中,LinearLayout中加入

android:background="@color/black"

其次将控制部分背景颜色区分开,同时将控制部分预留一部分边界,使布局结构明显。在activity_main中,RelativeLayout中加入

android:background="@color/darkGray"
android:padding="5dp"

偏好设置文本颜色

偏好设置文本颜色默认为黑色,对于相机预览来说不容易分辨,将文本颜色设置为白色更好。这里我们给SettingsFragment设置一个主题。

res/valuse/styles.xml中,resources下加入

<style name="PreferenceTheme">
    <item name="android:textColor">#FFF</item>
    <item name="android:textColorSecondary">#FFF</item>
</style>

创建一个名为PreferenceTheme的主题,主题只是修改文本颜色为纯白。

SettingsFragmentonCreate()中,addPreferencesFromResource()之后加入

getActivity().setTheme(R.style.PreferenceTheme);

即应用此主题。

这样偏好设置文本颜色就变为白色了,容易区分多了吧!

一点唠叨

本篇完成后,这个相机APP就离实用的APP更近了一步,甚至已经满足了大多数的需求。其实在旋转与纵横比的实现上,需要仔细研究诸如继承关系、生命周期等的问题,但本文没有谈及这些内容,还望想要了解其中细节的读者自己去研究。在纵横比的实现中,我采用的是在子类中获取父类,整个过程在子类中完成;但典型的方法是在父类中获取子类,整个过程在父类中完成,两种方法各有优缺点,但我认为在子类中操作更好。

另外我尝试过只使用一个布局文件,通过代码只让一部分的ViewLayout旋转,从而得到更好的相机预览旋转效果,但没有成功。不过从一些典型的相机APP中可以发现,还是可以只使用一个布局文件,但又能够完美处理相机预览旋转的。参考中列出了相关的尝试。

DEMO

本文实现的相机APP源码都放在GitHub上,如果需要请点击zhantong/AndroidCamera-OrientationAndRatio

参考