全画面表示にも負けないカメラ


本日二十歳になりました @nakker1218 です。[^1]
[^1]: ウイッシュリストは[こちら](http://amzn.asia/5qJzdwN)になります。
この記事は[Android Advent Calendar 2018]("https://qiita.com/advent-calendar/2018/android")の18日目の記事です。

## はじめに  
こんなやばいカメラ見たことないでしょうか。 
Macのキーボードをとったものなのですが、横に伸びてしまっていますね:thinking_face:
![やばい.png](https://qiita-image-store.s3.amazonaws.com/0/43128/5cdf32a6-8003-61bf-7b55-0b364a3be940.png "やばい.png")

ここまで過激なものはなかなかないですが、開発中やリリースされているアプリでも表示が崩れてしまっているものは見かけます。
この現象は、カメラの映像の比率と表示するSurfaceViewの比率がずれているために起こります。
端末に標準で入っているカメラアプリでは、表示するViewを**16:9**に保ち余白を作ることでどんな端末でも表示が崩れないようにしています。
![Google純正のカメラアプリ](https://qiita-image-store.s3.amazonaws.com/0/43128/be2adfa8-e518-97e6-e14b-49c750cb5050.png)

しかし、全画面でカメラのプレビューを表示したいときもありますよね。
この記事では全画面表示でも崩れずに表示することの出来るカメラのプレビューを実装していきます。

## 画像が崩れないようにする
カメラの映像を表示する部分はお好みで実装してください。  
Androidにおいてカメラの映像が崩れてしまうのは、端末の画面サイズと表示する映像のアスペクト比がずれているためです。
ということは、カメラの映像をきれいに表示するためには、SurfaceViewの比率をCameraのPreviewSizeに合わせればよいのです。例えば、CameraのPreviewSizeが16:9の比率であればSurfaceViewの比率も16:9にすればきれいに表示されます。
つまり、下の図のようにImageViewのCenterCropのように比率を保ったまま、長辺に合わせて**はみ出すように**表示すればどんな大きさのプレビュー画面でもきれいに表示させることができるはずです。

![比率.png](https://qiita-image-store.s3.amazonaws.com/0/43128/b41e80fb-6e8a-3989-1625-85054bba8611.png)

Androidではみ出すように表示するにするためには、FrameLayoutの中にSurfaceViewを配置し、カメラの比率に合わせて動的にSurfaceViewの大きさを変えることができればいいはずです。

順を追って見ていきましょう。
### カメラの比率を取得する
まずは、カメラのPreviewSizeの比率を求めます。
比率はWidthとHeightの最大公約数(GCD)を求め、`Width or Height / GCD`することで求められます。

最大公約数の求め方は、aをbでわり、あまりをrとしてbでわるということをrが0になるまで繰り返して行くことで求まります。

```
gcd(64, 24)
gcd(24, 16)
gcd(16, 8)
gcd(8, 0))
-> 8
```

といったように簡単に最大公約数を求めることが出来ます。
これをコードに起こしましょう。
ここでKotlinの `tailrec` という修飾子が活躍します。
`tailrec` は、再帰的な書き方のデメリットである実行時間の遅さや、スタックオーバーフローのリスクを、コンパイル時に最適化してくれるため、いい感じにつかうことが出来るようになります。
今回は、最大公約数の計算を再帰的に行うために使います。

```kotlin

private tailrec fun calculatedGcd(a: Int, b: Int): Int 
    = if (b == 0) a else calculatedGcd(b, a % b)
```

そして、それぞれの辺を最大公約数で割れば比が出ます。

```kotlin
val widthRatio = previewSize.width / gcd
val heightRatio = previewSize.height / gcd
```

画面の回転に対応するためにどちらの辺の比が大きいかも取得しておきましょう。

```kotlin

val largeRate: Int
val shortRate: Int

if (widthRatio > heightRatio) {
    largeRate = widthRatio
    shortRate = heightRatio
} else {
    largeRate = heightRatio
    shortRate = widthRatio
}
```

### SurfaceViewの大きさを決める
カメラのサイズの比が求まったので、SurfaceViewの大きさを決めていきます。
Frameの長辺いっぱいに映像が表示されるように拡大していきます。

まず、表示する枠の大きさを取得し、どちらの辺が大きいかを計算します
ここで注意が必要なのですが、この処理はViewのサイズを使うため画面の描画が終わったあとに行う必要があります。
そこで、ViewTreeObserverを使用して描画が終了したあとにレイアウトのサイズを変数に入れます。

```kotlin
private sealed class LargeSide(val longLength: Int) {
    data class Width(val width: Int, val height: Int) : LargeSide(width)
    data class Height(val width: Int, val height: Int) : LargeSide(height)

    companion object {
        fun getLargeSide(width: Int, height: Int): LargeSide {
            return when {
                width > height -> Width(width, height)
                else -> Height(width, height)
            }
        }
    }
}
```

```kotlin

var largeSide: LargeSide? = null

 viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
    override fun onGlobalLayout() {
        viewTreeObserver?.removeOnGlobalLayoutListener(this)
        largeSide = LargeSide.getLargeSide(width, height)
    }
})
```



余談ですが、Viewの大きさをよく使う場合はこのような拡張関数を作ると便利です。

```kotlin

inline fun <T : View> T.addOnGlobalLayoutListener(crossinline action: T.() -> Unit) {
    viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            viewTreeObserver?.removeOnGlobalLayoutListener(this)
            action()
        }
    })
}
```

長辺 / 大きい方の比をして長辺の長さが、比の大きい方になるような最大公約数を求めます。

```kotlin
val previewGcd = Math.ceil(longSide.toDouble() / largeRate).toInt()
```

先ほど出した最大公約数を使ってSurfaceViewの大きさを計算します。
映像のアスペクト比 * Frameの最大公約数をすると縦横のサイズが確定します。

```kotlin
val widthLength: Int
val heightLength: Int
when (largeSide) {
    is LargeSide.Width -> {
        widthLength = largeRate * previewGcd
        heightLength = shortRate * previewGcd
    }
    is LargeSide.Height -> {
        widthLength = shortRate * previewGcd
        heightLength = largeRate * previewGcd
    }
}
```

SurfaceViewのLayoutParamsに今計算したWidthとHeightを設定します。

```kotlin

val layoutParams = surface_view.layoutParams
layoutParams.width = size.width
layoutParams.height = size.height
surface_view.layoutParams = layoutParams
```

## さいごに
このようにSurfaceView自体の大きさを調整することによって、縦画面でも横画面でも表示の崩れないカメラを作ることが出来るようになります。
![正常.png](https://qiita-image-store.s3.amazonaws.com/0/43128/9f74587d-b123-76ed-ec55-5b6034f09e27.png)
![正常横.png](https://qiita-image-store.s3.amazonaws.com/0/43128/1c45edb3-d3a9-3e8e-18aa-ef3b97ed0c80.png)

```kotlin
class CameraSurfaceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : FrameLayout(context, attrs, defStyleAttr), SurfaceHolder.Callback {

    private var facing: CameraFacing = CameraFacing.BACK

    private var surfaceHolder: SurfaceHolder? = null
    private var camera: Camera? = null

    private var largeSide: LargeSide? = null

    companion object {
        private const val ASPECT_TOLERANCE = 0.1

        private fun getIdForRequestedCamera(cameraFacing: CameraFacing): Int {
            val cameraInfo = Camera.CameraInfo()
            for (i in 0 until Camera.getNumberOfCameras()) {
                Camera.getCameraInfo(i, cameraInfo)
                if (cameraInfo.facing === cameraFacing.facing) {
                    return i
                }
            }
            return -1
        }
    }

    init {
        LayoutInflater.from(context).inflate(R.layout.view_camera, this)

        clipToPadding = true
        clipChildren = true

        viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
            override fun onGlobalLayout() {
                viewTreeObserver?.removeOnGlobalLayoutListener(this)

                largeSide = LargeSide.get(width, height)
                surface_view.visibility = View.VISIBLE
            }
        })
    }

    override fun onFinishInflate() {
        super.onFinishInflate()

        surfaceHolder = surface_view.holder
        surfaceHolder?.addCallback(this)

        surface_view.setOnTouchListener { _, motionEvent ->
            when (motionEvent.action) {
                MotionEvent.ACTION_DOWN -> camera?.let { startAutoFocus(it, motionEvent) }
            }
            return@setOnTouchListener false
        }
    }

    override fun surfaceCreated(holder: SurfaceHolder?) {
        camera = createCamera().apply {
            startPreview()
        }
    }

    override fun surfaceChanged(holder: SurfaceHolder?, format: Int, width: Int, height: Int) {
        camera?.startPreview()
    }

    override fun surfaceDestroyed(holder: SurfaceHolder?) {
        camera?.apply {
            stopPreview()
            setPreviewCallbackWithBuffer(null)
            try {
                setPreviewTexture(null)
            } catch (e: Exception) {
                e.printStackTrace()
            }
            release()
        }
    }

    private tailrec fun calculatedGcd(a: Int, b: Int): Int = if (b == 0) a else calculatedGcd(b, a % b)

    private fun initSurfaceView(previewSize: Camera.Size) {
        val largeSide = largeSide ?: return
        val gcd = calculatedGcd(previewSize.width, previewSize.height)

        val widthRatio = previewSize.width / gcd
        val heightRatio = previewSize.height / gcd

        val largeRate: Int
        val shortRate: Int

        if (widthRatio > heightRatio) {
            largeRate = widthRatio
            shortRate = heightRatio
        } else {
            largeRate = heightRatio
            shortRate = widthRatio
        }

        val previewGcd = Math.ceil(largeSide.longLength.toDouble() / largeRate).toInt()

        val widthLength: Int
        val heightLength: Int
        when (largeSide) {
            is LargeSide.Width -> {
                widthLength = largeRate * previewGcd
                heightLength = shortRate * previewGcd
            }
            is LargeSide.Height -> {
                widthLength = shortRate * previewGcd
                heightLength = largeRate * previewGcd
            }
        }

        val size = Size(widthLength, heightLength)

        val layoutParams = surface_view.layoutParams
        layoutParams.width = size.width
        layoutParams.height = size.height
        surface_view.layoutParams = layoutParams
    }

    private fun createCamera(): Camera {
        val requestedCameraId = getIdForRequestedCamera(facing)
        if (requestedCameraId == -1) throw IllegalArgumentException("Could not find requested camera.")

        val camera = Camera.open(requestedCameraId)
        val previewSize = largeSide ?: throw IllegalStateException()

        val parameters = camera.parameters

        parameters.apply {
            setPreviewSize(parameters.previewSize.width, parameters.previewSize.height)
            initSurfaceView(parameters.previewSize)

            previewFormat = ImageFormat.NV21
            setRotation(camera, this, requestedCameraId)
        }

        camera.apply {
            this.parameters = parameters
            setPreviewDisplay(surfaceHolder)
        }
        
        return camera
    }

    enum class CameraFacing(val facing: Int) {
        BACK(Camera.CameraInfo.CAMERA_FACING_BACK),
        FRONT(Camera.CameraInfo.CAMERA_FACING_FRONT)
    }
    
    private sealed class LargeSide(val longLength: Int) {
        data class Width(val width: Int, val height: Int) : LargeSide(width)
        data class Height(val width: Int, val height: Int) : LargeSide(height)

        companion object {
            fun get(width: Int, height: Int): LargeSide {
                return when {
                    width > height -> Width(width, height)
                    else -> Height(width, height)
                }
            }
        }
    }
}
```