Android Camera Develop: capture photo and video

概述

上篇完成了相机的偏好设置,本篇就要实现相机的核心功能——拍照和录像了。直觉上拍照和录像应该差别不大,但在Android中两者是有很大差别的,录像需要更多的步骤,以及更严格的处理逻辑。本篇还加入了对拍摄到的照片和视频的预览查看功能,就像其他相机APP那样。

生成文件名

因为拍照录像都是对相机的操作,所以这部分绝大多数都是在CameraPreview中添加代码。首先我们添加生成文件名这一功能,我们采用通用的做法,生成的文件名首先标记是照片还是视频,然后是拍摄的时间,比如IMG_20160503_153218.jpgVID_20160505_080204.mp4

CameraPreview中添加

public static final int MEDIA_TYPE_IMAGE = 1;
public static final int MEDIA_TYPE_VIDEO = 2;
private Uri outputMediaFileUri;
private String outputMediaFileType;

private File getOutputMediaFile(int type) {
    File mediaStorageDir = new File(Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_PICTURES), TAG);
    if (!mediaStorageDir.exists()) {
        if (!mediaStorageDir.mkdirs()) {
            Log.d(TAG, "failed to create directory");
            return null;
        }
    }
    String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
    File mediaFile;
    if (type == MEDIA_TYPE_IMAGE) {
        mediaFile = new File(mediaStorageDir.getPath() + File.separator +
                "IMG_" + timeStamp + ".jpg");
        outputMediaFileType = "image/*";
    } else if (type == MEDIA_TYPE_VIDEO) {
        mediaFile = new File(mediaStorageDir.getPath() + File.separator +
                "VID_" + timeStamp + ".mp4");
        outputMediaFileType = "video/*";
    } else {
        return null;
    }
    outputMediaFileUri = Uri.fromFile(mediaFile);
    return mediaFile;
}

public Uri getOutputMediaFileUri() {
    return outputMediaFileUri;
}

public String getOutputMediaFileType() {
    return outputMediaFileType;
}

其中静态成员变量MEDIA_TYPE_IMAGEMEDIA_TYPE_VIDEO标记文件为照片还是视频;outputMediaFileUrioutputMediaFileType则用来记录生成文件的URI和MIME类型。getOutputMediaFile()则根据参数中指定的文件类型,生成File类型的实例,供调用者写入文件;getOutputMediaFile()会在Android外部存储的图片路径下生成TAG文件夹,并在此生成文件,比如外部存储的Pictures/CameraDemo/;因为Android拍摄到的照片一般是jpg格式,视频一般是mp4格式,所以直接写死;最后生成的文件还会与outputMediaFileUrioutputMediaFileType同步,而getOutputMediaFileUri()getOutputMediaFileType()则是对外的接口,后面再解释。

拍照

拍照主要用到的是CameratakePicture()方法,通过指定回调函数,将照片数据写入到文件中。

CameraPreview中添加

public void takePicture() {
    mCamera.takePicture(null, null, new Camera.PictureCallback() {
        @Override
        public void onPictureTaken(byte[] data, Camera camera) {
            File pictureFile = getOutputMediaFile(MEDIA_TYPE_IMAGE);
            if (pictureFile == null) {
                Log.d(TAG, "Error creating media file, check storage permissions");
                return;
            }
            try {
                FileOutputStream fos = new FileOutputStream(pictureFile);
                fos.write(data);
                fos.close();

                camera.startPreview();
            } catch (FileNotFoundException e) {
                Log.d(TAG, "File not found: " + e.getMessage());
            } catch (IOException e) {
                Log.d(TAG, "Error accessing file: " + e.getMessage());
            }
        }
    });
}

调用takePicture()时实际会调用mCamera.takePicture(),其第三个参数即指定回调函数;当相机拍照完成后,就会触发onPictureTaken(),其中data参数就是jpeg格式的照片数据。我们只需要调用getOutputMediaFile()获取输出文件,并向此文件写入照片数据就好了。

onPictureTaken()触发后相机会停止预览,此时我们手动添加camera.startPreview()让相机持续预览。另外takePicture()是一个异步过程,需要注意。

录像

录像部分的代码很多,但其中绝大部分都是来自Android官方文档的,基本就是一个不变的套路。录像是交给MediaRecorder类在做,大体上来说就是实例化一个MediaRecorder,向其指定一系列参数,然后start()开始录像,stop()结束录像。
CameraPreview中添加

private MediaRecorder mMediaRecorder;

public boolean startRecording() {
    if (prepareVideoRecorder()) {
        mMediaRecorder.start();
        return true;
    } else {
        releaseMediaRecorder();
    }
    return false;
}

public void stopRecording() {
    if (mMediaRecorder != null) {
        mMediaRecorder.stop();
    }
    releaseMediaRecorder();
}

