カメラアプリでサイズ変更可能なサーフェスをサポートする

1. はじめに

最終更新日: 2022 年 10 月 27 日

サーフェスをサイズ変更可能にする理由

アプリは従来、そのライフサイクルを通じて、同じウィンドウの中で動作するものと想定できました。

しかし、折りたたみ式デバイスなどの新しいフォーム ファクタや、マルチウィンドウやマルチディスプレイなどの新しいディスプレイ モードが出現したことで、そのような想定は不可能となりました。

特に、大画面デバイスや折りたたみ式デバイスをターゲットにアプリを開発する際には、次のような点を考慮することが重要です。

  • 縦長のウィンドウに表示されることを前提としない: Android 12L では引き続き固定の画面の向きをリクエストできますが、現在はデバイス メーカーがアプリからリクエストされた画面の向きをオーバーライドすることが可能となっています。
  • 固定のサイズやアスペクト比を前提としない: resizeableActivity = "false" と設定した場合でも、API レベル 31 以降での大画面(600 dp 以上)ではマルチウィンドウ モードで使用される場合があります。
  • 画面の向きとカメラの向きの間に決まった関係があることを前提としない: Android 互換性定義ドキュメントでは、カメラのイメージ センサーの向きは、カメラの長辺と画面の長辺が平行となる向きにしなければならないと規定されています。API レベル 32 以降では、折りたたみ式デバイスで向きを照会するカメラ クライアントが受け取る値は、デバイスや折りたたみの状態に応じて動的に変化する可能性があります。
  • インセットのサイズが変化しないことを前提としない: 新しいタスクバーはインセットとしてアプリに報告されます。また、タスクバーは、ジェスチャー ナビゲーションを使用したときに、表示されなくなったり動的に表示されたりする可能性があります。
  • カメラに対する排他的なアクセス権があることを前提としない: マルチ ウィンドウ モードにある場合、カメラやマイクなどの共有リソースに対する排他的なアクセス権を他のアプリが取得する可能性があります。

ここでは、サイズ変更可能なサーフェスに合わせてカメラ出力を変換する方法と、Android が提供している API を使ってさまざまなユースケースに対応する方法を学習して、カメラアプリがあらゆる状況で正常に動作するようにします。

作成するアプリの概要

この Codelab では、カメラ プレビューを表示するシンプルなアプリを作成します。向きをロックしてサイズ変更不可と宣言する単純なカメラアプリで Android 12L の動作を確認することから始めましょう。

次に、あらゆる状況でプレビューが適切に表示されるようにソースコードを更新します。その結果、構成変更を適切に処理し、プレビューに合わせてサーフェスを自動的に変換するカメラアプリができあがります。

1df0acf495b0a05a.png

学習内容

  • Android サーフェスに Camera2 のプレビューを表示する方法
  • センサーの向き、ディスプレイの回転、アスペクト比の関係
  • カメラ プレビューのアスペクト比とディスプレイの回転に合わせてサーフェスを変換する方法

必要なもの

  • 最新バージョンの 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 がない場合は、次のボタンをクリックして、この Codelab のすべてのコードをダウンロードできます。

最初のモジュールを開く

Android Studio で、/step1 にある最初のモジュールを開きます。

Android Studio で SDK へのパスの設定を求めるメッセージが表示されます。問題が発生した場合は、IDE と SDK ツールのアップデートに関する推奨事項に従ってください。

302f1fb5070208c7.png

最新バージョンの Gradle を使用するよう求められた場合は、更新してください。

デバイスを準備する

この Codelab の公開日の時点で Android 12L が動作する実機は限られています。

デバイスのリストと 12L のインストール手順については、https://developer.android.com/about/versions/12/12L/get をご覧ください。

可能であれば、実機を使用してカメラアプリをテストします。エミュレータを使用する場合は、大画面で作成し(Google Pixel C など)、API レベル 32 を指定してください。

被写体を準備する

カメラを扱うときには、設定、向き、拡大縮小の違いを理解するために、被写体に標準的なものを用意しておくと便利です。

この Codelab では、次のような正方形の画像を印刷して使用します。66e5d83317364e67.png

矢印が上を指していない場合や、正方形になっていない場合は、なんらかの修正が必要です。

3. 実行して観察する

デバイスを縦向きにして、モジュール 1 のコードを実行してください。Camera2 Codelab アプリは、その使用中に写真や動画を撮影できるようにします。プレビューは正しく表示され、画面のスペースは効率的に使用されているはずです。

次に、デバイスを回転させて横向きにします。

46f2d86b060dc15a.png

満足できる結果ではありません。そこで、右下の更新ボタンをクリックしましょう。

b8fbd7a793cb6259.png

少し良くなりましたが、まだ改善の余地があります。

今見ているのは、Android 12L の互換モードの動作です。デバイスが横向きに回転され、画面密度が 600 dp を超えていると、縦向きに固定されてレターボックス表示されます。

また、このモードでは元のアスペクト比が維持され、画面スペースの多くが使用されないため、最適なユーザー エクスペリエンスではありません。

さらに、この場合はプレビューが 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 の整数で定義される 4 つの向き(縦向き、横向き、逆の縦向き、逆の横向き)があるため、回転の差が 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. センサーの向きとディスプレイの回転

ユーザーが自然にデバイスを使用できる向きを「自然な向き」と呼んでいます。たとえば、ノートパソコンの自然な向きは横向きで、スマートフォンの場合は縦向きです。タブレットの場合は、どちらも可能です。

この定義から始めて、さらに 2 つのコンセプトを定義できます。

1f9cf3248b95e534.png

カメラセンサーとデバイスの自然な向きとの間の角度を「カメラの向き」と呼びます。これは、カメラがデバイスにどのように物理的に取り付けられているかと、センサーが常に画面の長辺に揃っていることが想定されているかよって異なります(CDD をご覧ください)。

折りたたみ式デバイスの長辺を定義するのは困難であることを考慮して(物理的に変形するため)、API レベル 32 以降、このフィールドは静的ではなくなりましたが、CameraCharacteristics オブジェクトから動的に取得できます。

もう 1 つのコンセプトはデバイスの回転です。これは、自然な向きからデバイスが物理的にどれだけ回転しているかを示します。

通常は 4 つの向きのみを取り扱うため、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 がデフォルトでコンテンツをウィンドウ全体に合わせて拡大縮小することが原因で発生しています。このウィンドウはカメラ プレビューとは異なるアスペクト比になる場合があるため、引き伸ばされたり、歪んだりすることがあります。

これは次の 2 つの計算で修正できます。

  • 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 2 つ目(中央)のケースでは、カメラ出力の X 軸は画面の Y 軸に沿って表示されます(逆も同様)。つまり、変換中にカメラ出力の幅と高さが逆になっています。その他のケースでは同じままですが、3 つ目のシナリオでは回転が必要です。

このようなケースを一般化するために、次の式を使用します。

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 のデフォルトの動作
  • サーフェスの拡大縮小と回転により、あらゆるシナリオでカメラのプレビューを正しく表示する方法

参考資料

リファレンス ドキュメント