Camera1 教程 · 第二章 · 預覽

Android Camera
上一章 ofollow,noindex">《Camera 教程 · 第一章 · 開啟相機》 我們介紹瞭如何開啟相機和關閉相機,但是還沒讓預覽畫面顯示出來,這一章我們就來介紹下如何讓相機開啟預覽。
閱讀完本章,你將會學到以下幾個知識點:
- 如何獲取相機支援的引數
- 如何配置預覽尺寸
- 如何配置預覽的 Surface
- 如何開啟和關閉預覽
- 裝置方向的概念
- 區域性座標系的概念
- 螢幕方向的概念
- 攝像頭感測器方向的概念
- 如何矯正預覽畫面的方向
- 如何適配預覽畫面的比例
- 如何切換前後置攝像頭
你可以在 https://github.com/darylgo/Camera1Sample 下載相關的原始碼,並且切換到 tag 為 chapter2 的 git 提交記錄以確保你檢視的程式碼和本章節是對應的。
1 認識 Parameters
相機功能的強大與否完全取決於各手機廠商的底層實現,在基於相機開發任何功能之前,你都需要通過某些手段判斷當前裝置相機的能力是否足以支撐你要開發的功能,而 Camera.Parameters 就是我們判斷相機能力大小的手段,在 Camera.Parameters 裡提供了大量形如 getSupportedXXX 的方法,通過這些方法你就可以判斷相機某方面的功能是否達到你的要求,例如通過 getSupportedPreviewSizes() 可以獲取相機支援的預覽尺寸列表,進而從這個列表中查詢是否有滿足你需求的尺寸。
除了通過 Camera.Parameters 判斷相機功能的支援情況之外,我們還通過 Camera.Parameters 設定絕大部分相機引數,並且通過 Camera.setParameters() 方法將設定好的引數傳給底層,讓這些引數生效。所以相機引數的配置流程基本就是以下三個步驟:
- 通過 Camera.getParameters() 獲取 Camera.Parameters 例項。
- 通過 Camera.Parameters.getSupportedXXX 獲取某個引數的支援情況。
- 通過 Camera.Parameters.set() 方法設定引數。
- 通過 Camera.setParameters() 方法將引數應用到底層。
注意:Camera.getParameters() 是一個比較耗時的操作,實測 20ms 到 100ms不等,所以儘可能地一次性設定所有必要的引數,然後通過 Camera.setParameters() 一次性應用到底層。
2 設定預覽尺寸
上面我們簡單介紹了 Camera.Parameters,這一節我們就要通過它來配置相機的預覽尺寸。所謂的預覽尺寸,指的就是相機把畫面輸出到手機螢幕上供使用者預覽的尺寸,通常來說我們希望預覽尺寸在不超過手機螢幕解析度的情況下,越大越好。另外,出於業務需求,我們的相機可能需要支援多種不同的預覽比例供使用者選擇,例如 4:3 和 16:9 的比例。由於不同廠商對相機的實現都會有差異,所以很多引數在不同的手機上支援的情況也不一樣,相機的預覽尺寸也是。所以在設定相機預覽尺寸之前,我們先通過 Camera.Parameters.getSupportedPreviewSizes() 獲取該裝置支援的所有預覽尺寸:
Camera.Parameters parameters = mCamera.getParameters(); List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
如果我們把所有的預覽尺寸都打印出來看時,會發現一個比較特別的情況,就是預覽尺寸的寬是長邊,高是短邊,例如 1920x1080,而不是 1080x1920,這一點大家需要特別注意。
在獲取到預覽尺寸列表之後,我們要根據自己的實際需求過濾出其中一個最符合要求的尺寸,並且把它設定給相機,在我們的 Demo 裡,只有當預覽尺寸的比例和大小都滿足要求時才能被設定給相機,如下所示:
/** * 根據指定的尺寸要求設定預覽尺寸,我們會同時考慮指定尺寸的比例和大小。 * * @param shortSide 短邊長度 * @param longSide長邊長度 */ @WorkerThread private void setPreviewSize(int shortSide, int longSide) { if (mCamera != null && shortSide != 0 && longSide != 0) { float aspectRatio = (float) longSide / shortSide; Camera.Parameters parameters = mCamera.getParameters(); List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes(); for (Camera.Size previewSize : supportedPreviewSizes) { if ((float) previewSize.width / previewSize.height == aspectRatio && previewSize.height <= shortSide && previewSize.width <= longSide) { parameters.setPreviewSize(previewSize.width, previewSize.height); mCamera.setParameters(parameters); break; } } } }
4 新增預覽 Surface
相機輸出的預覽畫面最終都是繪製到指定的 Surface 上,這個 Surface 可以來自 SurfaceHolder 或者 SurfaceTexture,至於什麼是 Surface 這裡就不過多解釋,大家可以自行了解。所以在開啟預覽之前,我們還要告訴相機把畫面輸出到哪個 Surface 上,Camera 支援兩種方式設定預覽的 Surface:
- 通過 Camera.setPreviewDisplay() 方法設定 SurfaceHolder 給相機,通常是在你使用 SurfaceView 作為預覽控制元件時會使用該方法。
- 通過 Camera.setPreviewTexture() 方法設定 SurfaceTexture 給相機,通常是在你使用 TextureView 作為預覽控制元件或者自己建立 SurfaceTexture 時使用該方法。
在我們的 Demo 裡,使用的 SurfaceView,所以會通過 Camera.setPreviewDisplay() 方法設定預覽的 Surface,程式碼片段如下所示:
/** * 設定預覽 Surface。 */ @WorkerThread private void setPreviewSurface(SurfaceHolder previewSurface) { if (mCamera != null && previewSurface != null) { try { mCamera.setPreviewDisplay(previewSurface); } catch (IOException e) { e.printStackTrace(); } } }
5 開啟和關閉預覽
接下來,我們就要正式開啟相機預覽了,相關的方法就下面兩個:
- Camera.startPreview():開啟預覽
- Camera.stopPreview():關閉預覽
在 Demo 的程式碼中,我們做了一些邏輯處理,程式碼如下:
/** * 開始預覽。 */ @WorkerThread private void startPreview() { if (mCamera != null) { mCamera.startPreview(); Log.d(TAG, "startPreview() called"); } } /** * 停止預覽。 */ @WorkerThread private void stopPreview() { if (mCamera != null) { mCamera.stopPreview(); Log.d(TAG, "stopPreview() called"); } }
6 校正預覽畫面方向
如果沒有做任何畫面方向的校正,我們看到的畫面很可能是橫向的,這是因為手機上的攝像頭感測器方向不一定是垂直的。在做預覽畫面方向的校正之前我們先來了解五個概念,分別是自然方向、裝置方向、區域性座標系、螢幕方向和攝像頭感測器方向。
自然方向
當我們談論方向的時候,實際上都是相對於某一個 0° 方向的角度,這個 0° 方向被稱作自然方向,例如人站立的時候就是自然方向,你總不會認為一個人要倒立的時候才是自然方向吧,而接下來我們要談論的裝置方向就有的自然方向的定義。
裝置方向
裝置方向指的是硬體裝置在空間中的方向與其自然方向的順時針夾角。這裡提到的自然方向指的就是我們手持一個裝置的時候最習慣的方向,比如手機我們習慣豎著拿,而平板我們則習慣橫著拿,所以通常情況下手機的自然方向就是豎著的時候,平板的自然方向就是橫著的時候。