public boolean isRecording() {
    return mMediaRecorder != null;
}

private boolean prepareVideoRecorder() {

    mCamera = getCameraInstance();
    mMediaRecorder = new MediaRecorder();

    mCamera.unlock();
    mMediaRecorder.setCamera(mCamera);

    mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.CAMCORDER);
    mMediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);

    mMediaRecorder.setProfile(CamcorderProfile.get(CamcorderProfile.QUALITY_HIGH));

    SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
    String prefVideoSize = prefs.getString("video_size", "");
    String[] split = prefVideoSize.split("x");
    mMediaRecorder.setVideoSize(Integer.parseInt(split[0]), Integer.parseInt(split[1]));

    mMediaRecorder.setOutputFile(getOutputMediaFile(MEDIA_TYPE_VIDEO).toString());

    mMediaRecorder.setPreviewDisplay(mHolder.getSurface());

    try {
        mMediaRecorder.prepare();
    } catch (IllegalStateException e) {
        Log.d(TAG, "IllegalStateException preparing MediaRecorder: " + e.getMessage());
        releaseMediaRecorder();
        return false;
    } catch (IOException e) {
        Log.d(TAG, "IOException preparing MediaRecorder: " + e.getMessage());
        releaseMediaRecorder();
        return false;
    }
    return true;
}

private void releaseMediaRecorder() {
    if (mMediaRecorder != null) {
        mMediaRecorder.reset();
        mMediaRecorder.release();
        mMediaRecorder = null;
        mCamera.lock();
    }
}

prepareVideoRecorder()即实例化MediaRecorder为成员变量mMediaRecorder,指定相机、音频源、视频源、录制视频参数、输出文件路径以及预览等,关于细节请查看官方文档。

其中的

SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
String prefVideoSize = prefs.getString("video_size", "");
String[] split = prefVideoSize.split("x");
mMediaRecorder.setVideoSize(Integer.parseInt(split[0]), Integer.parseInt(split[1]));

是从Preference中读取视频分辨率偏好设置,并将其应用到mMediaRecorder;实际上这一步可以省略,录制的视频分辨率会和相机预览分辨率保持相同。

startRecording()首先调用prepareVideoRecorder()准备录像(如果不成功则放弃),start()开始录像。stopRecording()则调用stop()结束录像,并通过releaseMediaRecorder()完成收尾工作。isRecording()则用来判断当前是否正在录像。

申请权限

录像需要用到音频,而生成文件需要写入外部存储

AndroidManifest.xml中添加

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

添加按钮

上述只是完成了方法的设计,现在就要在UI上添加按钮,并实际调用啦。

添加按钮

修改activity_main.xml

<?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="horizontal">

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

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

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

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:orientation="vertical">

            <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>
    </RelativeLayout>
</LinearLayout>

UI大概像这样

Screenshot Add Button

绑定事件

MainActivityonCreate()最后添加

final Button buttonCapturePhoto = (Button) findViewById(R.id.button_capture_photo);
buttonCapturePhoto.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mPreview.takePicture();
    }
});
final Button buttonCaptureVideo = (Button) findViewById(R.id.button_capture_video);
buttonCaptureVideo.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        if (mPreview.isRecording()) {
            mPreview.stopRecording();
            buttonCaptureVideo.setText("录像");
        } else {
            if (mPreview.startRecording()) {
                buttonCaptureVideo.setText("停止");
            }
        }
    }
});

并在

CameraPreview mPreview = new CameraPreview(this);

前加final修饰符,即

final CameraPreview mPreview = new CameraPreview(this);

绑定拍照很容易不解释。录像会有开始和结束两种状态,这里我们仅用一个按钮实现,处理逻辑也很简单,主要就是用isRecording()判断当前状态,根据当前状态选择处理方法,并修改button的text属性。

运行一下试试

这样就完成了拍照和录像的基本功能,你可以在真机上运行APP,点击“拍照”和“录像”了,生成的文件你可以在外部存储Pictures文件夹下的文件夹中找到。

解决后台返回时黑屏的问题

上一篇文章说到过这一篇会解决这个问题,现在就来着手解决啦,也是为下一节作铺垫。

原因分析

这里就不长篇大论从Activity的生命周期和View的关系细节讲了。黑屏的原因很简单,MainActivity在被切换到后台时,其本身的状态是保留的,但在其中的CameraPreview却被销毁了;当MainActivity从后台返回后,MainActivity状态恢复,而CameraPreview只在其onCreate()中实例化和加入窗口中,但MainActivity不会再触发onCreate(),造成黑屏。

解决方法

分析出上述原因后,解决方法也容易得出了。MainActivity在从后台返回时,会触发onResume(),我们只需要在其中像在onCreate()中一样完成整个CameraPreview的初始化,就解决这个问题了。

