본문 바로가기

Study/Android

Android : Camera

카메라로 촬영한 사진을 이미지뷰에 불러오는 예제입니다.

앱 내부에서는 카메라를 실행할 수 있고, 촬영 후 확인버튼을 누르면 다시 메인 화면에 있는 이미지뷰에 해당 사진을 뿌립니다.

그리고 갤러리에서 불러온 사진을 이미지뷰에 가져올 수 있습니다.

0. 예제를 살펴보기 전에


카메라로 촬영한 사진을 이미지뷰에 띄워준다는 개념을 생각해보아야 합니다.

우선 이 예제에서 구상할 View의 구조는 두개의 버튼과 한개의 이미지뷰입니다.

카메라 실행버튼을 눌렀을 때 카메라가 켜지게 되고 사진을 촬영할 수 있게 됩니다. 셔터를 눌러 사진을 촬영하면 사진이 촬영되고, 카메라에 내장된 "확인"버튼을 누르면 사진이 갤러리에 저장될 것입니다.

이 때 사진의 이름을 특정한 패턴으로 저장해주는 함수가 필요할 것이며, 이 함수에 따라 사진이름이 명명되어 저장됩니다. 사진은 바로 이미지뷰에 출력됩니다.

갤러리 실행 버튼을 눌렀을 때 갤러리가 실행되고 사진을 선택하게 되면 갤러리로부터 사진을 꺼내와서 이미지뷰에 불러옵니다.

이러한 과정은 카메라와 스토리지의 사용 권한을 필요로하며 코드를 통해 구현할 것입니다.

1. 레이아웃 구성을 위한 xml


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity"
>
    <ImageView
            android:id="@+id/avatars"
            android:layout_width="500dp"
            android:layout_height="500dp"
            tools:srcCompat="@tools:sample/avatars"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" 
            app:layout_constraintVertical_bias="0.695"
    />
    <Button
            android:id="@+id/camera"
            android:text="camera"
            android:layout_width="170dp"
            android:layout_height="70dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.193"
            app:layout_constraintVertical_bias="0.196"
    />
    <Button
            android:id="@+id/image"
            android:text="image"
            android:layout_width="170dp"
            android:layout_height="70dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.795"
            app:layout_constraintVertical_bias="0.196"
    />
</androidx.constraintlayout.widget.ConstraintLayout>

0에서 소개한 레이아웃을 구성하기 위해 다음과 같이 activity_main.xml을 작성해 주었습니다.

2. MainActivity 작성


Manifest.xml에 추가할 사항

카메라와 스토리지를 사용하기 위해 <application> 상단에 다음과 같이 추가해 줍니다.

<uses-permission android:name="android.permission.CAMERA"></uses-permission>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"></uses-permission>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"></uses-permission>

필요 변수 선언과 오버라이드

우선 카메라와 스토리지 권한 처리를 위해 필요한 변수를 선언해 줍시다.

CAMERASTORAGE는 배열형태로 권한 부여를 받았을 경우 리턴되는 값을 저장합니다.
자세한 사항은 여기를 참조하세요.

// storage 권한 처리에 필요한 변수
val CAMERA = arrayOf(Manifest.permission.CAMERA)
val STORAGE = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
val CAMERA_CODE = 98
val STORAGE_CODE = 99

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

그리고 두 개의 함수를 재정의해야 합니다. onRequestPermissionsResultonActivityResult를 오버라이드 합니다.

// storage 권한 처리에 필요한 변수
val CAMERA = arrayOf(Manifest.permission.CAMERA)
val STORAGE = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
val CAMERA_CODE = 98
val STORAGE_CODE = 99

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
    }

}

함수 onRequestPermissionsResult는 카메라와 저장소 권한을 취득하기 위한 함수입니다. onActivityResult는 촬영된 사진이나 불러온 사진에 대해 처리할 작업을 수행한 후 결과를 보여줄 함수입니다.

각각의 함수를 구성하여 보겠습니다.

// storage 권한 처리에 필요한 변수
val CAMERA = arrayOf(Manifest.permission.CAMERA)
val STORAGE = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
val CAMERA_CODE = 98
val STORAGE_CODE = 99

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        when(requestCode) {

            CAMERA_CODE -> {
                for(grant in grantResult) {
                    if(grant != PackageManager.PERMISSION_GRANTED) {
                        Toast.makeText(this, "카메라 권한을 승인해 주세요!", Toast.LENGTH_LONG).shot()
                    }
                }
            }

            STORAGE_CODE -> {
                for (grant in grantResults) {
                    if(grant != PackageManager.PERMISSION_GRANTED) {
                        Toast.makeText(this, "저장소 권한을 승인해주세요.", Toast.LENGTH_LONG).show()
                    }
                }
            }
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
    }

}

onRequestPermissionsResultrequestPermissioned에 의해 호출되며 권한 요청의 결과를 판단합니다.
함수의 매개변수인 requestCode에 대해 위에서 선언한 CAMERA_CODESTORAGE_CODE를 케이스로 지정하여 작업을 처리합니다.
바로 requsetCode가 퍼미션 체크를 위해 요청을 보낼 코드이며 grantResults는 요청에 OK했을 때 정보를 담습니다.

