Android模仿iOS iMessages10照片选择器的实现

2023-05-03,,

不知不觉已经接近半年多没有写过博客了,这段时间,也是我刚好毕业走出校园的时间,由于学习工作的原因,一直没有真正静下心来写下些什么东西。这个星期刚入了小米笔记本pro的坑,本着新电脑新生活的理念嘻嘻--,我决定把这半年来在工作遇到的一些技术难点分享出来,同时也加深自己的一些理解。

一、效果展示:

如下图所示,是Android端参考iOS iMessages10照片选择器所实现的一个效果:(就是一个小相机+最近照片列表的效果)

         

二、实现思路:

刚开始看到这样的一种功能时,我相信很多Android开发程序员都会菊花一紧吧,初看会让人觉得很难实现(可能我比较菜),但其实我们仔细分析下,并没有想象中那么难,首先我们可以先构思一下布局要如何去实现,让自己有一个大概的思路:

滑动列表我们顺其自然的想到用Recycleview来实现,关于小相机+相册和拍照按钮这两个我们可以尝试用添加头部的方式添加到Recycleview里面去,以此达到整体的滑动效果,这样想想似乎很完美,没有毛病,接下来就想办法把小相机弄出来就好了。

刚开始我就是上面这样的一种思路,大概效果也根据这个思路实现了出来,但是这里有一个问题我们忽略掉了,就是Recycleview的复用问题,如果小相机作为头部添加到Recycleview里面去,当滑动列表,小相机从不可见变为可见时,因为复用会导致每次都去重新加载这个小相机,导致滑动非常卡,体验非常不好。

这种布局体验不好,那我们就换一种实现方式,我相信很多人也都想到了,把相册和拍照按钮+小相机+Recycleview的三个布局顺序排放,在它们的外面嵌套一层Horizontalscrollview,这样同样能达到我们的目的,但这种方式同样有一个小坑,Horizontalscrollview和Recycleview相互嵌套会使得Recycleview显示不全(只显示一行),不过这个问题我们可以通过动态计算Recycleview的宽度来解决。

总体思路有了,我们就一步一步来实现我们所需要的效果吧,首先要讲的也是本篇最为重要的一个点,就是小相机的实现方式,其实也就是对Android Camera2的使用,关于Camera2我就不做过多的介绍了,它其实就是安卓5.0开始(API Level 21)的一个新的相机API,可以用来完全控制安卓相机设备。

三、小相机的实现:

1.首先我们应该在布局文件中定义一个TextureView,这个TextureView主要用来装载显示我们所获取到的相机数据,通俗一点来讲,这个TextureView就是我们的小相机啦,这里TextureView的宽高可以由我们自己来设定,但是这里需要注意一点,宽高应该按照一定的比例来设定,不然相机数据会被拉伸,例如我们可以使用4:3或者16:9的比例来设定,布局代码简单如下:

<TextureView
android:id="@+id/textureView"
android:layout_width="90dp"
android:layout_height="160dp" />

2.接下来要先初始化TextureView,首先让TextureView所在的Activity继承TextureView.SurfaceTextureListener这个接口,这个接口需要重写四个方法,分别为:onSurfaceTextureAvailable、onSurfaceTextureSizeChanged、onSurfaceTextureDestroyed、onSurfaceTextureUpdated,重写后调用如下代码进行初始化TextureView:

    private void initTextureView() {
//我们这里顺便new一个handler出来,后面会用到
mCameraThread = new HandlerThread("CameraThread");
mCameraThread.start();
mCameraHandler = new Handler(mCameraThread.getLooper()); mTextureView.setSurfaceTextureListener(this);
}

3.重写的这四个方法中,看名字我们也大概猜到它的意思了,这里我们主要看的是onSurfaceTextureAvailable这个方法,这个方法也是我们最核心的一个方法,当Activity接收到这个方法的回调时,则代表我们的TextureView已准备就绪,此时可以进行相关的相机设置并打开我们的相机获取数据,代码如下:

   /**
* *****************************TextureView.SurfaceTextureListener******************************
*/
@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
//当SurefaceTexture可用的时候,设置相机参数并打开相机
this.width = width;
this.height = height; setupCamera(width, height);
openCamera(mCameraId);
} @Override
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { } @Override
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
return false;
} @Override
public void onSurfaceTextureUpdated(SurfaceTexture surface) { }

