在相機應用程式中支援可調整大小的介面

1. 簡介

上次更新日期:2022 年 10 月 27 日

為何要設定可調整大小的介面?

一直以來,應用程式在整個生命週期都可持續於相同視窗中運作。

不過,隨著新的板型規格 (例如折疊式裝置) 和新的顯示模式 (例如多視窗和多螢幕模式) 問世,我們終於盼來這些特殊設計的裝置!

請特別注意,針對大型螢幕和折疊式裝置開發應用程式時應考量以下重點:

  • 不要假設應用程式會在直向視窗中持續執行:Android 12L 仍會支援應用程式固定螢幕方向的要求,但現在我們可讓裝置製造商選擇覆寫應用程式的要求來設定其偏好的螢幕方向。
  • 不要假設應用程式有任何固定尺寸或長寬比:如果設定 resizeableActivity = "false",您的應用程式便可在 API 級別 31 以上版本的大型螢幕 (>=600dp) 上的多視窗模式中使用。
  • 不要假設螢幕方向和相機之間的固定關聯性。 Android 相容性定義說明文件規定相機圖片感應器的方向「必須經過調整,讓相機的長邊對齊螢幕的長邊」。自 API 級別 32 起,相機用戶端在折疊式裝置上查詢螢幕方向時可接收一個值,這個值會因應裝置/折疊狀態動態變更。
  • 不要假設插邊大小無法變更。系統會將新的工作列做為插邊回報給應用程式;與手勢操作搭配使用時,系統可以動態隱藏及顯示工作列。
  • 不要假設應用程式擁有相機的專屬存取權。應用程式處於多視窗模式時,其他應用程式也能獨佔存取相機和麥克風等共用資源。

現在我們一同來瞭解如何轉換相機輸出內容以符合可調整大小的介面,以及如何使用 Android 提供的 API 來處理不同用途,藉此確保相機應用程式在各種情境下都能正常運作。

建構項目

本程式碼研究室將引導您建構可顯示相機預覽畫面的簡易應用程式。您將從簡易的相機應用程式開始著手,該應用程式會鎖定螢幕方向,並宣告其本身無法調整大小;此外,您還將同時瞭解該應用程式在 Android 12L 的運作情形。

接著,您將更新原始碼,以確保預覽畫面在所有情境下都能正確顯示。最終目的是讓相機應用程式可正確處理設定變更,並自動轉換介面來符合預覽畫面。

1df0acf495b0a05a.png

課程內容

  • Camera2 預覽畫面在 Android 介面上的顯示方式
  • 感應器方向、螢幕旋轉和長寬比之間的關聯性
  • 如何轉換介面,以符合相機預覽畫面的顯示比例以及螢幕旋轉情形

軟硬體需求

  • 最新版 Android Studio
  • 對 Android 應用程式開發的基本知識
  • Camera2 API 的基本知識
  • 搭載 Android 12L 的裝置或模擬器

2. 設定

取得範例程式碼

為瞭解 Android 12L 的運作情形,您將從相機應用程式開始著手;該應用程式會鎖定螢幕方向,並宣告其本身無法調整大小。

如果您已安裝 Git,只要執行下列指令即可。如要檢查 Git 是否已安裝完成,請在終端機或指令列中輸入 git --version 類型,並確認其可正確執行。

git clone https://github.com/googlecodelabs/android-camera2-preview.git

如果您沒有 Git,可以按一下下方按鈕,下載本程式碼研究室的所有程式碼:

開啟第一個模組

在 Android Studio 中,開啟 /step1 下的第一個模組。

Android Studio 會提示您設定 SDK 路徑。如有任何問題,建議您按照「更新 IDE 和 SDK 工具」中的建議項目操作。

302f1fb5070208c7.png

如果系統要求您使用最新版 Gradle,請直接進行更新。

裝置準備工作

截至本程式碼研究室的發布日期,只有部分實體裝置可以執行 Android 12L。

如需查看裝置清單和 12L 安裝操作說明,請前往 https://developer.android.com/about/versions/12/12L/get