以手機為例,我們可以有以下四個比較常見的裝置方向:
- 當我們把手機垂直放置且螢幕朝向我們的時候,裝置方向為 0°,即裝置自然方向
- 當我們把手機向右橫放且螢幕朝向我們的時候,裝置方向為 90°
- 當我們把手機倒著放置且螢幕朝向我們的時候,裝置方向為 180°
- 當我們把手機向左橫放且螢幕朝向我們的時候,裝置方向為 270°
瞭解了裝置方向的概念之後,我們可以通過 OrientationEventListener 監聽裝置的方向,進而判斷裝置當前是否處於自然方向,當裝置的方向發生變化的時候會回撥 OrientationEventListener.onOrientationChanged(int) 方法,傳給我們一個 0° 到 359° 的方向值,其中 0° 就代表裝置處於自然方向。
區域性座標系
所謂的區域性座標系指的是當裝置處於自然方向時,相對於裝置螢幕的座標系,該座標系是固定不變的,不會因為裝置方向的變化而改變,下圖是基於手機的區域性座標系示意圖:

區域性座標系
- x 軸是當手機處於自然方向時,和手機螢幕平行且指向右邊的座標軸。
- y 軸是當手機處於自然方向時,和手機螢幕平行且指向上方的座標軸。
- z 軸是當手機處於自然方向時,和手機螢幕垂直且指向螢幕外面的座標軸。
為了進一步解釋【座標系是固定不變的,不會因為裝置方向的變化而改變】的概念,這裡舉個例子,當我們把手機向右橫放且螢幕朝向我們的時候,此時裝置方向為 90°,區域性座標系相對於手機螢幕是保持不變的,所以 y 軸正方向指向右邊,x 軸正方向指向下方,z 軸正方向還是指向螢幕外面,如下圖所示:

裝置方向 90°
螢幕方向
螢幕方向指的是螢幕上顯示畫面與區域性座標系 y 軸的順時針夾角,注意這裡實際上指的是顯示的畫面,而不是物理硬體上的螢幕,只是我們習慣上稱作螢幕方向而已。
為了更清楚的說明這個概念,我們舉一個例子,假設我們將手機向右橫放看電影,此時畫面是朝上的,如下圖所示:

螢幕方向
從上圖來看,手機向右橫放會導致裝置方向變成了 90°,但是螢幕方向卻是 270°,因為它是相對區域性座標系 y 軸的順時針夾角,所以跟裝置方向沒有任何關係。如果把圖中的裝置換成是平板,結果就不一樣了,因為平板橫放的時候就是它的裝置自然方向,y 軸朝上,螢幕畫面顯示的方向和 y 軸的夾角是 0°,裝置方向也是 0°。
總結一下,裝置方向和螢幕方向之間沒有任何關係,裝置方向是相對於其現實空間中自然方向的角度,而螢幕方向是相對區域性座標系的角度。
攝像頭感測器方向
攝像頭感測器方向指的是感測器採集到的畫面方向經過順時針旋轉多少度之後才能和區域性座標系的 y 軸正方向一致,也就是在上一章 《Camera 教程 · 第一章 · 開啟相機》 裡,我們提到的 Camera.CameraInfo.orientation 屬性。
例如 orientation 為 90° 時,意味我們將攝像頭採集到的畫面順時針旋轉 90° 之後,畫面的方向就和區域性座標系的 y 軸正方向一致,換個說法就是原始畫面的方向和 y 軸的夾角是逆時針 90°。
最後我們要考慮一個特殊情況,就是前置攝像頭的畫面是做了映象處理的,也就是所謂的前置映象操作,這個情況下, orientation 的值並不是實際我們要旋轉的角度,我們需要取它的映象值才是我們真正要旋轉的角度,例如 orientation 為 270°,實際我們要旋轉的角度是 90°。
注意:攝像頭感測器方向在不同的手機上可能不一樣,大部分手機都是 90°,也有小部分是 0° 的,所以我們要通過 Camera.CameraInfo.orientation 去判斷方向,而不是假設所有裝置的攝像頭感測器方向都是 90°。
畫面方向校正
介紹完幾個方向的概念之後,我們就來說下如何校正相機的預覽畫面。我們會舉幾個例子,由簡到繁逐步說明預覽畫面校正過程中要注意的事項。
首先我們要知道的是攝像頭感測器方向只有 0°、90°、180°、270° 四個可選值,並且這些值是相對於區域性座標系 的 y 軸定義出來的,現在假設一個相機 APP 的畫面在手機上是豎屏顯示,也就是螢幕方向是 0° ,並且假設攝像頭感測器的方向是 90°,如果我們沒有校正畫面的話,則顯示的畫面如下圖所示(忽略畫面變形):