4.接下来我们看setupCamera这个方法里面是如何配置相机的,直接看代码:

    private void setupCamera(int width, int height) {
//获取摄像头的管理者CameraManager
CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
try {
//遍历所有摄像头
for (String cameraId : manager.getCameraIdList()) {
CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
//默认是后置摄像头,这个判断是检测哪一个是前置摄像头并进行相关记录(为了后面可以点击切换前后摄像头)
if (characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT) {
mCameraIdFront = cameraId;
} else {
mCameraId = cameraId;
} //获取StreamConfigurationMap,它是管理摄像头支持的所有输出格式和尺寸
StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
//根据TextureView的尺寸设置预览尺寸
mPreviewSize = getOptimalSize(map.getOutputSizes(SurfaceTexture.class), width, height);
//获取相机支持的最大拍照尺寸
mCaptureSize = Collections.max(Arrays.asList(map.getOutputSizes(ImageFormat.JPEG)), new Comparator<Size>() {
@Override
public int compare(Size lhs, Size rhs) {
return Long.signum(lhs.getWidth() * lhs.getHeight() - rhs.getHeight() * rhs.getWidth());
}
}); //此ImageReader用于拍照所需
setupImageReader();
}
} catch (Exception e) {
e.printStackTrace();
}
}

getOptimalSize这个方法作用是根据TextureView的尺寸大小来获取一个合适的预览尺寸,代码如下:

    //选择sizeMap中大于并且最接近width和height的size
private Size getOptimalSize(Size[] sizeMap, int width, int height) {
List<Size> sizeList = new ArrayList<>();
for (Size option : sizeMap) {
if (width > height) {
if (option.getWidth() > width && option.getHeight() > height) {
sizeList.add(option);
}
} else {
if (option.getWidth() > height && option.getHeight() > width) {
sizeList.add(option);
}
}
}
if (sizeList.size() > 0) {
return Collections.min(sizeList, new Comparator<Size>() {
@Override
public int compare(Size lhs, Size rhs) {
return Long.signum(lhs.getWidth() * lhs.getHeight() - rhs.getWidth() * rhs.getHeight());
}
});
}
return sizeMap[0];
}

setupImageReader是对ImageReader的一个简单配置,在ImageReader这里我们可以获取到小相机所拍摄到的照片,可以在这里进行相关存储照片等操作,这里我把拍完的照片(如何拍照请看后面)保存到了SD卡根目录的/ifreegroup/CameraV2/文件夹中,需要注意的一点是:这里如果保存完后想进行一些刷新界面的操作,需要使用Handler和Message的方式,不要直接在setOnImageAvailableListener回调中进行,不然会报错,代码代码如下:

private ImageReader mImageReader;
private void setupImageReader() {
//2代表ImageReader中最多可以获取两帧图像流
mImageReader = ImageReader.newInstance(mCaptureSize.getWidth(), mCaptureSize.getHeight(),
ImageFormat.JPEG, 2);
     
mImageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() {
@Override
public void onImageAvailable(ImageReader reader) {
Image mImage = reader.acquireNextImage();
ByteBuffer buffer = mImage.getPlanes()[0].getBuffer();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String path = Environment.getExternalStorageDirectory() + "/ifreegroup/CameraV2/";
File mImageFile = new File(path);
if (!mImageFile.exists()) {
mImageFile.mkdir();
}
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String fileName = path + "IMG_" + timeStamp + ".jpg";
FileOutputStream fos = null;
try {
fos = new FileOutputStream(fileName);
fos.write(data, 0, data.length); Message msg = new Message();
msg.what = CAPTURE_OK;
msg.obj = fileName;
mCameraHandler.sendMessage(msg); } catch (IOException e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
mImage.close();
}
}, mCameraHandler); mCameraHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
switch (msg.what) {
case CAPTURE_OK:
//图片已经保存好了,在这里做你想做的事
break;
}
}
};
}

