GDSC HUFS 4기/Kotlin Team #6

[6팀]드로잉 앱("취소 버튼과 기능 추가하기"부터 "이메일, 왓츠앱 등으로 이미지를 공유하는 공유 기능"까지)

burberryhills 2022. 11. 20. 17:57

이 글은 이것이 안드로이드다 with 코틀린(개정판)를 참고하여 작성하였습니다.

작성자 : 김태경

개발환경은 Windows, Android Studio입니다.


취소버튼 구현

private val mUndoPaths = ArrayList<CustomPath>()

fun onClickUndo(){
		if(mPath.size > 0){
				mUndoPaths.add(mPaths.removeAt(mPaths.size - 1)) // mPaths에서 아이템을 지움. -> 지운 아이템을 받아서 mUndoPaths에 추가.
				invalidate() // onDraw 불러옴.
		}
}

val ibUndo : ImageButton = findViewById(R.id.ib_undo)
ibUndo.setOnClickListner {
		drawingView?.onClickUndo()
}

코루틴

  • 안드로이드에서는 작업이 완료될 때까지 UI가 차단됨. 긴 시간이 걸리는 작업의 경우, 안드로이드는 UI 시스템을 최대 5초까지만 지연되도록 만듦.
  • 하지만, 코루틴을 사용하면 오래 걸리는 작업을 백그라운드의 다른 스레드로 넘겨서 UI 스레드가 차단되지 않고 계속 실행돼서 사용자가 막힘없이 앱을 사용하도록 만들 수 있음!

코루틴의 기능

  • 경량: 코루틴을 실행 중인 스레드를 차단하지 않는 정지를 지원하므로 단일 스레드에서 많은 코루틴을 실행할 수 있음. 정지는 많은 동시 작업을 지원하면서도 차단보다 메모리를 절약함.
  • 메모리 누수 감소: 구조화된 동시 실행을 사용하여 범위 내에서 작업을 실행함.
  • 기본으로 제공되는 취소 지원: 실행 중인 코루틴 계층 구조를 통해 자동으로 취소가 전달됨.
  • Jetpack 통합: 많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는 확장 프로그램이 포함되어 있음. 일부 라이브러리는 구조화된 동시 실행에 사용할 수 있는 자체 코루틴 범위도 제공함.

코루틴 만들기

  • build.gradle에서 dependencies에 implementation “androidx.activity:activity-ktx:1.6.0 추가.
  • import kotlinx.coroutines.Dispatchers , import kotlinx.coroutines.withContext
  • suspend 키워드를 사용한다. → function이 UI를 방해하는 것을 멈추게 함.
val btnExecute: Button = findViewById(R.id.btn_execute)
btnExecute.setOnClickListner {
		lifecycleScope.launch{ // lifecycleScope 범위(망가지면 자동으로 취소)에서 실행하도록 설정
				execute("Task executed successfully")
		}
}

private suspend fun execute(result:String){
		withContext(Dispatchers.ID){ // 프로세스가 완료될 때까지 작업을 다른 스레드로 이동시키는 메서드
				for(i in 1..100000){
						Log.e("delay : ", "" + i)
				}
				runOnUiThread { // Dispatch.IO 스레드에서 실행 X -> runOnUiThread 메서드 호출 -> UI 스레드에서 실행 O  
						Toast.makeText(
									this@MainActivity, result,
									Toast.LENGTH_SHORT
						).show()
				}
		}
}

→ 앱이 중단되지 않고 계속 실행하게 된다.

데이터 쓰기(저장하기)

1. Path 만들기

  • name=”captured” → Path 이름 설정
  • path="Android/data/com.example.kidsdrawingapp/files" → 저장할 위치 설정
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="captured"
        path="Android/data/com.example.kidsdrawingapp/files"/>
</paths>

2. Manifest 파일에서 permission, provider, meta-data 추가

  • permission 추가

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

  • provider 추가
    • authorities → authorities 설정
    • name → provider 이름 설정
    • exported → 다른 앱에서 제공자 사용 여부 설정
    • grantUriPermissions → 애플리케이션 구성요소가 권한으로 보호되는 데이터에서 일회성으로 액세스 여부
  • meta-data 추가
    • name → 일반적으로 android.support.FILE_PROVIDER_PATHS 로 설정
    • resource → 만들어놓은 @xml/Path.xml로 설정
<provider
            android:authorities="com.example.kidsdrawingapp.fileprovider"
            android:name="androidx.core.content.FileProvider"
            android:exported="false"
            android:grantUriPermissions="true"
            >
						<meta-data
								android:name="android.support.FILE_PROVIDER_PATHS"
								android:resource="@xml/path"
								/>

3. bitmap으로 return하는 메서드 만들기

  • view는 저장할 수 없기 때문에 bitmap으로 변환해서 저장해야 한다.
private fun getBitmapFromView(view: View) : Bitmap {
        val returnedBitmap = Bitmap.createBitmap(view.width,
            view.height, Bitmap.Config.ARGB_8888) // view -> bitmap
        val canvas = Canvas(returnedBitmap) // canvas = 그림 표현
        val bgDrawable = view.background // bgDrawable = 배경
        if(bgDrawable != null){ // 배경이 있으면
            bgDrawable.draw(canvas) // canvas를 bgDrawable 위에 그림.
        }else{ // 배경이 없으면
            canvas.drawColor(Color.WHITE) // canvas 컬러 -> 흰색
        }

        view.draw(canvas) // canvas를 view 위에 그림.

        return returnedBitmap
    }

