Camera2 教程 · 第四章 · 拍照

Android Camera
上一章《Camera2 預覽》我們學習瞭如何配置預覽,接下來我們來學習如何拍照。
閱讀完本章,你將會學到以下幾個知識點:
- 理解 Capture 工作流程
- 如何拍攝單張照片
- 如何連續拍攝多張照片
- 如何連拍照片
- 如何配置縮圖尺寸
- 如何播放快門音效
- 如何矯正圖片方向
- 如何切換前後置攝像頭
你可以在 https://github.com/darylgo/Camera2Sample 下載相關的原始碼,並且切換到 Tutorial4 標籤下。
1 理解 Capture 工作流程
在正式介紹如何拍照之前,我們有必要深入理解幾種不同模式的 Capture 的工作流程,只要理解它們的工作流程就很容易掌握各種拍照模式的實現原理,在第一章《Camera2 概覽》 裡我們介紹了 Capture 有以下幾種不同模式:
-
單次模式(One-shot):指的是隻執行一次的 Capture 操作,例如設定閃光燈模式、對焦模式和拍一張照片等。多個單次模式的 Capture 會進入佇列按順序執行。
-
多次模式(Burst):指的是連續多次執行指定的 Capture 操作,該模式和多次執行單次模式的最大區別是連續多次 Capture 期間不允許插入其他任何 Capture 操作,例如連續拍攝 100 張照片,在拍攝這 100 張照片期間任何新的 Capture 請求都會排隊等待,直到拍完 100 張照片。多組多次模式的 Capture 會進入佇列按順序執行。
-
重複模式(Repeating):指的是不斷重複執行指定的 Capture 操作,當有其他模式的 Capture 提交時會暫停該模式,轉而執行其他被模式的 Capture,當其他模式的 Capture 執行完畢後又會自動恢復繼續執行該模式的 Capture,例如顯示預覽畫面就是不斷 Capture 獲取每一幀畫面。該模式的 Capture 是全域性唯一的,也就是新提交的重複模式 Capture 會覆蓋舊的重複模式 Capture。
我們舉個例子來進一步說明上面三種模式,假設我們的相機應用程式開啟了預覽,所以會提交一個重複模式的 Capture 用於不斷獲取預覽畫面,然後我們提交一個單次模式的 Capture,接著我們又提交了一組連續三次的多次模式的 Capture,這些不同模式的 Capture 會按照下圖所示被執行:

Capture 工作原理
下面是幾個重要的注意事項:
-
無論 Capture 以何種模式被提交,它們都是按順序序列執行的,不存在並行執行的情況。
-
重複模式是一個比較特殊的模式,因為它會保留我們提交的 CaptureRequest 物件用於不斷重複執行 Capture 操作,所以大多數情況下重複模式的 CaptureRequest 和其他模式的 CaptureRequest 是獨立的,這就會導致重複模式的引數和其他模式的引數會有一定的差異,例如重複模式不會配置
CaptureRequest.AF_TRIGGER_START
,因為這會導致相機不斷觸發對焦的操作。 -
如果某一次的 Capture 沒有配置預覽的 Surface,例如拍照的時候,就會導致本次 Capture 不會將畫面輸出到預覽的 Surface 上,進而導致預覽畫面卡頓的情況,所以大部分情況下我們都會將預覽的 Surface 新增到所有的 CaptureRequest 裡。
2 如何拍攝單張照片
拍攝單張照片是最簡單的拍照模式,它使用的就是 單次模式 的 Capture,我們會使用 ImageReader 建立一個接收照片的 Surface,並且把它新增到 CaptureRequest 裡提交給相機進行拍照,最後通過 ImageReader 的回撥獲取 Image 物件,進而獲取 JPEG 影象資料進行儲存。
2.1 定義回撥介面
當拍照完成的時候我們會得到兩個資料物件,一個是通過 onImageAvailable()
回撥給我們的儲存影象資料的 Image,一個是通過 onCaptureCompleted()
回撥給我們的儲存拍照資訊的 CaptureResult,它們是一一對應的,所以我們定義瞭如下兩個回撥介面:
private val captureResults: BlockingQueue<CaptureResult> = LinkedBlockingDeque() private inner class CaptureImageStateCallback : CameraCaptureSession.CaptureCallback() { @MainThread override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) { super.onCaptureCompleted(session, request, result) captureResults.put(result) } }
private inner class OnJpegImageAvailableListener : ImageReader.OnImageAvailableListener { @WorkerThread override fun onImageAvailable(imageReader: ImageReader) { val image = imageReader.acquireNextImage() val captureResult = captureResults.take() if (image != null && captureResult != null) { // Save image into sdcard. } } }
2.2 建立 ImageReader
建立 ImageReader 需要我們指定照片的大小,所以首先我們要獲取支援的照片尺寸列表,並且從中篩選出合適的尺寸,假設我們要求照片的尺寸最大不能超過 4032x3024,並且比例必須是 4:3,所以會有如下篩選尺寸的程式碼片段:
@WorkerThread private fun getOptimalSize(cameraCharacteristics: CameraCharacteristics, clazz: Class<*>, maxWidth: Int, maxHeight: Int): Size? { val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val supportedSizes = streamConfigurationMap?.getOutputSizes(clazz) return getOptimalSize(supportedSizes, maxWidth, maxHeight) } @AnyThread private fun getOptimalSize(supportedSizes: Array<Size>?, maxWidth: Int, maxHeight: Int): Size? { val aspectRatio = maxWidth.toFloat() / maxHeight if (supportedSizes != null) { for (size in supportedSizes) { if (size.width.toFloat() / size.height == aspectRatio && size.height <= maxHeight && size.width <= maxWidth) { return size } } } return null }
接著我們就可以篩選出合適的尺寸,然後建立一個影象格式是 JPEG 的 ImageReader 物件,並且獲取它的 Surface:
val imageSize = getOptimalSize(cameraCharacteristics, ImageReader::class.java, maxWidth, maxHeight)!! jpegImageReader = ImageReader.newInstance(imageSize.width, imageSize.height, ImageFormat.JPEG, 5) jpegImageReader?.setOnImageAvailableListener(OnJpegImageAvailableListener(), cameraHandler) jpegSurface = jpegImageReader?.surface
2.3 建立 CaptureRequest
接下來我們使用 TEMPLATE_STILL_CAPTURE
模板建立一個用於拍照的 CaptureRequest.Builder 物件,並且新增拍照的 Surface 和預覽的 Surface 到其中:
captureImageRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE) captureImageRequestBuilder.addTarget(previewDataSurface) captureImageRequestBuilder.addTarget(jpegSurface)
你可能會疑問為什麼拍照用的 CaptureRequest 物件需要新增預覽的 Surface,這一點我們在前面有解釋過了,如果某一次的 Capture 沒有配置預覽的 Surface,例如拍照的時候,就會導致本次 Capture 不會將畫面輸出到預覽的 Surface 上,進而導致預覽畫面卡頓的情況,所以大部分情況下我們都會將預覽的 Surface 新增到所有的 CaptureRequest 裡。
2.4 矯正 JPEG 圖片方向
在《Camera2 預覽》 裡我們介紹了一些方向的概念,也提到了攝像頭感測器的方向很多時候都不是 0°,這就會導致我們拍出來的照片方向是錯誤的,例如手機攝像頭感測器方向是 90° 的時候,垂直拿著手機拍出來的照片很可能是橫著的:

在進行圖片方向矯正的時候,我們的目的是做到所見即所得,也就是使用者在預覽畫面裡看到的是什麼樣,輸出的圖片就是什麼樣。為了做到圖片所見即所得,我們要同時考慮裝置方向和攝像頭感測器方向,下面是一段來自官方的圖片矯正程式碼:
private fun getJpegOrientation(cameraCharacteristics: CameraCharacteristics, deviceOrientation: Int): Int { var myDeviceOrientation = deviceOrientation if (myDeviceOrientation == android.view.OrientationEventListener.ORIENTATION_UNKNOWN) { return 0 } val sensorOrientation = cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!! // Round device orientation to a multiple of 90 myDeviceOrientation = (myDeviceOrientation + 45) / 90 * 90 // Reverse device orientation for front-facing cameras val facingFront = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT if (facingFront) { myDeviceOrientation = -myDeviceOrientation } // Calculate desired JPEG orientation relative to camera orientation to make // the image upright relative to the device orientation return (sensorOrientation + myDeviceOrientation + 360) % 360 }
如果你已經理解《Camera2 預覽》 裡我們介紹的一些方向概念,那麼上面這段程式碼其實就很容易理解,唯一特別的地方是前置攝像頭輸出的畫面底層預設做了映象的翻轉才能保證我們在預覽的時候看到的畫面就想照鏡子一樣,所以前置攝像頭給的 SENSOR_ORIENTATION 值也是經過映象的,但是相機在輸出 JPEG 的時候並沒有進行映象操作,所以在計算 JPEG 矯正角度的時候要對這個預設映象的操作進行逆向映象。
計算出圖片的矯正角度後,我們要通過 CaptureRequest.JPEG_ORIENTATION
配置這個角度,相機在拍照輸出 JPEG 影象的時候會參考這個角度值從以下兩種方式選一種進行影象方向矯正:
- 直接對影象進行旋轉,並且將 Exif 的 ORIENTATION 標籤賦值為 0。
- 不對影象進行旋轉,而是將旋轉資訊寫入 Exif 的 ORIENTATION 標籤裡。
客戶端在顯示圖片的時候一定要去檢查 Exif 的ORIENTATION 標籤的值,並且根據這個值對圖片進行對應角度的旋轉才能保證圖片顯示方向是正確的。
val deviceOrientation = deviceOrientationListener.orientation val jpegOrientation = getJpegOrientation(cameraCharacteristics, deviceOrientation) captureImageRequestBuilder[CaptureRequest.JPEG_ORIENTATION] = jpegOrientation
2.5 設定縮圖尺寸
相機在輸出 JPEG 圖片的時候,同時會根據我們通過 CaptureRequest.JPEG_THUMBNAIL_SZIE
配置的縮圖尺寸生成一張縮圖寫入圖片的 Exif 資訊裡。在設定縮圖尺寸之前,我們首先要獲取相機支援哪些縮圖尺寸,與獲取預覽尺寸或照片尺寸列表方式不一樣的是,縮圖尺寸列表是直接通過 CameraCharacteristics.JPEG_AVAILABLE_THUMBNAIL_SIZES
獲取的。配置縮圖尺寸的程式碼如下所示:
val availableThumbnailSizes = cameraCharacteristics[CameraCharacteristics.JPEG_AVAILABLE_THUMBNAIL_SIZES] val thumbnailSize = getOptimalSize(availableThumbnailSizes, maxWidth, maxHeight)
在獲取圖片縮圖的時候,我們不能總是假設圖片一定會在 Exif 寫入縮圖,當 Exif 裡面沒有縮圖資料的時候,我們要轉而直接 Decode 原圖獲取縮圖,另外無論是原圖還是縮圖,都要根據 Exif 的 ORIENTATION 角度進行角度矯正才能正確顯示,下面是我們 Demo 中獲取圖片縮圖的程式碼:
@WorkerThread private fun getThumbnail(jpegPath: String): Bitmap? { val exifInterface = ExifInterface(jpegPath) val orientationFlag = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = when (orientationFlag) { ExifInterface.ORIENTATION_NORMAL -> 0.0F ExifInterface.ORIENTATION_ROTATE_90 -> 90.0F ExifInterface.ORIENTATION_ROTATE_180 -> 180.0F ExifInterface.ORIENTATION_ROTATE_270 -> 270.0F else -> 0.0F } var thumbnail = if (exifInterface.hasThumbnail()) { exifInterface.thumbnailBitmap } else { val options = BitmapFactory.Options() options.inSampleSize = 16 BitmapFactory.decodeFile(jpegPath, options) } if (orientation != 0.0F && thumbnail != null) { val matrix = Matrix() matrix.setRotate(orientation) thumbnail = Bitmap.createBitmap(thumbnail, 0, 0, thumbnail.width, thumbnail.height, matrix, true) } return thumbnail }
2.6 設定定位資訊
拍照的時候,通常都會在圖片的 Exif 寫入定位資訊,我們可以通過 CaptureRequest.JPEG_GPS_LOCATION
配置定位資訊,程式碼如下:
@WorkerThread private fun getLocation(): Location? { val locationManager = getSystemService(LocationManager::class.java) if (locationManager != null && ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { return locationManager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER) } return null }
val location = getLocation() captureImageRequestBuilder[CaptureRequest.JPEG_GPS_LOCATION] = location
2.7 播放快門音效
在進行拍照之前,我們還需要配置拍照時播放的快門音效,因為 Camera2 和 Camera1 不一樣,拍照時不會有任何聲音,需要我們在適當的時候通過 MediaSoundPlayer 播放快門音效,通常情況我們是在 CaptureStateCallback.onCaptureStarted()
回撥的時候播放快門音效:
private val mediaActionSound: MediaActionSound = MediaActionSound() private inner class CaptureImageStateCallback : CameraCaptureSession.CaptureCallback() { @MainThread override fun onCaptureStarted(session: CameraCaptureSession, request: CaptureRequest, timestamp: Long, frameNumber: Long) { super.onCaptureStarted(session, request, timestamp, frameNumber) // Play the shutter click sound. cameraHandler?.post { mediaActionSound.play(MediaActionSound.SHUTTER_CLICK) } } @MainThread override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) { super.onCaptureCompleted(session, request, result) captureResults.put(result) } }
2.8 拍照並儲存圖片
經過一連串的配置之後,我們終於可以開拍照了,直接呼叫 CameraCaptureSession.capture()
方法把 CaptureRequest 物件提交給相機就可以等待相機輸出圖片了,該方法要求我們設定三個引數:
- request :本次 Capture 操作使用的 CaptureRequest 物件。
- listener :監聽 Capture 狀態的回撥介面。
- handler :回撥 Capture 狀態監聽介面的 Handler 物件。
captureSession.capture(captureImageRequest, CaptureImageStateCallback(), mainHandler)
如果一切順利,相機在拍照完成的時候會通過 CaptureStateCallback.onCaptureCompleted()
回撥一個 CaptureResult 物件給我們,裡面包含了本次拍照的所有資訊,另外還會通過 OnImageAvailableListener.onImageAvailable()
回撥一個代表影象資料的 Image 物件給我們。在我們的 Demo 中,我們將獲取到的 CaptureResult 物件儲存到一個阻塞佇列中,在 OnImageAvailableListener.onImageAvailable()
回撥的時候就從這個阻塞佇列獲取 CaptureResult 物件,結合 Image 物件對圖片進行儲存操作,並且還會在圖片儲存完畢的時候獲取圖片的縮圖用於重新整理 UI,程式碼如下所示:
private inner class OnJpegImageAvailableListener : ImageReader.OnImageAvailableListener { private val dateFormat: DateFormat = SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault()) private val cameraDir: String = "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)}/Camera" @WorkerThread override fun onImageAvailable(imageReader: ImageReader) { val image = imageReader.acquireNextImage() val captureResult = captureResults.take() if (image != null && captureResult != null) { image.use { val jpegByteBuffer = it.planes[0].buffer// Jpeg image data only occupy the planes[0]. val jpegByteArray = ByteArray(jpegByteBuffer.remaining()) jpegByteBuffer.get(jpegByteArray) val width = it.width val height = it.height saveImageExecutor.execute { val date = System.currentTimeMillis() val title = "IMG_${dateFormat.format(date)}"// e.g. IMG_20190211100833786 val displayName = "$title.jpeg"// e.g. IMG_20190211100833786.jpeg val path = "$cameraDir/$displayName"// e.g. /sdcard/DCIM/Camera/IMG_20190211100833786.jpeg val orientation = captureResult[CaptureResult.JPEG_ORIENTATION] val location = captureResult[CaptureResult.JPEG_GPS_LOCATION] val longitude = location?.longitude ?: 0.0 val latitude = location?.latitude ?: 0.0 // Write the jpeg data into the specified file. File(path).writeBytes(jpegByteArray) // Insert the image information into the media store. val values = ContentValues() values.put(MediaStore.Images.ImageColumns.TITLE, title) values.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, displayName) values.put(MediaStore.Images.ImageColumns.DATA, path) values.put(MediaStore.Images.ImageColumns.DATE_TAKEN, date) values.put(MediaStore.Images.ImageColumns.WIDTH, width) values.put(MediaStore.Images.ImageColumns.HEIGHT, height) values.put(MediaStore.Images.ImageColumns.ORIENTATION, orientation) values.put(MediaStore.Images.ImageColumns.LONGITUDE, longitude) values.put(MediaStore.Images.ImageColumns.LATITUDE, latitude) contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) // Refresh the thumbnail of image. val thumbnail = getThumbnail(path) if (thumbnail != null) { runOnUiThread { thumbnailView.setImageBitmap(thumbnail) thumbnailView.scaleX = 0.8F thumbnailView.scaleY = 0.8F thumbnailView.animate().setDuration(50).scaleX(1.0F).scaleY(1.0F).start() } } } } } } }
2.9 前置攝像頭拍照的映象問題
如果你使用前置攝像頭進行拍照,雖然照片的方向已經被我們矯正了,但是你會發現畫面卻是相反的,例如你在預覽的時候人臉在左邊,拍出來的照片人臉卻是在右邊。出現這個問題的原因是預設情況下相機不會對 JPEG 影象進行映象操作,導致輸出的原始畫面是非映象的。解決這個問題的一個辦法是拿到 JPEG 資料之後再次對影象進行映象操作,然後才儲存圖片。
3 如何連續拍攝多張圖片
在我們的 Demo 中有一個特殊的拍照功能,就是當用戶雙擊快門按鈕的時候會連續拍攝 10 張照片,其實現原理就是採用了 多次模式 的 Capture,所有的配置流程和拍攝單張照片一樣,唯一的區別是我們使用 CameraCaptureSession.captureBurst()
進行拍照,該方法要求我們傳遞一下三個引數:
- requests :按順序連續執行的 CaptureRequest 物件列表,每一個 CaptureRequest 物件都可以有自己的配置,在我們的 Demo 裡出於簡化的目的,10 個 CaptureRequest 物件實際上的都是同一個。
- listener :監聽 Capture 狀態的回撥介面,需要注意的是有多少個 CaptureRequest 物件就會回撥該介面多少次。
- handler :回撥 Capture 狀態監聽介面的 Handler 物件。
val captureImageRequest = captureImageRequestBuilder.build() val captureImageRequests = mutableListOf<CaptureRequest>() for (i in 1..burstNumber) { captureImageRequests.add(captureImageRequest) } captureSession.captureBurst(captureImageRequests, CaptureImageStateCallback(), mainHandler)
接下來所有的流程就和拍攝單招照片一樣了,每輸出一張圖片我們就將其儲存到 SD 卡並且重新整理媒體庫和縮圖。
4 如何連拍
連拍這個功能在 Camera2 出現之前是不可能實現的,現在我們只需要使用 重複模式 的 Capture 就可以輕鬆實現連拍功能。在《Camera2 預覽》裡我們使用了 重複模式 的 Capture 來實現預覽功能,而這一次我們不僅要用該模式進行預覽,還要在預覽的同時也輸出照片,所以我們會使用 CameraCaptureSession.setRepeatingRequest()
方法開始進行連拍:
val captureImageRequest = captureImageRequestBuilder.build() captureSession.setRepeatingRequest(captureImageRequest, CaptureImageStateCallback(), mainHandler)
停止連拍有以下兩種方式:
CameraCaptueSession.stopRepeating() CameraCaptueSession.setRepeatingRequest()
在我們的 Demo 裡使用了第二種方式:
@MainThread private fun stopCaptureImageContinuously() { // Restart preview to stop the continuous image capture. startPreview() }
5 如何切換前後置攝像頭
切換前後置攝像頭是一個很常見的功能,雖然和本章的主要內容不相關,但是在 Demo 中已經實現,所以這裡也順便提一下。我們只要按照以下順序進行操作就可以輕鬆實現前後置攝像頭的切換:
- 關閉當前攝像頭
- 開啟新的攝像頭
- 建立新的 Session
- 開啟預覽
下面是程式碼片段,詳細程式碼大家可以自行檢視 Demo 原始碼:
@MainThread private fun switchCamera() { val cameraDevice = cameraDeviceFuture?.get() val oldCameraId = cameraDevice?.id val newCameraId = if (oldCameraId == frontCameraId) backCameraId else frontCameraId if (newCameraId != null) { closeCamera() openCamera(newCameraId) createCaptureRequestBuilders() setPreviewSize(MAX_PREVIEW_WIDTH, MAX_PREVIEW_HEIGHT) setImageSize(MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT) createSession() startPreview() } }
6 總結
本章主要講述瞭如何實現幾種常見的拍照模式,其核心要領就是理解【重複模式】、【單詞模式】和【多次模式】的工作流程,根據實際業務情況靈活運用,下面是幾個小建議:
- 重複模式和多次模式都可以實現連拍功能,其中重複模式適合沒有連拍上限的情況,而多次模式適合有連拍上限的情況。
- 一個 CaptureRequest 可以新增多個 Surface,這就意味著你可以同時拍攝多張照片。
- 拍照獲取 CaptureResult 和 Image 物件走的是兩個不同的回撥介面,靈活運用子執行緒的阻塞操作可以簡化你的程式碼邏輯。