開始使用 CameraX

在本程式碼研究室中,我們將學習如何利用相機應用程式開啟相機應用程式,包括觀景窗、拍照及分析相機中的影像串流。

為達成這個目標,我們將介紹 CameraX 的概念;這個應用實例可用於各種相機作業,從顯示觀景窗到即時分析影格,應有盡有。

課程內容

  • 如何新增 CameraX 依附元件。
  • 如何在活動中顯示相機預覽畫面。(預覽用途)
  • 如何拍照並儲存至儲存空間。(ImageCapture 使用案例)
  • 如何即時分析相機的畫面。(ImageAnalysis 應用實例)

硬體需求

  • 使用 Android 裝置時,雖然 Android Studio 的模擬器可以正常運作,支援的 API 級別最低為 21 個。

我們所需的軟體

  • Android Studio 3.3 以上版本。

使用 Android Studio 選單啟動新專案,並在系統提示時選取 [Empty Activity] (空白活動)

接下來,我們可以挑選任何想要的名稱,例如「相機 X 應用程式」。我們應該確認語言已設為 Kotlin,最低 API 級別為 21 (這是 CameraX 的最低需求),並且使用 AndroidX 成果。

如要開始使用,請讓我們將 CameraX 依附元件新增至應用程式的 Gradle 檔案,並列於「依附元件」部分:

// Use the most recent version of CameraX, currently that is alpha04
def camerax_version = "1.0.0-alpha04"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"

當系統提示時,按一下 [立即同步處理],我們就會開始在應用程式內使用 CameraX。

我們將使用 SurfaceTexture 顯示相機觀景窗。在本程式碼研究室中,我們將以固定大小的正方形顯示觀景窗。如需顯示回應式檢視器的完整範例,請查看官方範例

允許在 res &gt 中編輯 activity_main 版面配置檔案;版面配置 > activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <TextureView
            android:id="@+id/view_finder"
            android:layout_width="640px"
            android:layout_height="640px"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

如要在專案中新增任何使用相機的功能,請務必授予適當的 CAMERA 權限。首先,您必須在應用程式標記之前在資訊清單中宣告這些方法:

<uses-permission android:name="android.permission.CAMERA" />

隨後在 MainActivity 中,我們需要在執行階段要求權限。我們將在 java > com.example.cameraxapp > MainActivity.kt 下方對 MainActivity 檔案進行變更:

在檔案頂端,除了 MainActivity 類別定義以外,Let\#39;s 加入下列常數及匯入:

// Your IDE likely can auto-import these classes, but there are several
// different implementations so we list them here to disambiguate
import android.Manifest
import android.util.Size
import android.graphics.Matrix
import java.util.concurrent.TimeUnit

// This is an arbitrary number we are using to keep tab of the permission
// request. Where an app has multiple context for requesting permission,
// this can help differentiate the different contexts
private const val REQUEST_CODE_PERMISSIONS = 10

// This is an array of all the permission specified in the manifest
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

在 MainActivity 類別內,新增下列欄位和輔助程式方法,以便在確定所有權限後要求權限並觸發我們的程式碼:

class MainActivity : AppCompatActivity(), LifecycleOwner {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }

    // Add this after onCreate

    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        // TODO: Implement CameraX operations
    }

    private fun updateTransform() {
        // TODO: Implement camera viewfinder transformations
    }

    /**
     * Process result from permission request dialog box, has the request
     * been granted? If yes, start Camera. Otherwise display a toast
     */
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.", 
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
     * Check if all permission specified in the manifest have been granted
     */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
               baseContext, it) == PackageManager.PERMISSION_GRANTED
    }
}

最後,我們將所有功能放在 onCreate 內,以便在適當的情況下觸發權限要求:

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    // Add this at the end of onCreate function

    viewFinder = findViewById(R.id.view_finder)

    // Request camera permissions
    if (allPermissionsGranted()) {
        viewFinder.post { startCamera() }
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }

    // Every time the provided texture view changes, recompute layout
    viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateTransform()
    }
}