4. bitmap을 기기에 저장하기

  • 코루틴 활용 → bitmap 압축 및 디렉토리에 파일 저장
private suspend fun saveBitmapFile(mBitmap: Bitmap?): String{
        var result = "" // 이미지를 저장할 변수
        withContext(Dispatchers.IO){ // 스레드 추가
            if(mBitmap != null){
                try{ // null 값이 들어오면 오류가 발생할 수 있기 때문에 try 블록 사용
                    val bytes = ByteArrayOutputStream() // 바이트 배열 출력 스트림을 생성, 초기 버퍼 용량 = 32bytes
                    mBitmap.compress(Bitmap.CompressFormat.PNG, 90, bytes) // mBitmap을 compress로 압축(PNG 형태, 퀄리티, 출력 스트림)

                    val f = File(externalCacheDir?.absoluteFile.toString()
                            + File.separator + "KidsDrawingApp_" + System.currentTimeMillis() /1000 + ".png"
                    ) // 디렉토리를 포함하여 이름을 저장

                    val fo = FileOutputStream(f) // 파일에 출력 스트림 적용
                    fo.write(bytes.toByteArray()) // ByteArrayOutputStream()을 가져와서 ByteArray 만듦
                    fo.close() // 파일 출력 스트림 닫음 -> 시스템 리소스 해제

                    result = f.absolutePath // absolutePath를 이용해 결과를 result에 대입

                    runOnUiThread{
                        if(result.isNotEmpty()){ // 보관된 장소 알려줌
                            Toast.makeText(
                                this@MainActivity,
                                "File saved successfully :$result",
                                Toast.LENGTH_SHORT
                            ).show()
                        }else{ // 결과가 비어있다면, 파일 저장이 잘못되었다고 알려줌
                            Toast.makeText(
                                this@MainActivity,
                                "Something went wrong while saving the file.",
                                Toast.LENGTH_SHORT
                            ).show()
                        }
                    }
                }
                catch (e: java.lang.Exception){
                    result = ""
                    e.printStackTrace()
                }
            }
        }
        return result // 문자열 result 반환
    }
  • 세이브 버튼 눌렀을 시, 발생 구현
val ibSave : ImageButton = findViewById(R.id.ib_save)
        ibSave.setOnClickListener{
            if(isReadStorageAllowed()){
                lifecycleScope.launch{ // lifecycleScope 범위(망가지면 자동으로 취소)에서 실행하도록 설정
                    val flDrawingView:FrameLayout = findViewById(R.id.fl_drawing_view_container)
                    saveBitmapFile(getBitmapFromView(flDrawingView))
                }
            }
        }

커스텀 실행 다이얼로그

  • 커스텀 실행 다이얼로그 메서드 정의 및 세이브 과정 중 적절한 부분에 커스텀 실행 다이얼로그를 삽입
private fun showProgressDialog(){ // 커스텀 실행 다이얼로그 보이게 하는 메서드
        customProgressDialog = Dialog(this@MainActivity)
        customProgressDialog?.setContentView(R.layout.dialog_custom_progress)
        customProgressDialog?.show()
    }

private fun cancelProgressDialog(){ // 커스텀 실행 다이얼로그 사라지게 하는 메서드
        if(customProgressDialog != null){
            customProgressDialog?.dismiss()
            customProgressDialog = null
        }
    }
val ibSave : ImageButton = findViewById(R.id.ib_save)
        ibSave.setOnClickListener{
            if(isReadStorageAllowed()){
                showProgressDialog() // 이 부분에 쇼우 커스텀 실행 다이얼로그 메서드 삽입
                lifecycleScope.launch{ 
                    saveBitmapFile(getBitmapFromView(flDrawingView))
                }
            }
        }
private suspend fun saveBitmapFile(mBitmap: Bitmap?): String{
        var result = ""
        withContext(Dispatchers.IO){
            if(mBitmap != null){
                try{
                    val bytes = ByteArrayOutputStream()
                    mBitmap.compress(Bitmap.CompressFormat.PNG, 90, bytes)

                    val f = File(externalCacheDir?.absoluteFile.toString()
                            + File.separator + "KidsDrawingApp_" + System.currentTimeMillis() /1000 + ".png"
                    )

                    val fo = FileOutputStream(f)
                    fo.write(bytes.toByteArray())
                    fo.close()

                    result = f.absolutePath

                    runOnUiThread{
                        cancelProgressDialog() // 이 부분에 캔슬 커스텀 실행 다이얼로그 메서드 삽입
                        if(result.isNotEmpty()){
                            Toast.makeText(
                                this@MainActivity,
                                "File saved successfully :$result",
                                Toast.LENGTH_SHORT
                            ).show()
                        }else{
                            Toast.makeText(
                                this@MainActivity,
                                "Something went wrong while saving the file.",
                                Toast.LENGTH_SHORT
                            ).show()
                        }
                    }
                }
                catch (e: java.lang.Exception){
                    result = ""
                    e.printStackTrace()
                }
            }
        }
        return result
    }

이메일, 왓츠앱 등으로 이미지를 공유하는 공유 기능

private fun shareImage(result:String){
        MediaScannerConnection.scanFile(
            this@MainActivity, arrayOf(result), null
        ) { path, uri ->
						// 이미지가 저장된 이후에 공유 실행
            val shareIntent = Intent()
            shareIntent.action = Intent.ACTION_SEND
            shareIntent.putExtra(
                Intent.EXTRA_STREAM,
                uri
            )
            shareIntent.type =
                "image/png"
            startActivity(
                Intent.createChooser(
                    shareIntent,
                    "Share"
                )
            )
        }
    }