請盡可能使用實體裝置測試相機應用程式。如果您仍想使用模擬器,請務必建立配置大型螢幕 (例如 Pixel C) 和 API 級別 32 的模擬器。

準備拍攝主體

調整相機應用程式時,我們需要一個用於對焦的標準主體,以便瞭解各種設定、螢幕方向和縮放比例的差異。

在本程式碼研究室中,我們將使用這張正方形圖片的印刷紙本 66e5d83317364e67.png

在任何情況下,如果箭頭未指向頂端或正方形變成其他幾何圖形時,就表示相關設定必須修正!

3. 執行並觀察

將裝置擺放為直向模式,然後在模組 1 上執行程式碼。請務必允許 Camera2 Codelab 應用程式在使用期間拍照及錄影。如畫面所示,系統可正確顯示預覽畫面,且有效使用畫面空間。

現在,請將裝置轉成橫向。

46f2d86b060dc15a.png

上方的預覽畫面想必很糟糕。現在,請點選右下角的「Refresh」按鈕。

b8fbd7a793cb6259.png

這樣應該好一點,但還是不夠理想。

您看到的是 Android 12L 相容性模式的運作情形。如果應用程式將螢幕方向鎖定為直向模式,當裝置旋轉為橫向且螢幕密度高於 600dp 時,應用程式可能會出現上下黑邊。

雖然這個模式會保留原始長寬比,但由於螢幕空間大多未使用,因此所提供的使用者體驗不甚理想。

此外,本例中的預覽畫面未正確旋轉 90 度。

現在請將裝置擺放回直向,然後啟動分割畫面模式

您可以拖曳中央分隔線來調整視窗大小。

同時查看調整大小對相機預覽畫面的影響。畫面是否變形?是否維持相同的長寬比?

4. 快速修正方法

由於您只能在鎖定方向且無法調整大小的應用程式上觸發相容模式,因此您可能只想更新資訊清單中的旗標來避免觸發該模式。

您可以嘗試這麼做:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

現在請建構應用程式,並在橫向模式中再次執行應用程式。畫面應如下所示:

f5753af5a9e44d2f.png

箭頭未指向上方,圖片也不是正方形!

由於應用程式未針對多視窗模式或不同螢幕方向進行設計,因此不會預期視窗大小出現任何變化,因而導致您剛遇到的問題。

5. 處理設定變更

首先,讓系統知道我們要自行處理設定變更。開啟 step1/AndroidManifest.xml 並新增下列幾行內容:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

現在,您也需要更新 step1/CameraActivity.kt,以便每次介面尺寸改變更時重新建立 CameraCaptureSession

前往第 232 行並呼叫 createCaptureSession() 函式:

step1/CameraActivity.kt

override fun onSurfaceTextureSizeChanged(
    surface: SurfaceTexture,
    width: Int,
    height: Int
) {
    createCaptureSession()
}

這裡有一個限制:當裝置旋轉 180 度後,系統不會呼叫 onSurfaceTextureSizeChanged (因為大小沒有改變!),也不會觸發 onConfigurationChanged,因此我們只能選擇將 DisplayListener 執行個體化,並檢查裝置是否旋轉 180 度。由於裝置有四個螢幕方向 (直向、橫向、反直向和反橫向),分別由整數 0、1、2 和 3 定義,因此我們必須檢查 2 的旋轉角度差異。

請加入下列程式碼︰

step1/CameraActivity.kt

/** DisplayManager to listen to display changes */
private val displayManager: DisplayManager by lazy {
    applicationContext.getSystemService(DISPLAY_SERVICE) as DisplayManager
}

/** Keeps track of display rotations */
private var displayRotation = 0

...

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    displayManager.registerDisplayListener(displayListener, mainLooperHandler)
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    displayManager.unregisterDisplayListener(displayListener)
}