現在當應用程式啟動時,應用程式會檢查其是否具備適當的相機權限。如果有,則會直接呼叫「startCamera()」。否則應用程式會要求權限,並在授權完成後呼叫「startCamera()」。

對於大多數相機應用程式而言,向使用者顯示觀景窗是相當重要的,否則使用者很難將相機指向正確的位置。使用 CameraX「Preview」類別可導入觀景窗。

如要使用預先發布版,您必須先定義設定,然後用來建立用途的實例。產生的執行個體是繫結至 CameraX 生命週期所需的資料。我們會在「startCamera()」方法中進行此操作,並填入以下程式碼來實作實作:

private fun startCamera() {

    // Create configuration object for the viewfinder use case
    val previewConfig = PreviewConfig.Builder().apply {
        setTargetAspectRatio(Rational(1, 1))
        setTargetResolution(Size(640, 640))
    }.build()

    // Build the viewfinder use case
    val preview = Preview(previewConfig)

    // Every time the viewfinder is updated, recompute layout
    preview.setOnPreviewOutputUpdateListener {

        // To update the SurfaceTexture, we have to remove it and re-add it
        val parent = viewFinder.parent as ViewGroup
        parent.removeView(viewFinder)
        parent.addView(viewFinder, 0)

        viewFinder.surfaceTexture = it.surfaceTexture
        updateTransform()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

這時,我們就必須實作神秘的「updateTransform()」方法。在「updateTransform()」中,目標是要補償裝置螢幕方向的變動,以直立式旋轉的方式顯示觀景窗:

private fun updateTransform() {
    val matrix = Matrix()

    // Compute the center of the view finder
    val centerX = viewFinder.width / 2f
    val centerY = viewFinder.height / 2f

    // Correct preview output to account for display rotation
    val rotationDegrees = when(viewFinder.display.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> return
    }
    matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

    // Finally, apply transformations to our TextureView
    viewFinder.setTransform(matrix)
}

如要導入可用於實際工作環境的應用程式,請參閱官方範例,瞭解其他需要處理的項目。為了簡化此程式碼研究室,我們整理了幾項快速鍵。舉例來說,我們不會追蹤某些設定變更 (例如旋轉 180 度的裝置),而不觸發版面配置變更監聽器。此外,非正方形的觀景窗也需要針對裝置旋轉時,要變更顯示比例。

現在,在建構及執行應用程式時,我們應該會看到生命預覽。很好!

為了讓使用者擷取圖片,我們會在版面配置 (位於 res > 版面配置 > activity_main.xml 之後) 的版面配置中,加入按鈕:

<ImageButton
        android:id="@+id/capture_button"
        android:layout_width="72dp"
        android:layout_height="72dp"
        android:layout_margin="24dp"
        app:srcCompat="@android:drawable/ic_menu_camera"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

其他用途的運作方式則與預覽功能十分類似。首先,我們必須定義用來將實際用途物件執行個體化的設定物件,如要拍照,只要按下擷取按鈕,我們就必須更新「startCamera()」方法,並在最後加入幾行程式碼,再呼叫 CameraX.bindToLifecycle:

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Create configuration object for the image capture use case
    val imageCaptureConfig = ImageCaptureConfig.Builder()
        .apply {
            setTargetAspectRatio(Rational(1, 1))
            // We don't set a resolution for image capture; instead, we
            // select a capture mode which will infer the appropriate
            // resolution based on aspect ration and requested mode
            setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
    }.build()

    // Build the image capture use case and attach button click listener
    val imageCapture = ImageCapture(imageCaptureConfig)
    findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
        val file = File(externalMediaDirs.first(),
            "${System.currentTimeMillis()}.jpg")
        imageCapture.takePicture(file,
            object : ImageCapture.OnImageSavedListener {
            override fun onError(error: ImageCapture.UseCaseError,
                                 message: String, exc: Throwable?) {
                val msg = "Photo capture failed: $message"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.e("CameraXApp", msg)
                exc?.printStackTrace()
            }

            override fun onImageSaved(file: File) {
                val msg = "Photo capture succeeded: ${file.absolutePath}"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.d("CameraXApp", msg)
            }
        })
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

接著,請將呼叫更新為 CameraX.bindToLifecycle,以納入新的用途:

CameraX.bindToLifecycle(this, preview, imageCapture)

除此之外,我們還導入了實用的相片拍攝按鈕。

CameraAnalysis 類別是 CameraX 非常有趣的功能。它可以定義實作類別,以實作 ImageAnalysis.Analyzer 介面,並呼叫連入的相機頁框。根據 CameraX 的核心願景,我們不必擔心會管理攝影機工作階段狀態,甚至是配置圖片;與應用程式希望達成的生命週期相當,如同其他生命週期感知元件

首先,我們會導入自訂的圖片分析工具。我們的分析工具相當簡單,它只會記錄圖片的平均亮度 (亮度),但能夠展現在各種複雜的用途方面需要做什麼。我們只需要在實作 ImageAnalysis.Analyzer 介面的類別中覆寫「分析」函式即可。我們可以將實作定義為 MainActivity 中的內部類別:

private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
    private var lastAnalyzedTimestamp = 0L

    /**
     * Helper extension function used to extract a byte array from an
     * image plane buffer
     */
    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()    // Rewind the buffer to zero
        val data = ByteArray(remaining())
        get(data)   // Copy the buffer into a byte array
        return data // Return the byte array
    }

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        // Calculate the average luma no more often than every second
        if (currentTimestamp - lastAnalyzedTimestamp >=
            TimeUnit.SECONDS.toMillis(1)) {
            // Since format in ImageAnalysis is YUV, image.planes[0]
            // contains the Y (luminance) plane
            val buffer = image.planes[0].buffer
            // Extract image data from callback object
            val data = buffer.toByteArray()
            // Convert the data into an array of pixel values
            val pixels = data.map { it.toInt() and 0xFF }
            // Compute average luminance for the image
            val luma = pixels.average()
            // Log the new luma value
            Log.d("CameraXApp", "Average luminosity: $luma")
            // Update timestamp of last analyzed frame
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

我們的類別實作 ImageAnalysis.Analyzer 介面後,我們只需要對其他所有應用實例執行個體化,就能對 ImageAnalysis 執行個體化,並在呼叫 CameraX.bindToLifecycle 前再次更新「startCamera()」函式:

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Setup image analysis pipeline that computes average pixel luminance
    val analyzerConfig = ImageAnalysisConfig.Builder().apply {
        // Use a worker thread for image analysis to prevent glitches
        val analyzerThread = HandlerThread(
            "LuminosityAnalysis").apply { start() }
        setCallbackHandler(Handler(analyzerThread.looper))
        // In our analysis, we care more about the latest image than
        // analyzing *every* image
        setImageReaderMode(
            ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
    }.build()

    // Build the image analysis use case and instantiate our analyzer
    val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
        analyzer = LuminosityAnalyzer()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview, imageCapture)
}

我們也更新了 CameraX.bindtoLifecycle 的呼叫,以繫結至新的用途:

CameraX.bindToLifecycle(
    this, preview, imageCapture, analyzerUseCase)

現在執行應用程式,在大約 {0}

D/CameraXApp: Average luminosity: ...

如要測試應用程式,只需按一下 Android Studio 中的 [執行] 按鈕,系統就會在所選裝置或模擬器中建構、部署專案。應用程式載入後,我們將透過觀景器顯示。由於我們先前新增的方向處理程式碼,在旋轉裝置後,這個畫面仍然會保持直立,而且可以使用按鈕拍照:

您已完成程式碼研究室並順利完成!回顧過去,您已在新的 Android 應用程式中導入下列項目:

  • 在專案中包含 CameraX 依附元件。
  • 顯示相機觀景窗 (使用預覽用途)
  • 實作相片拍攝,將圖片儲存到儲存空間 (使用 ImageCapture 用途)
  • 使用 ImageAnalysis 應用實例即時執行相機的影格分析作業

如要進一步瞭解 CameraX 和相關功能,請參閱說明文件或複製官方範例