分离CameraPreview初始化语句

为了之后的方便,将onCreate()中涉及到CameraPreview初始化的语句独立为方法,将onCreate()中的

final CameraPreview mPreview = new CameraPreview(this);
FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
preview.addView(mPreview);

SettingsFragment.passCamera(mPreview.getCameraInstance());
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
SettingsFragment.setDefault(PreferenceManager.getDefaultSharedPreferences(this));
SettingsFragment.init(PreferenceManager.getDefaultSharedPreferences(this));

替换为

initCamera();

MainActivity中添加

private CameraPreview mPreview;

private void initCamera() {
    mPreview = new CameraPreview(this);
    FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
    preview.addView(mPreview);

    SettingsFragment.passCamera(mPreview.getCameraInstance());
    PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
    SettingsFragment.setDefault(PreferenceManager.getDefaultSharedPreferences(this));
    SettingsFragment.init(PreferenceManager.getDefaultSharedPreferences(this));
}

注意由于mPreview的处理分离,将mPreview提升为成员变量;原先添加的final CameraPreview删去,变更为对成员变量的赋值。

重载onResume

为了安全起见,也对onPause()重载,在MainActivity中加入

public void onPause() {
    super.onPause();
    mPreview = null;
}

public void onResume() {
    super.onResume();
    if (mPreview == null) {
        initCamera();
    }
}

当APP切换到后台时触发onPause(),此时mPreview被销毁,将其赋值null;当从后台切换回来时,重新对mPreview初始化。

运行一下试试

现在把APP切换到后台再切换回来就不会出现黑屏的问题了。

添加预览

这里的预览不是相机预览了,是拍到的照片和视频的预览。目前常见的相机APP在拍照后,左下角或某个角落的小框就会马上显示新拍到的照片,点击这个小框就会全屏显示这个照片,现在我们就来实现这个功能。

预览框实际是ImageView,通过向ImageView指定图片或图片的URI,就可以在UI上显示这个图片了。那么对于拍到的视频怎么显示呢?这里我们可以获取到视频的预览图,将这个预览图指定给ImageView就好了。

添加预览框

修改activity_main.xml,在RelativeLayout内的LinearLayout之下加入

<ImageView
    android:id="@+id/media_preview"
    android:layout_width="60dp"
    android:layout_height="60dp"
    android:layout_alignParentBottom="true"
    android:layout_centerHorizontal="true"
    android:background="#000" />

就会在窗口的右下角出现一个黑框,效果如下:

Screenshot Add ImageView

加入预览

之前提到拍照时异步操作,因为这个原因,我们将ImageView的操作交给CameraPreview处理。

修改CameraPreview

我们需要修改takePicture()stopRecording()这两个方法,将其参数加上ImageView,并在其内部进行处理。
修改后的takePicture()如下

public void takePicture(final ImageView view) {
    mCamera.takePicture(null, null, new Camera.PictureCallback() {
        @Override
        public void onPictureTaken(byte[] data, Camera camera) {
            File pictureFile = getOutputMediaFile(MEDIA_TYPE_IMAGE);
            if (pictureFile == null) {
                Log.d(TAG, "Error creating media file, check storage permissions");
                return;
            }
            try {
                FileOutputStream fos = new FileOutputStream(pictureFile);
                fos.write(data);
                fos.close();

                view.setImageURI(outputMediaFileUri);
                camera.startPreview();
            } catch (FileNotFoundException e) {
                Log.d(TAG, "File not found: " + e.getMessage());
            } catch (IOException e) {
                Log.d(TAG, "Error accessing file: " + e.getMessage());
            }
        }
    });
}

相比于之前,加入了参数final ImageView view,以及在文件写入完成后加入了

view.setImageURI(outputMediaFileUri);

即指定ImageView的URI为刚才生成的照片文件。

修改后的stopRecording()如下:

public void stopRecording(final ImageView view) {
    if (mMediaRecorder != null) {
        mMediaRecorder.stop();
        Bitmap thumbnail = ThumbnailUtils.createVideoThumbnail(outputMediaFileUri.getPath(), MediaStore.Video.Thumbnails.MINI_KIND);
        view.setImageBitmap(thumbnail);
    }
    releaseMediaRecorder();
}

相比于之前,加入了参数final ImageView view,以及在录像完成后加入了

Bitmap thumbnail = ThumbnailUtils.createVideoThumbnail(outputMediaFileUri.getPath(), MediaStore.Video.Thumbnails.MINI_KIND);
view.setImageBitmap(thumbnail);

第一句是根据指定的视频路径,生成了一张视频预览图;第二句则将这个图片交给ImageView显示。

修改MainActivity

我们还需要在MainActivity中找到ImageView,并在调用上述两个方法时添加参数。

onCreate()中加入