5.到这里我们的相机已经配置完毕了,一切准备就绪,接下来就去打开相机吧,这里指的是使用Camera2 API 打开我们的相机数据,而不是指调用打开我们的系统相机,关于怎么打开相机,这里分装成了一个方法openCamera(mCameraId),也就是我们上面在onSurfaceTextureAvailable所调用的方法,这个方法又是怎样的呢,我们也直接来看代码:

    private void openCamera(String CameraId) {
//获取摄像头的管理者CameraManager
CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
//检查权限
try {
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
return;
}
//打开相机,第一个参数指示打开哪个摄像头,第二个参数stateCallback为相机的状态回调接口,第三个参数用来确定Callback在哪个线程执行,为null的话就在当前线程执行
manager.openCamera(CameraId, mStateCallback, null);
} catch (CameraAccessException e) {
e.printStackTrace();
}
} private CameraDevice.StateCallback mStateCallback = new CameraDevice.StateCallback() {
@Override
public void onOpened(CameraDevice camera) {
mCameraDevice = camera;
//相机已打开,此时可以开启我们的小相机预览
startPreview();
} @Override
public void onDisconnected(CameraDevice camera) {
camera.close();
mCameraDevice = null;
} @Override
public void onError(CameraDevice camera, int error) {
camera.close();
mCameraDevice = null;
}
};

6.配置好了相机,也打开了相机,接下来就去开启我们的小相机预览吧,代码如下:

 public void startPreview() {
SurfaceTexture mSurfaceTexture = mTextureView.getSurfaceTexture();
if (mSurfaceTexture != null) {
//设置TextureView的缓冲区大小
mSurfaceTexture.setDefaultBufferSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
//获取Surface显示预览数据
Surface mSurface = new Surface(mSurfaceTexture);
try {
//创建CaptureRequestBuilder,TEMPLATE_PREVIEW比表示预览请求
mCaptureRequestBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
//设置Surface作为预览数据的显示界面
mCaptureRequestBuilder.addTarget(mSurface);
//创建相机捕获会话,第一个参数是捕获数据的输出Surface列表,第二个参数是CameraCaptureSession的状态回调接口,当它创建好后会回调onConfigured方法,第三个参数用来确定Callback在哪个线程执行,为null的话就在当前线程执行
mCameraDevice.createCaptureSession(Arrays.asList(mSurface, mImageReader.getSurface()), new CameraCaptureSession.StateCallback() {
@Override
public void onConfigured(CameraCaptureSession session) {
try {
//创建捕获请求
mCaptureRequest = mCaptureRequestBuilder.build();
mCameraCaptureSession = session;
//设置反复捕获数据的请求,这样预览界面就会一直有数据显示
mCameraCaptureSession.setRepeatingRequest(mCaptureRequest, null, mCameraHandler); } catch (Exception e) {
e.printStackTrace();
}
} @Override
public void onConfigureFailed(CameraCaptureSession session) { }
}, null);
} catch (CameraAccessException e) {
e.printStackTrace();
}
}
}

7.如何拍照呢?也很简单,这里我也分装成了一个方法capture(),每次拍照只需要调用这个方法就行了,这里有一点需要注意一下,就是前置摄像头拍完照后照片会变歪,所以需要我们自己手动旋转一下,代码如下所示:

    //拍照方向
private static final SparseIntArray ORIENTATION = new SparseIntArray(); static {
ORIENTATION.append(Surface.ROTATION_0, 90);
ORIENTATION.append(Surface.ROTATION_90, 0);
ORIENTATION.append(Surface.ROTATION_180, 270);
ORIENTATION.append(Surface.ROTATION_270, 180);
}
private void capture() {
if (mCameraDevice == null) {
return;
}
try {
final CaptureRequest.Builder mCaptureBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
int rotation =activity.getWindowManager().getDefaultDisplay().getRotation();
mCaptureBuilder.addTarget(mImageReader.getSurface());
//CameraFront是自定义的一个boolean值,用来判断是不是前置摄像头,是的话需要旋转180°,不然拍出来的照片会歪了
if (CameraFront) {
mCaptureBuilder.set(CaptureRequest.JPEG_ORIENTATION, ORIENTATION.get(Surface.ROTATION_180));
} else {
mCaptureBuilder.set(CaptureRequest.JPEG_ORIENTATION, ORIENTATION.get(rotation));
} CameraCaptureSession.CaptureCallback CaptureCallback = new CameraCaptureSession.CaptureCallback() {
@Override
public void onCaptureCompleted(CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {
unLockFocus();
}
};
mCameraCaptureSession.stopRepeating();
mCameraCaptureSession.capture(mCaptureBuilder.build(), CaptureCallback, null);
} catch (CameraAccessException e) {
e.printStackTrace();
}
} private void unLockFocus() {
try {
mCaptureRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL);
//mCameraCaptureSession.capture(mCaptureRequestBuilder.build(), null, mCameraHandler);
mCameraCaptureSession.setRepeatingRequest(mCaptureRequest, null, mCameraHandler);
} catch (CameraAccessException e) {
e.printStackTrace();
}
}