grantResults[0]에는 PackageManager.PERMISSION_GRANTED 값이 들어가게 되며, 요청에 거절당했을 때에는 내부에 아무것도 들어가지 않습니다.

그러므로 grantResults 배열을 돌면서 내부에 PackageManager.PERMISSION_GRANTED가 없다면 토스트를 띄워서 카메라 권한 승인 요청을 받으라고 알려줍니다.

CAMERA_CODESTORAGE_CODE는 지정되어 있는 것이므로 각각 98과 99를 사용합니다.

권한 확인을 위한 함수 작성

이제 checkPermission이라는 이름의 함수를 만들어주겠습니다. 이는 Boolean 값을 반환하여 권한이 부여되었는지 아닌지를 체크하게 해줍니다.

fun checkPermission(permissions: Array<out String>, type: Int):Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        for (permission in permissions) {
            if(ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(this, permissions, type)
                return false
            }
        }
    }

    return true
}

여기서 매개변수 permission은 카메라나 스토리지에 대한 사용 권한을 배열로 저장한 형태를 취합니다. type에는 CAMERA_CODE와 같은 접근하기 위해 필요한 숫자가 들어갈 것입니다.

자세한 내용은 여기를 참조해주세요.

안드로이드 SDK버전을 비교해서 6버전보다 상위 버전일 경우 매개변수로 받은 배열을 loop로 순회합니다.

ContextCompat.checkSelfPermission는 권한을 체크하는데, 권한이 있는 경우 PackageManager.PERMISSION_GRANTED를 반환하고 그렇지 않은 경우 PackageManager.PERMISSION_DENIED를 반환합니다.

다시 말해서 ContextCompat.checkSelfPermission 메서드를 통해 권한을 부여받지 못했음을 리턴받은 경우 ActivityCompat.requestPermissions를 통해 사용자로부터 권한을 받도록 합니다. 이후 false를 리턴해줍니다.

카메라 촬영을 위해 필요한 함수 작성

이제 카메라 촬영을 하는데 필요한 함수를 작성해보겠습니다.

fun callCamera() {
    if(checkPermission(CAMERA, CAMERA_CODE) && checkPermission(STORAGE, STORAGE_CODE)) {
        val itt = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        startActivityForResult(itt, CAMERA_CODE)
    }
}

위에서 작성한 checkPermission 함수를 사용해서 카메라와 스토리지 모두 true를 리턴받은 경우에만 Intent를 통해 MediaStore.ACTION_IMAGE_CAPTURE 사진 촬영 화면으로 이동하게 합니다.
그리고 startActivityForResult를 통해 카메라 코드를 전달하여 카메라를 띄워주도록 합니다.

여기서 startActivitystartActivityForResult의 차이를 살펴볼 필요가 있습니다.

startActivity는 단방향으로 새로운 액티비티를 열어주는 역할을 합니다. 인자는 하나만 사용하며, 새로 열어줄 액티비티의 클래스명을 입력해줍니다. startActivityForResult는 새 액티비티를 열어줌과 동시에 결과값을 전달해주는 역할을 합니다. 따라서 startActivityForResult는 두개의 인자를 받으며 넘겨줄 페이지를 첫번째 인자로 받으며 두번째 인자로는 requestCode를 넣어서 다중 액티비티를 사용하는 경우 어떤 액티비티로 전환해 줄 지 결정합니다.

사진을 저장하기 위한 함수

사진을 저장해주기 위한 함수를 작성합니다. ContentValues라는 클래스를 사용합니다.

This class is used to store a set of values that the ContentResolver can process.

이 클래스는 ContentResolver가 처리하는 값의 집합입니다. ContentResolver는 컨텐츠에 대한 엑세스를 제공합니다.

fun saveFile(fileName:String, mimeType:String, bitmap: Bitmap):Uri? {
    val CV = ContentValues()

    // MediaStore에 파일명, mimeType 지정
    CV.put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
    CV.put(MediaStore.Images.Media.MIME_TYPE, mimeType)

    // 안정성 검사
    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        CV.put(MediaStore.Images.Media.IS_PENDING, 1)
    }

    // MediaStore에 파일 저장
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, CV)
    if(uri != null) {
        val scriptor = contentResolver.openFileDescriptor(uri, "w")
        val fos = FileOutputStream(scriptor?.fileDescriptor)

        bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
        fos.close()

        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            CV.clear()
            CV.put(MediaStore.Images.Media.IS_PENDING, 0)   // IS_PENDING을 초기화
            contentResolver.update(uri, CV, null, null)
        }
    }

    return uri
}

ContentValues에 사용하는 put메서드는 다양한 것을 인자로 받을 수 있는데, 여기에서는 put(String key, String value) 형식을 사용합니다.
첫번째 인자는 파일 이름이며 두번째 인자는 데이터 타입입니다. 각각 saveFile 함수의 매개변수를 넣어줍니다.

IS_PENDING은 저장소의 미디어 파일에 접근하기 위한 것으로 공식문서에서 아래와 같이 설명하고 있습니다.