final ImageView mediaPreview = (ImageView) findViewById(R.id.media_preview);

修改

mPreview.takePicture();

mPreview.takePicture(mediaPreview);

修改

mPreview.stopRecording();

mPreview.stopRecording(mediaPreview);

运行一下试试

现在每拍到新照片或视频,预览框都会同步更新了。如下

Screenshot Enable ImageView

点击预览框全屏显示

只是在预览框中显示拍到的照片或视频预览还是不够的,我们还想要点击这个预览框时能够全屏显示照片,或播放视频,下面我们就来实现这一功能。

先说思路,监听ImageView的点击事件,当点击时,通过Intent创建并显示一个新的Activity,同时MainActivity将需要显示的照片或视频的URI和MIME交给新的Activity,而这个新的Activity则负责显示照片和播放视频。在需要时,用户点击后退,回到MainActivity

修改MainActivity

onCreate()的最后加入

mediaPreview.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(MainActivity.this, ShowPhotoVideo.class);
        intent.setDataAndType(mPreview.getOutputMediaFileUri(), mPreview.getOutputMediaFileType());
        startActivityForResult(intent, 0);
    }
});

即对ImageView的点击事件监听,点击时创建一个新的Intent,新的ActivityShowPhotoVideo(稍后创建);然后通过DataType向这个Intent传递拍到的照片或视频的URI和MIME(也可以用setExtra()实现,但这个方法更直观准确);最后启动这个Activity,并加入回退栈,使得点击后退时能够返回到MainActivity(上一步解决的后台返回黑屏问题也在这个得到应用)。

创建ShowPhotoVideo

这个类继承自Activity,功能很简单,根据Type判断使用ImageView显示照片,或使用VideoView显示视频,然后向ImageViewVideoView传递Data包含的URI信息。

创建ShowPhotoVideo类

File List ShowPhotoVideo

文件内容如下:

import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.widget.ImageView;
import android.widget.MediaController;
import android.widget.RelativeLayout;
import android.widget.VideoView;

public class ShowPhotoVideo extends Activity {
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        RelativeLayout relativeLayout = new RelativeLayout(this);
        RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT);
        layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
        Uri uri = getIntent().getData();
        if (getIntent().getType().equals("image/*")) {
            ImageView view = new ImageView(this);
            view.setImageURI(uri);
            view.setLayoutParams(layoutParams);
            relativeLayout.addView(view);
        } else {
            MediaController mc = new MediaController(this);
            VideoView view = new VideoView(this);
            mc.setAnchorView(view);
            mc.setMediaPlayer(view);
            view.setMediaController(mc);
            view.setVideoURI(uri);
            view.start();
            view.setLayoutParams(layoutParams);
            relativeLayout.addView(view);
        }
        setContentView(relativeLayout, layoutParams);
    }
}

与其他Activity不同的是,ShowPhotoVideo没有layout文件,其布局等在onCreate()中用代码生成。首先新建一个RelativeLayoutrelativeLayout;再创建一个layout参数layoutParams,其参数设置长和宽均为MATCH_PARENT,随后像参数加入新规则CENTER_IN_PARENT,即居中显示。新建了ImageViewVideoView后,将view指定layoutParams参数,并将view加入到relativeLayout中。最后将本Activity的布局指定为刚才创建的relativeLayout,并设置layout参数为layoutParams

对于ImageViewVideoView的选择和操作。根据Type决定实例化ImageViewVideoView,若实例化ImageView,则指定其URI为Data参数,指定layout参数后加入到layout中;若实例化VideoView,则同时还会实例化MediaController用来对播放视频进行控制,在对MediaController进行初始化操作后,将VideoView指定layout参数后加入到layout中。

再说下工作过程。MainActivity创建ShowPhotoVideoIntent,并传递照片或视频的URI和MIME,然后切换到新的ActivityShowPhotoVideoShowPhotoVideo创建时触发onCreate(),通过MIME实例化ImageViewVideoView,用代码生成布局并将ImageViewVideoView加入到布局,最后将布局应用到ShowPhotoVideo,完成整个工作过程。

修改AndroidManifest

Intent操作时需要在AndroidManifest.xml中提前声明Activity,这里我们就是要将ShowPhotoVideo加入到声明中。

application中添加新的Activity,如下

<activity android:name=".ShowPhotoVideo" />

运行一下试试

现在运行APP在拍照或录像后,点击预览框就能够全屏显示照片或播放视频了。比如点击视频预览图后

Screenshot Show VideoView.png

一点唠叨

直到本文,我们就实现了一个基本能用的相机APP了,为了代码的简洁和逻辑的清晰考虑,没有做太多特殊情况的处理,所以还会有各种各样的小bug,你可以自己进行优化。另外还会有一些功能上的增强和细节上的优化,会在随后的文章中介绍。

DEMO

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

参考