8.如果你跟着上面一步一步的敲下来,那么你的小相机估计也能看到后置摄像头数据并实现拍照保存照片了~ ,如果不行,好好在回去校对看有没有哪里写错了吧。

9.前后摄像头的切换功能,关于这个实现非常简单,已知我们前面在配置相机的时候已经对前摄像头的ID进行了记录,我们只需要重新的调用 setupCamera()和openCamera()这两个方法重新刷新下界面就可以了,代码如下:(代码中mCameraIdFront是前置摄像头ID,mCameraId是后置摄像头ID,想要打开哪个摄像头,只需要将相关ID传入openCamera(id)这个方法里面就可以了)

    private  boolean CameraFront;
public void switchCamera() {
if (mCameraDevice != null) {
mCameraDevice.close();
} if (CameraFront) {
setupCamera(width, height);
openCamera(mCameraId);
CameraFront = false;
} else {
setupCamera(width, height);
openCamera(mCameraIdFront);
CameraFront = true;
}
}

10.这里有个小坑,就是在小相机在已经加载好的情况下,如果你在其他地方调用到了系统相机,当你回到小相机页面时,你会发现小相机没有了预览数据,所以这里我还定义了一个刷新相机界面的方法,同样是调用 setupCamera()和openCamera()这两个方法,供我们在必要合适的时候重新刷新一下小相机数据,防止其黑屏,代码如下所示,代码里的mCameraId是我们前面获取到的后置摄像头ID,这里表示刷新后默认先打开后置摄像头

    public void refreshCamera() {
setupCamera(width, height);
openCamera(mCameraId);
CameraFront = false;
}

到这里,我们的小相机基本功能已经可以使用了,接下来只需要把Recycleview列表加上去就好了。

四、Recycleview列表显示最近保存的照片:

1.这里主要是提供一个可以获取最近保存到手机图片的方法,代码如下:

   private static ArrayList<String> getNearImags(Context context) {
int count = 0;
ArrayList<String> img_path = new ArrayList<>();
// 获取SDcard卡的路径
String sdcardPath = Environment.getExternalStorageDirectory().toString(); ContentResolver mContentResolver = context.getContentResolver();
Cursor mCursor = mContentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[]{MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA},
MediaStore.Images.Media.MIME_TYPE + "=? OR " + MediaStore.Images.Media.MIME_TYPE + "=?",
new String[]{"image/jpeg", "image/gif"}, MediaStore.Images.Media._ID + " DESC"); // 获取ipg和gif图片,并按图片ID降序排列 while (mCursor.moveToNext()) {
count++;
// 过滤掉不需要的图片,只获取拍照后存储照片的相册里的图片
String path = mCursor.getString(mCursor.getColumnIndex(MediaStore.Images.Media.DATA));
//只取前30张
if (count > 30) {
break;
}
}
mCursor.close();
return img_path;
}

五、动态计算RecycleView的宽度:

Recycleview的LayoutManager我使用的是StaggeredGridLayoutManager,设置的是两行,如下:

photoRecycleView.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.HORIZONTAL));

列表总共两行并且是固定的,这就比较好办了,我们获取拿到的照片总数,然后看其能不能被2整除,可以的话只要除以2然后乘以每一个item的宽度就是Recycleview的宽度了,如果不可以被2整除,那么还需要再加多一个item的宽度,代码如下:(这里的128dp是我默认的每一个item的宽度)

   //动态计算recycleview高度
if (getNearImags(context) != null) {
int size = getNearImags(context).size();
ViewGroup.LayoutParams mParams = photoRecycleView.getLayoutParams();
if (size % 2 == 0) {
mParams.width = ScreenUtil.dip2px(128) * size / 2;
} else {
mParams.width = ScreenUtil.dip2px(128) * (size / 2) + 1;
}
photoRecycleView.setLayoutParams(mParams);
}
photoRecycleView.setAdapter(adapter);

到这里,关于Android模仿iOS iMessages10照片选择器的实现思路大概就是这样了,这个过程中遇到的坑也都进行了红色标注,因为是集成在项目里面的,暂时还没有DEMO,如果有其他的问题,欢迎大家一起讨论~

QQ:471497226@qq.com

Android模仿iOS iMessages10照片选择器的实现的相关教程结束。

《Android模仿iOS iMessages10照片选择器的实现.doc》

下载本文的Word格式文档,以方便收藏与打印。