很明顯,上面顯示的畫面內容方向是錯誤的,裡面的人物應該是垂直向上顯示才對,所以我們應該吧攝像頭採集到的畫面順時針旋轉 90°,才能得到正確的顯示結果,如下圖所示:

上面的例子是建立在我們的螢幕方向是 0° 的時候,如果我們要求螢幕方向是 90°,也就是手機向左橫放的時候畫面才是正的,並且假設攝像頭感測器的方向還是 90°,如果我們沒有校正畫面的話,則顯示的畫面如下圖所示(忽略畫面變形):

此時,我們知道感測器的方向是 90°,如果我們將感測器採集到的畫面順時針旋轉 90° 顯然是無法得到正確的畫面,因為它是相對於區域性座標系 y 軸的角度,而不是實際螢幕方向,所以在做畫面校正的時候我們還要把實際螢幕方向也考慮進去,這裡實際螢幕方向是 90°,所以我們應該把感測器採集到的畫面順時針旋轉 180°(攝像頭感測器方向 + 實際螢幕方向) 才能得到正確的畫面,顯示的畫面如下圖所示(忽略畫面變形):

總結一下,在校正畫面方向的時候要同時考慮兩個因素,即攝像頭感測器方向和螢幕方向。接下來我們要回到我們的相機應用裡,看看通過程式碼是如何實現預覽畫面方向校正的。
如果你有自己看過 Camera 的官方 API 文件,你會發現官方已經給我們寫好了一個同時考慮螢幕方向和攝像頭感測器方向的方法:
private int getCameraDisplayOrientation(Camera.CameraInfo cameraInfo) { int rotation = getWindowManager().getDefaultDisplay().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; } int result; if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { result = (cameraInfo.orientation + degrees) % 360; result = (360 - result) % 360;// compensate the mirror } else {// back-facing result = (cameraInfo.orientation - degrees + 360) % 360; } return result; }
如果你已經完全理解前面介紹的那些角度的概念,那你應該很容易就能理解上面這段程式碼,實際上就是通過 WindowManager 獲取當前的螢幕方向,然後再參照攝像頭感測器方向以及是否是前後置,最後計算出我們實際要旋轉的角度。
計算出要矯正的角度之後,我們要通過 Camera.setDisplayOrientation() 方法設定畫面的矯正方向,下面是 Demo 中開啟相機之後,馬上配置畫面矯正方向的程式碼:
private void openCamera(int cameraId) { if (mCamera != null) { throw new RuntimeException("You must close previous camera before open a new one."); } if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { mCamera = Camera.open(cameraId); mCameraId = cameraId; mCameraInfo = cameraId == mFrontCameraId ? mFrontCameraInfo : mBackCameraInfo; Log.d(TAG, "Camera[" + cameraId + "] has been opened."); assert mCamera != null; mCamera.setDisplayOrientation(getCameraDisplayOrientation(mCameraInfo)); } }
7 適配預覽比例
前面介紹矯正預覽畫面方向的時候,我們看到了畫面變形的情況,這因為展示預覽畫面的 Surface 和預覽尺寸的比例不一致導致的,所以接下來我們要學習的是如何適配不同的預覽比例。實際上預覽比例的適配有兩種方式:
- 根據預覽比例修改 Surface 的比例,這個是我們實際業務中經常用的方式,比如使用者選擇了 4:3 的預覽比例,這個時候我們會選取 4:3 的預覽尺寸並且把 Surface 修改成 4:3 的比例,從而讓畫面不會變形。
- 根據 Surface 的比例修改預覽比例,這種情況適用於 Surface 的比例是固定的,然後根據 Surface 的比例去選取適合的預覽尺寸。
在我們的 Demo 中,出於簡化的目的,我們選擇了第二種方式適配比例,因為這種方式實現起來比較簡單,所以我們會寫一個自定義的 SurfaceView,讓它的比例固定是 4:3,它的寬度固定填滿父佈局,高度根據比例動態計算:
public class SurfaceView43 extends SurfaceView { public SurfaceView43(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = MeasureSpec.getSize(widthMeasureSpec); int height = width / 3 * 4; setMeasuredDimension(width, height); } }
上面的 SurfaceView43 使我們自定義的 SurfaceView,它的比例固定為 4:3,所以在它的 surfaceChanged() 回撥中拿到的寬高的比例固定是 4:3,我們根據這個寬高比去呼叫前面定義好的設定預覽尺寸方法就可以設定正確比例的預覽尺寸:
@WorkerThread private void setPreviewSize(int shortSide, int longSide) { if (mCamera != null && shortSide != 0 && longSide != 0) { float aspectRatio = (float) longSide / shortSide; Camera.Parameters parameters = mCamera.getParameters(); List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes(); for (Camera.Size previewSize : supportedPreviewSizes) { if ((float) previewSize.width / previewSize.height == aspectRatio && previewSize.height <= shortSide && previewSize.width <= longSide) { parameters.setPreviewSize(previewSize.width, previewSize.height); mCamera.setParameters(parameters); Log.d(TAG, "setPreviewSize() called with: width = " + previewSize.width + "; height = " + previewSize.height); } } } }
經過上面的比例適配之後,相機的預覽畫面就應該固定是 4:3 的比例並且不會變形了。
8 切換前後置攝像頭
實際需求經常要求 APP 能夠支援前後置攝像頭的切換,所以這裡我們也介紹下如何實現前後置攝像頭的切換。大部分情況下我們在切換前後置攝像頭的時候,都會直接複用同一個 Surface,所以我們會在 surfaceChanged() 的時候把 Surface 儲存下來,如下所示:
private class PreviewSurfaceCallback implements SurfaceHolder.Callback { @Override public void surfaceCreated(SurfaceHolder holder) { } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { mPreviewSurface = holder; mPreviewSurfaceWidth = width; mPreviewSurfaceHeight = height; setupPreview(holder, width, height); } @Override public void surfaceDestroyed(SurfaceHolder holder) { mPreviewSurface = null; mPreviewSurfaceWidth = 0; mPreviewSurfaceHeight = 0; } }
然後就是新增一個切換前後置的按鈕,當點選按鈕的時候回去獲取和當前攝像頭 ID 相反方向的 ID,所以我們定義了一個 switchCameraId() 方法,如下所示:
/** * 切換前後置時切換ID */ private int switchCameraId() { if (mCameraId == mFrontCameraId && hasBackCamera()) { return mBackCameraId; } else if (mCameraId == mBackCameraId && hasFrontCamera()) { return mFrontCameraId; } else { throw new RuntimeException("No available camera id to switch."); } }
最後就是走一個標準的切換前後置攝像頭流程了:
- 停止預覽
- 關閉當前攝像頭
- 開啟新的攝像頭
- 配置預覽尺寸
- 配置預覽 Surface
- 開啟預覽
因為我們的 Demo 中使用 HandlerThread 控制了相機的操作流程,所以你可以看到如下程式碼,具體的實現請看 Demo:
private class OnSwitchCameraButtonClickListener implements View.OnClickListener { @Override public void onClick(View v) { Handler cameraHandler = mCameraHandler; SurfaceHolder previewSurface = mPreviewSurface; int previewSurfaceWidth = mPreviewSurfaceWidth; int previewSurfaceHeight = mPreviewSurfaceHeight; if (cameraHandler != null && previewSurface != null) { int cameraId = switchCameraId();// 切換攝像頭 ID cameraHandler.sendEmptyMessage(MSG_STOP_PREVIEW);// 停止預覽 cameraHandler.sendEmptyMessage(MSG_CLOSE_CAMERA);// 關閉當前的攝像頭 cameraHandler.obtainMessage(MSG_OPEN_CAMERA, cameraId, 0).sendToTarget();// 開啟新的攝像頭 cameraHandler.obtainMessage(MSG_SET_PREVIEW_SIZE, previewSurfaceWidth, previewSurfaceHeight).sendToTarget();// 配置預覽尺寸 cameraHandler.obtainMessage(MSG_SET_PREVIEW_SURFACE, previewSurface).sendToTarget();// 配置預覽 Surface cameraHandler.sendEmptyMessage(MSG_START_PREVIEW);// 開啟預覽 } } }
到這裡,本章就介紹完了,謝謝。