Edited at
AndroidDay 18

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

本日二十歳になりました @nakker1218 です。1

この記事はAndroid Advent Calendar 2018の18日目の記事です。


はじめに

こんなやばいカメラ見たことないでしょうか。

Macのキーボードをとったものなのですが、横に伸びてしまっていますね:thinking:

ここまで過激なものはなかなかないですが、開発中やリリースされているアプリでも表示が崩れてしまっているものは見かけます。

この現象は、カメラの映像の比率と表示するSurfaceViewの比率がずれているために起こります。

端末に標準で入っているカメラアプリでは、表示するViewを16:9に保ち余白を作ることでどんな端末でも表示が崩れないようにしています。

しかし、全画面でカメラのプレビューを表示したいときもありますよね。

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


画像が崩れないようにする

カメラの映像を表示する部分はお好みで実装してください。


Androidにおいてカメラの映像が崩れてしまうのは、端末の画面サイズと表示する映像のアスペクト比がずれているためです。

ということは、カメラの映像をきれいに表示するためには、SurfaceViewの比率をCameraのPreviewSizeに合わせればよいのです。例えば、CameraのPreviewSizeが16:9の比率であればSurfaceViewの比率も16:9にすればきれいに表示されます。

つまり、下の図のようにImageViewのCenterCropのように比率を保ったまま、長辺に合わせてはみ出すように表示すればどんな大きさのプレビュー画面でもきれいに表示させることができるはずです。

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 は、再帰的な書き方のデメリットである実行時間の遅さや、スタックオーバーフローのリスクを、コンパイル時に最適化してくれるため、いい感じにつかうことが出来るようになります。

今回は、最大公約数の計算を再帰的に行うために使います。


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

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

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
}


SurfaceViewの大きさを決める

カメラのサイズの比が求まったので、SurfaceViewの大きさを決めていきます。

Frameの長辺いっぱいに映像が表示されるように拡大していきます。

まず、表示する枠の大きさを取得し、どちらの辺が大きいかを計算します

ここで注意が必要なのですが、この処理はViewのサイズを使うため画面の描画が終わったあとに行う必要があります。

そこで、ViewTreeObserverを使用して描画が終了したあとにレイアウトのサイズを変数に入れます。

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)
}
}
}
}


var largeSide: LargeSide? = null

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

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


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

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

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

先ほど出した最大公約数を使ってSurfaceViewの大きさを計算します。

映像のアスペクト比 * Frameの最大公約数をすると縦横のサイズが確定します。

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を設定します。


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


さいごに

このようにSurfaceView自体の大きさを調整することによって、縦画面でも横画面でも表示の崩れないカメラを作ることが出来るようになります。



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)
}
}
}
}
}





  1. ウイッシュリストはこちらになります。