private val displayListener = object : DisplayManager.DisplayListener {
    override fun onDisplayAdded(displayId: Int) {}
    override fun onDisplayRemoved(displayId: Int) {}
    override fun onDisplayChanged(displayId: Int) {
        val difference = displayManager.getDisplay(displayId).rotation - displayRotation
        displayRotation = displayManager.getDisplay(displayId).rotation

        if (difference == 2 || difference == -2) {
            createCaptureSession()
        }
    }
}

現在我們已確定系統會在任何情況下重新建立擷取工作階段。接下來將說明相機方向和螢幕旋轉之間的隱藏關係。

6. 感應器方向和螢幕旋轉

我們將使用者在「自然」狀態下使用裝置的方向稱為「自然方向」。舉例來說,自然方向對筆電而言可能是橫向,對手機而言是直向,對於平板電腦而言可以是兩者擇一。

以這個定義為基準,我們可以延伸定義其他兩個概念。

1f9cf3248b95e534.png

我們將相機感應器與裝置自然方向之間的角度稱為「相機方向」。相機方向可能取決於相機在裝置上實際安裝的方式,且感應器應一律與螢幕的長邊對齊 (請參閱 CDD)。

由於折疊式裝置可實際變更機體形狀,要定義這類裝置的長邊可能較為困難,因此從 API 級別 32 開始,這個欄位不再是固定不變,而是改由從 CameraCharacteristics 物件中動態擷取。

另一個概念是「裝置旋轉」,用於測量裝置從自然方向實際旋轉的角度。

由於我們通常只想處理四個不同的螢幕方向,因此只需要考量 90 倍數的角度,將 Display.getRotation() 傳回的值乘以 90 即可取得相關資訊。

根據預設,TextureView 已為補正相機方向,但並未處理螢幕旋轉,導致預覽畫面無法正確旋轉。

如要解決這個問題,只要旋轉 SurfaceTexture 目標即可。請更新 CameraUtils.buildTargetTexture 函式來納入 surfaceRotation: Int 參數,以便對介面進行轉換:

step1/CameraUtils.kt

fun buildTargetTexture(
    containerView: TextureView,
    characteristics: CameraCharacteristics,
    surfaceRotation: Int
): SurfaceTexture? {

    val previewSize = findBestPreviewSize(Size(containerView.width, containerView.height), characteristics)

    val surfaceRotationDegrees = surfaceRotation * 90

    val halfWidth = containerView.width / 2f
    val halfHeight = containerView.height / 2f

    val matrix = Matrix()

    // Rotate to compensate display rotation
    matrix.postRotate(
        -surfaceRotationDegrees.toFloat(),
        halfWidth,
        halfHeight
    )

    containerView.setTransform(matrix)

    return containerView.surfaceTexture?.apply {
        setDefaultBufferSize(previewSize.width, previewSize.height)
    }
}

接著,您可以按照以下內容修改 CameraActivity 的第 138 行程式碼,以呼叫該函式:

step1/CameraActivity.kt

val targetTexture = CameraUtils.buildTargetTexture(
textureView, cameraManager.getCameraCharacteristics(cameraID))

完成後,執行應用程式時會產生以下預覽畫面:

1566c3f9e5089a35.png

箭頭現在已指向上方,但方框仍不是正方形。我們來看看如何在最後一個步驟中修正這個問題。

縮放觀景窗

最後一個步驟是縮放介面以符合相機輸出內容的長寬比。

上一個步驟的問題之所以發生,是因為 TextureView 預設會縮放畫面大小來填滿整個視窗。該視窗的長寬比可能與相機預覽畫面不同,因此成像結果可能會延展或變形。

我們可透過下列兩個步驟解決這個問題:

  • 計算 TextureView 預設對本身套用的縮放比例係數,並對調轉換結果
  • 計算並套用適當的縮放比例係數 (必須與 X 軸和 Y 軸相同)

為了計算正確的縮放比例係數,我們必須將相機方向和螢幕旋轉的差異納入考量。請開啟 step1/CameraUtils.kt 並新增下列函式,以便計算感應器方向和螢幕旋轉的相對旋轉角度:

step1/CameraUtils.kt

/**
 * Computes the relative rotation between the sensor orientation and the display rotation.
 */
