에르노트
안드로이드 카메라 프리뷰 예제(Camera2 API, 코틀린) 본문
안드로이드 초창기(킷캣 이전)에 카메라 프리뷰를 다루는 예제는 매우 간단했다. 그런데 5.0롤리팝에 오면서 기존 android.hardware.camera가 Deprecated 되버리고 새롭게 더 복잡한 camera2로 대체되었다. 더불어 6.0마시멜로에 와서는 권한 체크가 강화되면서 더 신경쓸 일이 많아져서 이전과는 완전히 새로운 코드가 필요하게 되었다. 그래서 최신 트렌드에 맞추어 camera2 예제를 코틀린으로 리팩토링 해보았다.
우선 카메라2 공식문서는 여기를 확인하면 된다. 그리고 본 예제는 이 블로그의 예제를 기반으로 작성되었다.
카메라에 보이는 것들은 SurfaceView를 통해 표현된다. 일반 뷰의 경우 뷰를 그리는 일은 메인 스레드에서 담당한다. 그러나 카메라가 표현하는 프리뷰는 실시간으로 변하기 때문에 메인 스레드가 담당하면 ANR이 발생할 확률이 매우 높다. 따라서 백그라운드 스레드에서 이미지 처리를 해주고 그 데이터를 전달받아 보여줄 수 있는 뷰인 SurfaceView가 활용된다. 그래서 SurfaceView는 껍데기인셈이고 SurfaceHolder를 통해 제어해줘야 한다.
그래서 할 일을 정리하면,
1. 카메라 권한 관련 작업
2. SurfaceView 생성
3. SurfaceHolder Callback 구현
4. CameraManager에서 카메라 열기
5. 앞뒤 전환 버튼 구현
정도가 될 것이다.
1. 카메라 권한 관련 작업
우선 매니페스트에 권한을 명시한다.
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
그리고 권한을 확인하는 LaunchActivity의 intent-filter를 걸어주고 MainActivity는 그냥 선언한다.
<activity android:name=".LaunchActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".MainActivity"/>
이어서 LaunchActivity에서는 카메라 권한이 부여되었는지 확인하여 메인액티비티를 실행하거나 에러로그를 띄운다. 권한 확인을 위해서 TedPermission 라이브러리를 이용했다.
LaunchActivity.kt
import android.Manifest
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.TedPermission
class LaunchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_launch)
TedPermission.with(this)
.setPermissionListener(object : PermissionListener{
override fun onPermissionGranted() {
startActivity(Intent(this@LaunchActivity, MainActivity::class.java))
finish()
}
override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
for(i in deniedPermissions!!)
i.showErrLog()
}
})
.setDeniedMessage("앱을 실행하려면 권한을 허가하셔야합니다.")
.setPermissions(Manifest.permission.CAMERA)
.check()
}
}
2. SurfaceView 생성
xml 상에서 FrameLayout에다가 SurfaceView를 넣어준다. 앞뒤 전환을 위한 이미지 버튼도 하나 넣어준다.
main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageButton
android:id="@+id/btn_convert"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
app:srcCompat="@android:drawable/ic_menu_rotate" />
</FrameLayout>
3. SurfaceHolder Callback 구현
SurfaceHolder.Callback 인터페이스를 구현한다. surfaceCreated(), surfaceDestroyed(), surfaceChanged()를 재정의해야 한다. 각각 뷰 생성 시점, 뷰 소멸 시점, 뷰 변동 시점(화면 회전 등)에 호출된다.
mSurfaceViewHolder = surfaceView.holder
mSurfaceViewHolder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
initCameraAndPreview()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
mCameraDevice.close()
}
override fun surfaceChanged(
holder: SurfaceHolder, format: Int,
width: Int, height: Int
) {
}
})
fun initCameraAndPreview() {
val handlerThread = HandlerThread("CAMERA2")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
openCamera()
}
4. CameraManager에서 카메라 열기
이전 camera api에서는 Camera.Open()이었지만 camera2 api에서는 CamaeraManager의 메소드인 openCamera()를 이용한다. 인자로는 카메라 아이디, 콜백, 핸들러를 갖는다. 여기서 카메라 아이디란 전면, 후면, 기타(요즘에는 카메라가 3개 이상인 디바이스도 종종 있으니)를 의미한다.
private fun openCamera() {
try {
val mCameraManager = this.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val characteristics = mCameraManager.getCameraCharacteristics(mCameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val largestPreviewSize = map!!.getOutputSizes(ImageFormat.JPEG)[0]
setAspectRatioTextureView(largestPreviewSize.height, largestPreviewSize.width)
mImageReader = ImageReader.newInstance(
largestPreviewSize.width,
largestPreviewSize.height,
ImageFormat.JPEG,
7
)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) return
mCameraManager.openCamera(mCameraId, deviceStateCallback, mHandler)
} catch (e: CameraAccessException) {
toast("카메라를 열지 못했습니다.")
}
}
private val deviceStateCallback = object : CameraDevice.StateCallback() {
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
override fun onOpened(camera: CameraDevice) {
mCameraDevice = camera
try {
takePreview()
} catch (e: CameraAccessException) {
e.printStackTrace()
}
}
override fun onDisconnected(camera: CameraDevice) {
mCameraDevice.close()
}
override fun onError(camera: CameraDevice, error: Int) {
toast("카메라를 열지 못했습니다.")
}
}
@Throws(CameraAccessException::class)
fun takePreview() {
mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
mPreviewBuilder.addTarget(mSurfaceViewHolder.surface)
mCameraDevice.createCaptureSession(
listOf(mSurfaceViewHolder.surface, mImageReader.surface), mSessionPreviewStateCallback, mHandler
)
}
5. 앞뒤 전환 버튼 구현
먼저 버튼 클릭 리스너를 달아준다.
btn_convert.setOnClickListener { switchCamera() }
카메라 아이디는 스트링으로 받는다. "0"이 후면카메라고, "1"이 전면카메라다. 따라서 버튼을 누를 때마다 아이디를 바꾸고 카메라를 닫았다가 다시 열어주면 전/후면 카메라가 전환된다.
private fun switchCamera() {
when(mCameraId){
CAMERA_BACK -> {
mCameraId = CAMERA_FRONT
mCameraDevice.close()
openCamera()
}
else -> {
mCameraId = CAMERA_BACK
mCameraDevice.close()
openCamera()
}
}
}
MainActivity 전체 코드는 아래와 같다.
MainActivity.kt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.camera2.*
import android.media.ImageReader
import android.os.*
import androidx.appcompat.app.AppCompatActivity
import android.util.DisplayMetrics
import android.util.Log
import android.util.SparseIntArray
import android.view.SurfaceHolder
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.exifinterface.media.ExifInterface
import kotlinx.android.synthetic.main.activity_main.*
import splitties.toast.toast
//Camera2 Document: https://developer.android.com/reference/android/hardware/camera2/package-summary
//Reference: https://webnautes.tistory.com/822
class MainActivity : AppCompatActivity() {
private lateinit var mSurfaceViewHolder: SurfaceHolder
private lateinit var mImageReader: ImageReader
private lateinit var mCameraDevice: CameraDevice
private lateinit var mPreviewBuilder: CaptureRequest.Builder
private lateinit var mSession: CameraCaptureSession
private var mHandler: Handler? = null
private lateinit var mAccelerometer: Sensor
private lateinit var mMagnetometer: Sensor
private lateinit var mSensorManager: SensorManager
private val deviceOrientation: DeviceOrientation by lazy { DeviceOrientation() }
private var mHeight: Int = 0
private var mWidth:Int = 0
var mCameraId = CAMERA_BACK
companion object
{
const val CAMERA_BACK = "0"
const val CAMERA_FRONT = "1"
private val ORIENTATIONS = SparseIntArray()
init {
ORIENTATIONS.append(ExifInterface.ORIENTATION_NORMAL, 0)
ORIENTATIONS.append(ExifInterface.ORIENTATION_ROTATE_90, 90)
ORIENTATIONS.append(ExifInterface.ORIENTATION_ROTATE_180, 180)
ORIENTATIONS.append(ExifInterface.ORIENTATION_ROTATE_270, 270)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 상태바 숨기기
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
// 화면 켜짐 유지
window.setFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
)
setContentView(R.layout.activity_main)
initSensor()
initView()
}
private fun initSensor() {
mSensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
mMagnetometer = mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
}
private fun initView() {
with(DisplayMetrics()){
windowManager.defaultDisplay.getMetrics(this)
mHeight = heightPixels
mWidth = widthPixels
}
mSurfaceViewHolder = surfaceView.holder
mSurfaceViewHolder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
initCameraAndPreview()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
mCameraDevice.close()
}
override fun surfaceChanged(
holder: SurfaceHolder, format: Int,
width: Int, height: Int
) {
}
})
btn_convert.setOnClickListener { switchCamera() }
}
private fun switchCamera() {
when(mCameraId){
CAMERA_BACK -> {
mCameraId = CAMERA_FRONT
mCameraDevice.close()
openCamera()
}
else -> {
mCameraId = CAMERA_BACK
mCameraDevice.close()
openCamera()
}
}
}
fun initCameraAndPreview() {
val handlerThread = HandlerThread("CAMERA2")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
openCamera()
}
private fun openCamera() {
try {
val mCameraManager = this.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val characteristics = mCameraManager.getCameraCharacteristics(mCameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val largestPreviewSize = map!!.getOutputSizes(ImageFormat.JPEG)[0]
setAspectRatioTextureView(largestPreviewSize.height, largestPreviewSize.width)
mImageReader = ImageReader.newInstance(
largestPreviewSize.width,
largestPreviewSize.height,
ImageFormat.JPEG,
7
)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) return
mCameraManager.openCamera(mCameraId, deviceStateCallback, mHandler)
} catch (e: CameraAccessException) {
toast("카메라를 열지 못했습니다.")
}
}
private val deviceStateCallback = object : CameraDevice.StateCallback() {
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
override fun onOpened(camera: CameraDevice) {
mCameraDevice = camera
try {
takePreview()
} catch (e: CameraAccessException) {
e.printStackTrace()
}
}
override fun onDisconnected(camera: CameraDevice) {
mCameraDevice.close()
}
override fun onError(camera: CameraDevice, error: Int) {
toast("카메라를 열지 못했습니다.")
}
}
@Throws(CameraAccessException::class)
fun takePreview() {
mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
mPreviewBuilder.addTarget(mSurfaceViewHolder.surface)
mCameraDevice.createCaptureSession(
listOf(mSurfaceViewHolder.surface, mImageReader.surface), mSessionPreviewStateCallback, mHandler
)
}
private val mSessionPreviewStateCallback = object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
mSession = session
try {
// Key-Value 구조로 설정
// 오토포커싱이 계속 동작
mPreviewBuilder.set(
CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
)
//필요할 경우 플래시가 자동으로 켜짐
mPreviewBuilder.set(
CaptureRequest.CONTROL_AE_MODE,
CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
)
mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mHandler)
} catch (e: CameraAccessException) {
e.printStackTrace()
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {
Toast.makeText(this@MainActivity, "카메라 구성 실패", Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
mSensorManager.registerListener(
deviceOrientation.eventListener, mAccelerometer, SensorManager.SENSOR_DELAY_UI
)
mSensorManager.registerListener(
deviceOrientation.eventListener, mMagnetometer, SensorManager.SENSOR_DELAY_UI
)
}
override fun onPause() {
super.onPause()
mSensorManager.unregisterListener(deviceOrientation.eventListener)
}
private fun setAspectRatioTextureView(ResolutionWidth: Int, ResolutionHeight: Int) {
if (ResolutionWidth > ResolutionHeight) {
val newWidth = mWidth
val newHeight = mWidth * ResolutionWidth / ResolutionHeight
updateTextureViewSize(newWidth, newHeight)
} else {
val newWidth = mWidth
val newHeight = mWidth * ResolutionHeight / ResolutionWidth
updateTextureViewSize(newWidth, newHeight)
}
}
private fun updateTextureViewSize(viewWidth: Int, viewHeight: Int) {
Log.d("ViewSize", "TextureView Width : $viewWidth TextureView Height : $viewHeight")
surfaceView.layoutParams = FrameLayout.LayoutParams(viewWidth, viewHeight)
}
}
끝으로 화면 회전이나 유틸 코드 등을 포함한 전체 소스는 이곳에 올려두었다.
'Dev > Android' 카테고리의 다른 글
리사이클러뷰 깜빡임 현상 제거 [setItemViewCacheSize()] (1) | 2020.03.03 |
---|---|
[안드로이드] 다른 앱에서 공유 목록에 내 앱 띄우기 (0) | 2020.02.12 |
Room 라이브러리 어노테이션 에러 해결 (0) | 2020.01.28 |
Anko를 대체할 안드로이드 확장 라이브러리 Splitties (1) | 2020.01.20 |