앱이 미디어 파일에 쓰기 작업을 하는 것과 같이 시간이 많이 소요될 수 있는 작업을 실행한다면 작업을 처리하는 동안 파일에 독점적으로 액세스하는 것이 유용합니다. Android 10 이상을 실행하는 기기에서는 앱이 IS_PENDING 플래그 값을 1로 설정하여 이 독점 액세스 권한을 얻을 수 있습니다. IS_PENDING 값을 다시 0으로 변경할 때까지 이 앱에서만 파일을 볼 수 있습니다.

contentResolver를 통해 ContentModel에 접근하여 insert해주며 첫번째 인자로 삽입할 테이블의 URL을 넣어줍니다. 이 값은 null일 수 없습니다.

두번째 인자는 새로 삽입되는 초기값이며 null일 수 있습니다.

이 값이 null이 아닌 경우 openFileDescriptor를 통해 파일을 열어줍니다. 여기에는 열고자 하는 파일의 Uri값과 모드를 넣어줍니다.

모드에 대한 자세한 설명입니다.

Converts a string representing a file mode, such as "rw", into a bitmask suitable for use with open(File, int).
The argument must define at least one of the following base access modes:

  • "r" indicates the file should be opened in read-only mode, equivalent to OsConstants#O\_RDONLY.
  • "w" indicates the file should be opened in write-only mode, equivalent to OsConstants#O\_WRONLY.
  • "rw" indicates the file should be opened in read-write mode, equivalent to OsConstants#O\_RDWR.
  • In addition to a base access mode, the following additional modes may requested:
  • "a" indicates the file should be opened in append mode, equivalent to OsConstants#O\_APPEND. Before each write, the file offset is positioned at the end of the file.
  • "t" indicates the file should be opened in truncate mode, equivalent to OsConstants#O\_TRUNC. If the file already exists and is a regular file and is opened for writing, it will be truncated to length 0.

FileOutputStream을 통해 파일을 출력해줍니다.

compress를 통해 비트맵을 압축할 형식을 지정합니다. 여기서는 PNG로 포맷할 것이며 퀄리티는 100으로 주었습니다. 숫자가 높아질수록 화질이 올라갑니다. 두번째 파라미터로 압축할 사진을 지정합니다.

버전 체크를 통해 IS_PENDING을 0으로 지정하여 초기화해주고 업데이트시켜줍니다.

마지막으로 uri를 리턴해줍니다.

파일명을 날짜로 지정하는 함수

파일명을 날짜로 지정하는 함수를 작성해주겠습니다.

날짜와 시간으로 지정해줄 것입니다.

fun randomFileName():String {
    val fileName= SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(System.currentTimeMillis())
    return fileName
}

갤러리의 경로를 취득하는 함수 작성

fun getAlbumDirectory() {
    if(checkPermission(STORAGE, STORAGE_CODE)) {
        val itt = Intent(Intent.ACTION_PICK)
        itt.type = MediaStore.Images.Media.CONTENT_TYPE
        startActivityForResult(itt, STORAGE_CODE)
    }
}

스토리지의 퍼미션 체크 결과가 true일 경우 ACTION_PICK을 통해 데이터로부터 아이템을 꺼내와서 다른 페이지로 넘어갑니다.
넘어가는 페이지 타입은 Uri로부터 가져온 이미지 컨텐츠입니다.
startActivityForResult(itt, STORAGE_CODE)를 통해 스토리지로 페이지를 전환해줍니다.

오버라이드한 onActivityResult 작성

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    val imageView = findViewById<ImageView>(R.id.avatars)
    if(resultCode == Activity.RESULT_OK) {
        when(requestCode) {
            CAMERA_CODE -> {
                if(data?.extras?.get("data") != null) {
                    val img = data.extras?.get("data") as Bitmap
                    val uri = saveFile(randomFileName(), "image/png", img)
                    imageView.setImageURI(uri)
                    Toast.makeText(this, uri.toString(), Toast.LENGTH_LONG).show()
                }
            }

            STORAGE_CODE -> {
                val uri = data?.data
                imageView.setImageURI(uri)
            }
        }
    }
}

매개변수로 받는 resultCodeOK일 때 요청 코드가 무엇인지에 따라 작업을 다르게 처리합니다.

CAMERA_CODE인 경우 매개변수로 받은 Intent 타입의 data를 비트맵으로 변환시켜주고 이미지파일로 저장시켜 uri를 가져옵니다.
이미지뷰에 이미지 경로를 통해 사진을 넣어줍니다. 그리고 토스트를 띄워 경로를 확인할 수 있습니다.

STORAGE_CODE인 경우 Intent의 데이터를 이미지뷰에 넣어줍니다.

마무리

이제 가장 상단으로 올라와서 onCreate에 작성한 함수를 사용해주겠습니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    // 카메라
    val camera = findViewById<Button>(R.id.camera)
    camera.setOnClickListener {
        callCamera()
    }

    // 사진 저장
    val picture = findViewById<Button>(R.id.image)
    picture.setOnClickListener {
        getAlbumDirectory()
    }
}