不知不覺已經接近半年多沒有寫過博客了,這段時間,也是我剛好畢業走出校園的時間,由於學習工作的原因,一直沒有真正靜下心來寫下些什么東西。這個星期剛入了小米筆記本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