private fun computeRelativeRotation(
    characteristics: CameraCharacteristics,
    deviceOrientationDegrees: Int
): Int {
    val sensorOrientationDegrees =
        characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0

    // Reverse device orientation for front-facing cameras
    val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) ==
        CameraCharacteristics.LENS_FACING_FRONT
    ) 1 else -1

    return (sensorOrientationDegrees - (deviceOrientationDegrees * sign) + 360) % 360
}

掌握 computeRelativeRotation 傳回的值非常重要,因為我們便能得知原始預覽畫面在縮放之前已經過旋轉。

舉例來說,在自然方向擺放的手機上,其相機輸出內容為橫向,這時系統會先將輸出內容旋轉 90 度,它才能顯示在螢幕上。

另一方面,如果是以自然方向擺放的 Chromebook,相機輸出內容則會直接顯示在螢幕上,無需額外經過旋轉。

請再看一次以下案例:

4e3a61ea9796a914.png 在第二個 (中間) 案例中,相機輸出內容的 x 軸會顯示在螢幕的 y 軸上,反之亦然;也就是說,相機輸出內容的寬度和高度在轉換間需互換。在其他案例中,它們保持不變,不過在第三個案例中,仍需旋轉輸出內容。

我們可以使用以下公式對這些案例進行歸納:

val isRotationRequired =
        computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

掌握以上資訊後,我們現在可以更新函式來縮放介面:

step1/CameraUtils.kt

fun buildTargetTexture(
        containerView: TextureView,
        characteristics: CameraCharacteristics,
        surfaceRotation: Int
    ): SurfaceTexture? {

        val surfaceRotationDegrees = surfaceRotation * 90
        val windowSize = Size(containerView.width, containerView.height)
        val previewSize = findBestPreviewSize(windowSize, characteristics)
        val sensorOrientation =
            characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
        val isRotationRequired =
            computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

        /* Scale factor required to scale the preview to its original size on the x-axis */
        var scaleX = 1f
        /* Scale factor required to scale the preview to its original size on the y-axis */
        var scaleY = 1f

        if (sensorOrientation == 0) {
            scaleX =
                if (!isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (!isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        } else {
            scaleX =
                if (isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        }

        /* Scale factor required to fit the preview to the TextureView size */
        val finalScale = max(scaleX, scaleY)
        val halfWidth = windowSize.width / 2f
        val halfHeight = windowSize.height / 2f

        val matrix = Matrix()

        if (isRotationRequired) {
            matrix.setScale(
                1 / scaleX * finalScale,
                1 / scaleY * finalScale,
                halfWidth,
                halfHeight
            )
        } else {
            matrix.setScale(
                windowSize.height / windowSize.width.toFloat() / scaleY * finalScale,
                windowSize.width / windowSize.height.toFloat() / scaleX * finalScale,
                halfWidth,
                halfHeight
            )
        }

        // Rotate to compensate display rotation
        matrix.postRotate(
            -surfaceRotationDegrees.toFloat(),
            halfWidth,
            halfHeight
        )

        containerView.setTransform(matrix)

        return containerView.surfaceTexture?.apply {
            setDefaultBufferSize(previewSize.width, previewSize.height)
        }
    }

建構及執行應用程式,享受全新的相機預覽畫面!

額外步驟:變更預設動畫

如要避免旋轉時的預設動畫 (相機應用程式可能有不常見的外觀),您可以在活動 onCreate() 方法中加入以下程式碼,透過跳接動畫變更播放效果,讓轉換更流暢:

val windowParams: WindowManager.LayoutParams = window.attributes
windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
window.attributes = windowParams

7. 恭喜

涵蓋內容:

  • 未最佳化的應用程式在 Android 12L 相容模式中的運作情形
  • 如何處理設定變更
  • 相機方向、螢幕旋轉和裝置自然方向等概念之間的差異
  • TextureView 的預設行為
  • 如何縮放及旋轉介面,以便在各種情況下正確顯示相機預覽畫面!

其他資訊

參考文件