Camera2 教程 · 第三章 · 預覽

Android Camera
上一章《Camera2 開啟相機》我們學習瞭如何開啟和關閉相機,接下來我們來學習如何開啟預覽。
閱讀完本章,你將會學到以下幾個知識點:
- 如何配置預覽尺寸
- 如何建立 CameraCaptureSession
- 如何建立 CaptureRequest
- 如何開啟和關閉預覽
- 如何適配預覽畫面的比例
- 如何使用 ImageReader 獲取預覽資料
- 裝置方向的概念
- 區域性座標系的概念
- 顯示方向的概念
- 攝像頭感測器方向的概念
- 如何矯正影象資料的方向
你可以在 https://github.com/darylgo/Camera2Sample 下載相關的原始碼,並且切換到 Tutorial3 標籤下。
1 獲取預覽尺寸
在第一章《Camera2 概覽》我們提到了 CameraCharacteristics 是一個只讀的相機資訊提供者,其內部攜帶大量的相機資訊,包括代表相機朝向的 LENS_FACING
;判斷閃光燈是否可用的 FLASH_INFO_AVAILABLE
;獲取所有可用 AE 模式的 CONTROL_AE_AVAILABLE_MODES
等等。如果你對 Camera1 比較熟悉,那麼 CameraCharacteristics 有點像 Camera1 的 Camera.CameraInfo 或者 Camera.Parameters。CameraCharacteristics 以鍵值對的方式提供相機資訊,你可以通過 CameraCharacteristics.get()
方法獲取相機資訊,該方法要求你傳遞一個 Key 以確定你要獲取哪方面的相機資訊,例如下面的程式碼展示瞭如何獲取攝像頭方向資訊:
val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId) val lensFacing = cameraCharacteristics[CameraCharacteristics.LENS_FACING] when(lensFacing) { CameraCharacteristics.LENS_FACING_FRONT -> { // 前置攝像頭 } CameraCharacteristics.LENS_FACING_BACK -> { // 後置攝像頭 } CameraCharacteristics.LENS_FACING_EXTERNAL -> { // 外接攝像頭 } }
CameraCharacteristics 有大量的 Key 定義,這裡就不一一闡述,當你在開發過程中需要獲取某些相機資訊的時候再去查閱 API文件即可。
由於不同廠商對相機的實現都會有差異,所以很多引數在不同的手機上支援的情況也不一樣,相機的預覽尺寸也是,所以接下來我們就要通過 CameraCharacteristics 獲取相機支援的預覽尺寸列表。所謂的預覽尺寸,指的就是相機把畫面輸出到手機螢幕上供使用者預覽的尺寸,通常來說我們希望預覽尺寸在不超過手機螢幕解析度的情況下,越大越好。另外,出於業務需求,我們的相機可能需要支援多種不同的預覽比例供使用者選擇,例如 4:3 和 16:9 的比例。由於不同廠商對相機的實現都會有差異,所以很多引數在不同的手機上支援的情況也不一樣,相機的預覽尺寸也是。所以在設定相機預覽尺寸之前,我們先通過 CameraCharacteristics 獲取該裝置支援的所有預覽尺寸:
val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val supportedSizes = streamConfigurationMap?.getOutputSizes(SurfaceTexture::class.java)
從上面的程式碼可以看出預覽尺寸列表並不是直接從 CameraCharacteristics 獲取的,而是先通過 SCALER_STREAM_CONFIGURATION_MAP
獲取 StreamConfigurationMap 物件,然後通過 StreamConfigurationMap.getOutputSizes()
方法獲取尺寸列表,該方法會要求你傳遞一個 Class 型別,然後根據這個型別返回對應的尺寸列表,如果給定的型別不支援,則返回 null,你可以通過 StreamConfigurationMap.isOutputSupportedFor()
方法判斷某一個型別是否被支援,常見的型別有:
- ImageReader:常用來拍照或接收 YUV 資料。
- MediaRecorder:常用來錄製視訊。
- MediaCodec:常用來錄製視訊。
- SurfaceHolder:常用來顯示預覽畫面。
- SurfaceTexture:常用來顯示預覽畫面。
由於我們使用的是 SurfaceTexture,所以顯然這裡我們就要傳遞 SurfaceTexture.class 獲取支援的尺寸列表。如果我們把所有的預覽尺寸都打印出來看時,會發現一個比較特別的情況,就是預覽尺寸的寬是長邊,高是短邊,例如 1920x1080,而不是 1080x1920,這是因為相機 Sensor 的寬是長邊,而高是短邊。
在獲取到預覽尺寸列表之後,我們要根據自己的實際需求過濾出其中一個最符合要求的尺寸,並且把它設定給相機,在我們的 Demo 裡,只有當預覽尺寸的比例和大小都滿足要求時才能被設定給相機,如下所示:
@WorkerThread private fun getOptimalSize(cameraCharacteristics: CameraCharacteristics, clazz: Class<*>, maxWidth: Int, maxHeight: Int): Size? { val aspectRatio = maxWidth.toFloat() / maxHeight val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val supportedSizes = streamConfigurationMap?.getOutputSizes(clazz) if (supportedSizes != null) { for (size in supportedSizes) { if (size.width.toFloat() / size.height == aspectRatio && size.height <= maxHeight && size.width <= maxWidth) { return size } } } return null }
2 配置預覽尺寸
在獲取適合的預覽尺寸之後,接下來就是配置預覽尺寸使其生效了。在配置尺寸方面,Camera2 和 Camera1 有著很大的不同,Camera1 是將所有的尺寸資訊都設定給相機,而 Camera2 則是把尺寸資訊設定給 Surface,例如接收預覽畫面的 SurfaceTexture,或者是接收拍照圖片的 ImageReader,相機在輸出影象資料的時候會根據 Surface 配置的 Buffer 大小輸出對應尺寸的畫面。
獲取 Surface 的方式有很多種,可以通過 TextureView、SurfaceView、ImageReader 甚至是通過 OpenGL 建立,這裡我們要將預覽畫面顯示在螢幕上,所以我們選擇了 TextureView,並且通過 TextureView.SurfaceTextureListener
回撥介面監聽 SurfaceTexture 的狀態,在獲取可用的 SurfaceTexture 物件之後通過 SurfaceTexture.setDefaultBufferSize()
設定預覽畫面的尺寸,最後使用 Surface(SurfaceTexture)
構造方法創建出預覽的 Surface 物件:
首先,我們在佈局檔案中新增一個 TextureView,並給它取個 ID 叫 camera_preview:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <TextureView android:id="@+id/camera_preview" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
然後我們在 Activity 裡獲取 TextureView 物件,並且註冊一個 TextureView.SurfaceTextureListener
用於監聽 SurfaceTexture 的狀態:
private inner class PreviewSurfaceTextureListener : TextureView.SurfaceTextureListener { @MainThread override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) = Unit @MainThread override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit @MainThread override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean = false @MainThread override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { previewSurfaceTexture = surfaceTexture } }
cameraPreview = findViewById<CameraPreview>(R.id.camera_preview) cameraPreview.surfaceTextureListener = PreviewSurfaceTextureListener()
當 SurfaceTexture 可用的時候會回撥 onSurfaceTextureAvailable()
方法並且把 SurfaceTexture 物件和尺寸傳遞給我們,此時我們要做的就是通過 SurfaceTexture.setDefaultBufferSize()
設定預覽畫面的尺寸並且建立 Surface 物件:
val previewSize = getOptimalSize(cameraCharacteristics, SurfaceTexture::class.java, width, height)!! previewSurfaceTexture.setDefaultBufferSize(previewSize.width, previewSize.height) previewSurface = Surface(previewSurfaceTexture)
到這裡,用於預覽的 Surface 就準備好了,接下來我們來看下如何建立 CameraCaptureSession。
3 建立 CameraCaptureSession
用於接收預覽畫面的 Surface 準備就緒了,接了下來我們要使用這個 Surface 建立一個 CameraCaptureSession 例項,涉及的方法是 CameraDevice.createCaptureSession()
,該方法要求你傳遞以下三個引數:
- outputs :所有用於接收影象資料的 Surface,例如本章用於接收預覽畫面的 Surface,後續還會有用於拍照的 Surface,這些 Surface 必須在建立 Session 之前就準備好,並且在建立 Session 的時候傳遞給底層用於配置 Pipeline。
- callback :用於監聽 Session 狀態的
CameraCaptureSession.StateCallback
物件,就如同開關相機一樣,建立和銷燬 Session 也需要我們註冊一個狀態監聽器。 - handler :用於執行
CameraCaptureSession.StateCallback
的 Handler 物件,可以是非同步執行緒的 Handler,也可以是主執行緒的 Handler,在我們的 Demo 裡使用的是主執行緒 Handler。
private inner class SessionStateCallback : CameraCaptureSession.StateCallback() { @MainThread override fun onConfigureFailed(session: CameraCaptureSession) { } @MainThread override fun onConfigured(session: CameraCaptureSession) { } @MainThread override fun onClosed(session: CameraCaptureSession) { } }
val sessionStateCallback = SessionStateCallback() val outputs = listOf(previewSurface) cameraDevice.createCaptureSession(outputs, sessionStateCallback, mainHandler)
4 建立 CaptureRequest
在介紹如何開啟和關閉預覽之前,我們有必要先介紹下 CaptureRequest,因為它是我們執行任何相機操作都繞不開的核心類,因為 CaptureRequest 是向 CameraCaptureSession 提交 Capture 請求時的資訊載體,其內部包括了本次 Capture 的引數配置和接收影象資料的 Surface。CaptureRequest 可以配置的資訊非常多,包括影象格式、影象解析度、感測器控制、閃光燈控制、3A 控制等等,可以說絕大部分的相機引數都是通過 CaptureRequest 配置的。我們可以通過 CameraDevice.createCaptureRequest()
方法建立一個 CaptureRequest.Builder 物件,該方法只有一個引數 templateType 用於指定使用何種模板建立 CaptureRequest.Builder 物件。因為 CaptureRequest 可以配置的引數實在是太多了,如果每一個引數都要我們手動去配置,那真的是既複雜又費時,所以 Camera2 根據使用場景的不同,為我們事先配置好了一些常用的引數模板:
- TEMPLATE_PREVIEW:適用於配置預覽的模板。
- TEMPLATE_RECORD:適用於視訊錄製的模板。
- TEMPLATE_STILL_CAPTURE:適用於拍照的模板。
- TEMPLATE_VIDEO_SNAPSHOT:適用於在錄製視訊過程中支援拍照的模板。
- TEMPLATE_MANUAL:適用於希望自己手動配置大部分引數的模板。
這裡我們要建立一個用於預覽的 CaptureRequest,所以傳遞了 TEMPLATE_PREVIEW 作為引數:
val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
一個 CaptureRequest 除了需要配置很多引數之外,還要求至少配置一個 Surface(任何相機操作的本質都是為了捕獲影象),並且配置的 Surface 必須屬於建立 Session 時新增的那些 Surface,涉及的方法是 CaptureRequest.Builder.addTarget()
,你可以多次呼叫該方法新增多個 Surface。
requestBuilder.addTarget(previewSurface)
最後,我們通過 CaptureRequest.Builder.build()
方法創建出一個只讀的 CaptureRequest 例項:
val request = requestBuilder.build()
5 開啟和停止預覽
在 Camera2 裡,預覽本質上是不斷重複執行的 Capture 操作,每一次 Capture 都會把預覽畫面輸出到對應的 Surface 上,涉及的方法是 CameraCaptureSession.setRepeatingRequest()
,該方法有三個引數:
- request :在不斷重複執行 Capture 時使用的 CaptureRequest 物件。
- callback :監聽每一次 Capture 狀態的
CameraCaptureSession.CaptureCallback
物件,例如onCaptureStarted()
意味著一次 Capture 的開始,而onCaptureCompleted()
意味著一次 Capture 的結束。 - hander :用於執行
CameraCaptureSession.CaptureCallback
的 Handler 物件,可以是非同步執行緒的 Handler,也可以是主執行緒的 Handler,在我們的 Demo 裡使用的是主執行緒 Handler。
瞭解了核心方法之後,開啟預覽的操作就很顯而易見了:
val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) requestBuilder.addTarget(previewSurface) val request = requestBuilder.build() captureSession.setRepeatingRequest(request, RepeatingCaptureStateCallback(), mainHandler)
如果要關閉預覽的話,可以通過 CameraCaptureSession.stopRepeating()
停止不斷重複執行的 Capture 操作:
captureSession.stopRepeating()
到目前為止,如果一切正常的話,預覽畫面應該就已經顯示出來了。
6 適配預覽比例
前面我們使用了一個佔滿螢幕的 TextureView 來顯示預覽畫面,並且預覽尺寸我們選擇了 4:3 的比例,你很可能會看到預覽畫面變形的情況,這因為 Surface 的比例和 TextureView 的比例不一致導致的,你可以想象 Surface 就是一張圖片,TextureView 就是 ImageView,將 4:3 的圖片顯示在 16:9 的 ImageView 上必然會出現畫面拉伸變形的情況:

預覽畫面變形
所以接下來我們要學習的是如何適配不同的預覽比例。預覽比例的適配有多種方式:
- 根據預覽比例修改 TextureView 的寬高,比如使用者選擇了 4:3 的預覽比例,這個時候我們會選取 4:3 的預覽尺寸並且把 TextureView 修改成 4:3 的比例,從而讓畫面不會變形。
- 使用固定的預覽比例,然後根據比例去選取適合的預覽尺寸,例如固定 4:3 的比例,選擇 1440x1080 的尺寸,並且把 TextureView 的寬高也設定成 4:3。
- 固定 TextureView 的寬高,然後根據預覽比例使用
TextureView.setTransform()
方法修改預覽畫面繪製在 TextureView 上的方式,從而讓預覽畫面不變形,這跟ImageView.setImageMatrix()
如出一轍。
簡單來說,解決預覽畫面變形的問題,本質上就是解決畫面和畫布比例不一致的問題。在我們的 Demo 中,出於簡化的目的,我們選擇了第二種方式適配比例,因為這種方式實現起來比較簡單,所以我們會寫一個自定義的 TextureView,讓它的比例固定是 4:3,它的寬度固定填滿父佈局,高度根據比例動態計算:
class CameraPreview @JvmOverloads constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int = 0) : TextureView(context, attrs, defStyleAttr) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val width = MeasureSpec.getSize(widthMeasureSpec) setMeasuredDimension(width, width / 3 * 4) } }
7 認識 ImageReader
在 Camera2 裡,ImageReader 是我們一個非常重要的獲取影象資料的途徑,我們可以通過它獲取各種各樣格式的影象資料,例如 JPEG、YUV 和 RAW 等等。我們可以通過 ImageReader.newInstance()
方法建立一個 ImageReader 物件,該方法要求我們傳遞以下四個引數:
- width :影象資料的寬度。
- height :影象資料的高度。
- format :影象資料的格式,定義在 ImageFormat 裡,例如
ImageFormat.YUV_420_888
。 - maxImages :最大 Image 個數,可以理解成 Image 物件池的大小。
當有影象資料生成的時候,ImageReader 會通過通過 ImageReader.OnImageAvailableListener.onImageAvailable()
方法通知我們,然後我們可以呼叫 ImageReader.acquireNextImage()
方法獲取存有最新資料的 Image 物件,而在 Image 物件裡影象資料又根據不同格式被劃分多個部分分別儲存在單獨的 Plane 物件裡,我們可以通過呼叫 Image.getPlanes()
方法獲取所有的 Plane 物件的陣列,最後通過 Plane.getBuffer()
獲取每一個 Plane 裡儲存的影象資料。以 YUV 資料為例,當有 YUV 資料生成的時候,資料會被分成 Y、U、V 三部分分別儲存到 Plane 裡,如下圖所示:

override fun onImageAvailable(imageReader: ImageReader) { val image = imageReader.acquireNextImage() if (image != null) { val planes = image.planes val yPlane = planes[0] val uPlane = planes[1] val vPlane = planes[2] val yBuffer = yPlane.buffer // Data from Y channel val uBuffer = uPlane.buffer // Data from U channel val vBuffer = vPlane.buffer // Data from V channel } image?.close() }
上面的程式碼是獲取 YUV 資料的流程,特別要注意的是最後一步呼叫 Image.close()
方法十分重要,當我們不再需要使用某一個 Image 物件的時候記得通過該方法釋放資源,因為 Image 物件實際上來自於一個建立 ImageReader 時就確定大小的物件池,如果我們不釋放它的話就會導致物件池很快就被耗光,並且丟擲一個異常。類似的的當我們不再需要使用 某一個 ImageReader 物件的時候,也要記得呼叫 ImageReader.close()
方法釋放資源。
8 獲取預覽資料
介紹完 ImageReader 之後,接下來我們就來建立一個接收每一幀預覽資料的 ImageReader,並且資料格式為 YUV_420_888。首先,我們要先判斷 YUV_420_888 資料格式是否支援,所以會有如下的程式碼:
val imageFormat = ImageFormat.YUV_420_888 val streamConfigurationMap = cameraCharacteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP] if (streamConfigurationMap?.isOutputSupportedFor(imageFormat) == true) { // YUV_420_888 is supported }
接著,我們使用前面已經確定好的預覽尺寸建立一個 ImageReader,並且註冊一個 ImageReader.OnImageAvailableListener
用於監聽資料的更新,最後通過 ImageReader.getSurface()
方法獲取接收預覽資料的 Surface:
val imageFormat = ImageFormat.YUV_420_888 val streamConfigurationMap = cameraCharacteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP] if (streamConfigurationMap?.isOutputSupportedFor(imageFormat) == true) { previewDataImageReader = ImageReader.newInstance(previewSize.width, previewSize.height, imageFormat, 3) previewDataImageReader?.setOnImageAvailableListener(OnPreviewDataAvailableListener(), cameraHandler) previewDataSurface = previewDataImageReader?.surface }
建立完 ImageReader,並且獲取它的 Surface 之後,我們就可以在建立 Session 的時候新增這個 Surface 告訴 Pipeline 我們有一個專門接收 YUV_420_888 的 Surface:
val sessionStateCallback = SessionStateCallback() val outputs = mutableListOf<Surface>() val previewSurface = previewSurface val previewDataSurface = previewDataSurface outputs.add(previewSurface!!) if (previewDataSurface != null) { outputs.add(previewDataSurface) } cameraDevice.createCaptureSession(outputs, sessionStateCallback, mainHandler)
獲取預覽資料和顯示預覽畫面一樣都是不斷重複執行的 Capture 操作,所以我們只需要在開始預覽的時候通過 CaptureRequest.Builder.addTarget()
方法新增接收預覽資料的 Surface 即可,所以一個 CaptureRequest
會有兩個 Surface,一個現實預覽畫面的 Surface,一個接收預覽資料的 Surface:
val requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) val previewSurface = previewSurface val previewDataSurface = previewDataSurface requestBuilder.addTarget(previewSurface!!) if (previewDataSurface != null) { requestBuilder.addTarget(previewDataSurface) } val request = requestBuilder.build() captureSession.setRepeatingRequest(request, RepeatingCaptureStateCallback(), mainHandler)
在開始預覽之後,每一次重新整理預覽畫面的時候,都會通過 ImageReader.OnImageAvailableListener.onImageAvailable()
方法通知我們:
private inner class OnPreviewDataAvailableListener : ImageReader.OnImageAvailableListener { /** * Called every time the preview frame data is available. */ override fun onImageAvailable(imageReader: ImageReader) { val image = imageReader.acquireNextImage() if (image != null) { val planes = image.planes val yPlane = planes[0] val uPlane = planes[1] val vPlane = planes[2] val yBuffer = yPlane.buffer // Data from Y channel val uBuffer = uPlane.buffer // Data from U channel val vBuffer = vPlane.buffer // Data from V channel } image?.close() } }
9 如何矯正影象資料的方向
如果你熟悉 Camera1 的話,也許已經發行了一個問題,就是 Camera2 不需要經過任何預覽畫面方向的矯正,就可以正確現實畫面,而 Camera1 則需要根據攝像頭感測器的方向進行預覽畫面的方向矯正。其實,Camera2 也需要進行預覽畫面的矯正,只不過系統幫我們做了而已,當我們使用 TextureView 或者 SurfaceView 進行畫面預覽的時候,系統會根據【裝置自然方向】、【攝像感測器方向】和【顯示方向】自動矯正預覽畫面的方向,並且該矯正規則只適用於顯示方向和和裝置自然方向一致的情況下,舉個例子,當我們把手機橫放並且允許自動旋轉螢幕的時候,看到的預覽畫面的方向就是錯誤的。此外,當我們使用一個 GLSurfaceView 顯示預覽畫面或者使用 ImageReader 接收影象資料的時候,系統都不會進行畫面的自動矯正,因為它不知道我們要如何顯示預覽畫面,所以我們還是有必要學習下如何矯正影象資料的方向,在介紹如何矯正影象資料方向之前,我們需要先了解幾個概念,它們分別是【裝置自然方向】、【區域性座標系】、【顯示方向】和【攝像頭感測器方向】。
9.1 裝置方向
當我們談論方向的時候,實際上都是相對於某一個 0° 方向的角度,這個 0° 方向被稱作自然方向,例如人站立的時候就是自然方向,你總不會認為一個人要倒立的時候才是自然方向吧,而接下來我們要談論的裝置方向就有的自然方向的定義。
裝置方向指的是硬體裝置在空間中的方向與其自然方向的順時針夾角。這裡提到的自然方向指的就是我們手持一個裝置的時候最習慣的方向,比如手機我們習慣豎著拿,而平板我們則習慣橫著拿,所以通常情況下手機的自然方向就是豎著的時候,平板的自然方向就是橫著的時候。

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

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

裝置方向 90°
9.3 顯示方向
顯示方向指的是螢幕上顯示畫面與區域性座標系 y 軸的順時針夾角。
為了更清楚的說明這個概念,我們舉一個例子,假設我們將手機向右橫放看電影,此時畫面是朝上的,如下圖所示:

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

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

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

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

總結一下,在校正畫面方向的時候要同時考慮兩個因素,即攝像頭感測器方向和顯示方向。接下來我們要回到我們的相機應用裡,看看通過程式碼是如何實現預覽畫面方向校正的。
如果你有自己看過 Camera 的官方 API 文件,你會發現官方已經給我們寫好了一個同時考慮顯示方向和攝像頭感測器方向的方法,我把它翻譯成 Kotlin 語法:
private fun getDisplayRotation(cameraCharacteristics: CameraCharacteristics): Int { val rotation = windowManager.defaultDisplay.rotation val degrees = when (rotation) { Surface.ROTATION_0 -> 0 Surface.ROTATION_90 -> 90 Surface.ROTATION_180 -> 180 Surface.ROTATION_270 -> 270 else -> 0 } val sensorOrientation = cameraCharacteristics[CameraCharacteristics.SENSOR_ORIENTATION]!! return if (cameraCharacteristics[CameraCharacteristics.LENS_FACING] == CameraCharacteristics.LENS_FACING_FRONT) { (360 - (sensorOrientation + degrees) % 360) % 360 } else { (sensorOrientation - degrees + 360) % 360 } }
如果你已經完全理解前面介紹的那些角度的概念,那你應該很容易就能理解上面這段程式碼,實際上就是通過 WindowManager 獲取當前的顯示方向,然後再參照攝像頭感測器方向以及是否是前後置,最後計算出我們實際要旋轉的角度。