앱 별로 외부 저장소(SD카드)에 접근할 때, Environment.getExternalStorageDirectory()를 사용했었다.
그러나 이 메서드는 API 29 (Android 10)에서 Deprecated되었다.
공식문서에서는 왜 Deprecated되었는지 찾을 수 없었다.
안드로이드 스튜디오에서는 다음과 같이 설명하고있다.
사용자의 프라이버시를 향상시키기 위해서(사생활 보호를 강화하기 위해서), 앱에서 외부 저장소에 직접 접근하는 것은 Deprecated되었다. API 29 (Android 10) 이상을 타겟팅하는 앱부터는 더이상 이 메서드가 반환하는 경로로 바로 접근할 수 없다. |
또한 원래 이미지 저장을 할때 쓰던 MediaStore.Images.Media.insertImage()도
API 29 (Android 10)에서 Deprecated되었다.
안드로이드 스튜디오에서는 따로 구체적인 이유는 설명해주지 않는다.
찾아보니, 안드로이드 10부터는 저장소에 관련해서 Scoped storage 모드로 작동하게 된다고 한다. 이것은
- 저장소 권한의 남용을 막아서 유저의 사적인 데이터를 보호하고 (위에 설명한 프라이버시 보호)
- 파일들이 어떤 앱에 속해있는지 구별해서 파일의 관리를 쉽게하고
- 다른 앱이 특정 앱의 파일에 접근하지 않도록 해서 앱의 데이터를 보호하려는
목적이 있다고 한다.
Android 11부터는 다시 File Path를 허용한다고 한다.
File Path에 대한 자료는 구글에 많으니까 생략하고...
또 유연하게 여러 방법을 알면 좋으므로... 오늘은 Android Q(Android 10, API 29)
이상에서 이미지 저장 기능에 대해 정리해 보겠다.
이미지 저장 기능을 그동안 구현해 본 적이 없었는데, 고려해보게 된 계기가
인스타그램 공유기능 때문이었다.
인스타그램 공유하기 기능은 따로 API를 제공하지 않고,
Intent를 통해서 Instagram App의 패키지를 명시한 후에 현재 외부 저장소에 저장된 이미지의
URI를 넘겨서 공유하게 된다.
따라서 이미지 저장 기능을 고민하게 되었다.
인스타그램 공유 기능은 차후에 정리하도록 하고 다시 이미지 저장으로 돌아와서,
어떻게하면 이미지 저장을 할 수 있을까?
오늘 사용해볼 것은 Bitmap, Canvas를 사용해서 View를 Bitmap에 그린 후에 Bitmap을 png파일로 저장해볼 것이다.
물론 jpeg도 가능하지만, 만일 내가 이미지의 배경을 투명으로 만들고싶다면 png 파일로 만들어야 한다.
둘의 차이는 간단하게 PNG는 비손실압축이라서 원본이 훼손되지 않고, JPEG는 사람의 눈에는 거의 거슬리지 않을 정도로 손실압축이긴 하지만 원본이 훼손되는 이미지 저장 방식이다. 위에서 설명했듯이 PNG 파일이 배경 투명을 지원한다.
Canvas를 통해서 무언가를 그리기 위해서는 4가지 기본적인 구성요소가 있다.
- 픽셀을 기록할 비트맵(ex. 스케치북)
- 비트맵에 무언가를 그릴 캔버스(ex. 그림도구)
- 도형
- 색(Color)
도형으로 그릴 것은 아니고, 이미지의 크기, 기기의 해상도를 계산해서 그릴 것이다.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="androidx.core.content.ContextCompat"/>
<variable
name="viewModel"
type="com.khs.imagesaveexampleproject.model.MainViewModel" />
</data>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/main_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:clipToPadding="false">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/img_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@{ContextCompat.getColor(context, viewModel.background)}"
app:layout_constraintBottom_toBottomOf="@+id/tv"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv"
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_marginTop="20dp"
android:src="@{ContextCompat.getDrawable(context, viewModel.image)}"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@{viewModel.content}"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="@color/black"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv" />
</androidx.constraintlayout.widget.ConstraintLayout>
<EditText
android:id="@+id/et"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="30dp"
android:text="@={viewModel.content}"
android:textColor="@color/black"
android:textSize="25sp"
android:background="@drawable/edittext_background"
android:elevation="20dp"
app:layout_constraintTop_toBottomOf="@+id/img_layout" />
<Button
android:id="@+id/img_change_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="20dp"
android:text="@string/img_change"
android:textStyle="bold"
android:textSize="20sp"
android:background="@drawable/btn_background"
android:onClick="@{viewModel.randomImageSetClick}"
app:layout_constraintTop_toBottomOf="@+id/et"/>
<Button
android:id="@+id/bg_change_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="20dp"
android:text="@string/bg_change"
android:textStyle="bold"
android:textSize="20sp"
android:background="@drawable/btn_background"
android:onClick="@{viewModel.randomBackgroundSetClick}"
app:layout_constraintTop_toBottomOf="@+id/img_change_btn"/>
<Button
android:id="@+id/save_img_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="30dp"
android:text="@string/save_img"
android:textColor="@color/Gold"
android:textStyle="bold"
android:textSize="30sp"
android:background="@drawable/btn_background"
android:onClick="imgSaveOnClick"
app:layout_constraintTop_toBottomOf="@id/bg_change_btn"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</layout>
데이터 바인딩, 뷰 바인딩을 사용해서 레이아웃을 만들었다.
레이아웃에서는
- id가 tv인 텍스트뷰
- id가 iv인 이미지뷰
- viewModel.background 가 중요하다.
package com.khs.imagesaveexampleproject.model
import android.view.View
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.khs.imagesaveexampleproject.R
class MainViewModel : ViewModel() {
//뷰에 나타낼 값들. 라이브데이터 형식
var image = MutableLiveData<Int>()
var background = MutableLiveData<Int>()
val content = MutableLiveData<String>()
//이미지, 배경 리스트들
private var imageList = mutableListOf<Int>()
private var backgroundList = mutableListOf<Int>()
// viewModel에서 onClick메서드 구현
var randomImageSetClick: View.OnClickListener
var randomBackgroundSetClick: View.OnClickListener
init{
image.value = R.drawable.android
background.value = R.color.white
content.value = ""
imageList = mutableListOf(R.drawable.android,
R.drawable.camera,
R.drawable.curry,
R.drawable.fride_rice,
R.drawable.glasses,
R.drawable.ramen,
R.drawable.smile)
backgroundList = mutableListOf(
R.color.white,
R.color.Ivory,
R.color.AntiqueWhite,
R.color.SkyBlue,
R.color.Azure,
R.color.Bisque,
R.color.YellowGreen,
R.color.PaleGoldenrod,
R.color.OrangeRed,
R.color.MediumPurple
)
randomBackgroundSetClick = View.OnClickListener {
val idx = (Math.random() * 10).toInt()
background.postValue(backgroundList[idx])
}
randomImageSetClick = View.OnClickListener {
val idx = (Math.random() * 7).toInt()
image.value = (imageList[idx])
}
}
}
뷰모델 클래스이다.
라이브 데이터 형식을 통해서 자동으로 데이터 바인딩에서 값 변화를 불러올 수 있도록 만들었다.
- image : 보여줄 이미지
- background : 배경 색상
- content : 텍스트
- imageList : 보여줄 이미지 배열
- backgroundList : 나타낼 배경 색상 배열
- randomImageSetClick : 보여줄 이미지를 랜덤하게 하나 고르도록 하는 onClickListener. 뷰모델에서 구현해서 메인액티비티의 코드를 줄이도록 했다.
- randomBackgroundSetClick : 배경 색상을 랜덤하게 하나 고르도록 하는 onClickListener. 뷰모델에서 구현했다.
Manifest에 다음과 같은 권한을 요청한다.
이는 Android 10 이하 버전의 앱에서 이미지를 저장할 때 권한을 요청할 수 있도록 하기위함이다.
package com.khs.imagesaveexampleproject
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColor
import androidx.lifecycle.ViewModelProvider
import com.khs.imagesaveexampleproject.databinding.ActivityMainBinding
import com.khs.imagesaveexampleproject.model.MainViewModel
import java.io.*
import java.lang.Exception
import java.util.jar.Manifest
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
initViewModel()
setUpLifeCycleOwner()
setContentView(binding.root)
}
private fun initViewModel() {
// 뷰모델 초기화 메서드
binding.viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
}
private fun setUpLifeCycleOwner() {
binding.lifecycleOwner = this
/*
* LiveData에서는 LifeCycleOwner만 지정해주면
* invalidateAll() 메서드를호출하지 않아도
* DataBinding에서 ViewModel의 값 변동을 감지하고 View Update를 해준다.
* */
}
//이미지 저장 버튼 클릭 메서드
fun imgSaveOnClick(view: View) {
val bitmap = drawBitmap()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//Q 버전 이상일 경우. (안드로이드 10, API 29 이상일 경우)
saveImageOnAboveAndroidQ(bitmap)
Toast.makeText(baseContext, "이미지 저장이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} else {
// Q 버전 이하일 경우. 저장소 권한을 얻어온다.
val writePermission = ActivityCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
if(writePermission == PackageManager.PERMISSION_GRANTED) {
saveImageOnUnderAndroidQ(bitmap)
Toast.makeText(baseContext, "이미지 저장이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} else {
val requestExternalStorageCode = 1
val permissionStorage = arrayOf(
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)
ActivityCompat.requestPermissions(this, permissionStorage, requestExternalStorageCode)
}
}
}
// 화면에 나타난 View를 Bitmap에 그릴 용도.
private fun drawBitmap(): Bitmap {
//기기 해상도를 가져옴.
val backgroundWidth = resources.displayMetrics.widthPixels
val backgroundHeight = resources.displayMetrics.heightPixels
val totalBitmap = Bitmap.createBitmap(backgroundWidth, backgroundHeight, Bitmap.Config.ARGB_8888) // 비트맵 생성
val canvas = Canvas(totalBitmap) // 캔버스에 비트맵을 Mapping.
val bgColor = binding.viewModel?.background?.value // 뷰모델의 현재 설정된 배경색을 가져온다.
if(bgColor != null) {
val color = ContextCompat.getColor(baseContext, bgColor)
canvas.drawColor(color) // 캔버스에 현재 설정된 배경화면색으로 칠한다.
}
val imageView = binding.iv
val imageViewBitmap = Bitmap.createBitmap(imageView.width, imageView.height, Bitmap.Config.ARGB_8888)
val imageViewCanvas = Canvas(imageViewBitmap)
imageView.draw(imageViewCanvas)
/*imageViewCanvas를 통해서 imageView를 그린다.
*이 때 스케치북은 imageViewBitmap이므로 imageViewBitmap에 imageView가 그려진다.
*/
val imageViewLeft = ((backgroundWidth - imageView.width) / 2).toFloat()
val imageViewTop = ((backgroundHeight - imageView.height) / 2).toFloat()
/*이미지가 그려질 곳 계산. 정 가운데에 ImageView를 그릴것이다.
* 기기의 가로크기 - 이미지의 가로크기 를 2로 나눈 후 왼쪽에 해당 크기만큼 마진을 준다.
* 세로 크기도 마찬가지로 계산해준다.
* */
canvas.drawBitmap(imageViewBitmap, imageViewLeft, imageViewTop, null)
//아래는 TextView. 위에 ImageView와 같은 로직으로 비트맵으로 만든 후 캔버스에 그려준다.
val textView = binding.tv
if(textView.length() > 0) {
//textView가 공백이 아닐때만
val textViewBitmap = Bitmap.createBitmap(textView.width, textView.height, Bitmap.Config.ARGB_8888)
val textViewCanvas = Canvas(textViewBitmap)
textView.draw(textViewCanvas)
val marginTop = (20 * resources.displayMetrics.density).toInt() // 20dp의 마진
val textViewLeft = ((backgroundWidth - textView.width) / 2).toFloat()
val textViewTop = imageViewTop + imageView.height + marginTop
canvas.drawBitmap(textViewBitmap, textViewLeft, textViewTop, null)
}
return totalBitmap
}
//Android Q (Android 10, API 29 이상에서는 이 메서드를 통해서 이미지를 저장한다.)
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveImageOnAboveAndroidQ(bitmap: Bitmap) {
val fileName = System.currentTimeMillis().toString() + ".png" // 파일이름 현재시간.png
/*
* ContentValues() 객체 생성.
* ContentValues는 ContentResolver가 처리할 수 있는 값을 저장해둘 목적으로 사용된다.
* */
val contentValues = ContentValues()
contentValues.apply {
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/ImageSave") // 경로 설정
put(MediaStore.Images.Media.DISPLAY_NAME, fileName) // 파일이름을 put해준다.
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.IS_PENDING, 1) // 현재 is_pending 상태임을 만들어준다.
// 다른 곳에서 이 데이터를 요구하면 무시하라는 의미로, 해당 저장소를 독점할 수 있다.
}
// 이미지를 저장할 uri를 미리 설정해놓는다.
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
try {
if(uri != null) {
val image = contentResolver.openFileDescriptor(uri, "w", null)
// write 모드로 file을 open한다.
if(image != null) {
val fos = FileOutputStream(image.fileDescriptor)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
//비트맵을 FileOutputStream를 통해 compress한다.
fos.close()
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) // 저장소 독점을 해제한다.
contentResolver.update(uri, contentValues, null, null)
}
}
} catch(e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun saveImageOnUnderAndroidQ(bitmap: Bitmap) {
val fileName = System.currentTimeMillis().toString() + ".png"
val externalStorage = Environment.getExternalStorageDirectory().absolutePath
val path = "$externalStorage/DCIM/imageSave"
val dir = File(path)
if(dir.exists().not()) {
dir.mkdirs() // 폴더 없을경우 폴더 생성
}
try {
val fileItem = File("$dir/$fileName")
fileItem.createNewFile()
//0KB 파일 생성.
val fos = FileOutputStream(fileItem) // 파일 아웃풋 스트림
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
//파일 아웃풋 스트림 객체를 통해서 Bitmap 압축.
fos.close() // 파일 아웃풋 스트림 객체 close
sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(fileItem)))
// 브로드캐스트 수신자에게 파일 미디어 스캔 액션 요청. 그리고 데이터로 추가된 파일에 Uri를 넘겨준다.
} catch (e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
메인 액티비티 코드.
코드가 길지만 하나씩 순차적으로 살펴보겠다.
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
initViewModel()
setUpLifeCycleOwner()
setContentView(binding.root)
}
private fun initViewModel() {
// 뷰모델 초기화 메서드
binding.viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
}
private fun setUpLifeCycleOwner() {
binding.lifecycleOwner = this
/*
* LiveData에서는 LifeCycleOwner만 지정해주면
* invalidateAll() 메서드를호출하지 않아도
* DataBinding에서 ViewModel의 값 변동을 감지하고 View Update를 해준다.
* */
}
우선 뷰 바인딩을 사용해서 레이아웃 파일을 인플레이션 했다.
그리고 initViewModel() 메서드를 호출해서 데이터 바인딩의 뷰 모델을 초기화했다.
뷰 모델을 초기화한 후에 뷰 바인딩 객체(binding)의 lifeCycleOwner를 this, MainActivity로 설정했다.
이렇게 할 경우, 데이터 바인딩에서 라이브 데이터의 값 변화를 바로바로 읽어서 뷰를 자동으로 갱신해준다.
lifeCycleOwner는 안드로이드 생명주기를 알고있는 클래스이다.
액티비티 / 프래그먼트는 lifeCycleOwner 인터페이스를 구현하고 있어서, LiveData를 Observe 할 수 있는 것이다.
lifecycleowner를 자기 자신으로 설정하면 LifecycleObserver를 구현하는 구성요소들은 LifecycleOwner를 구현하는 구성요소와 원활하게 작동한다고 되어있다.
//이미지 저장 버튼 클릭 메서드
fun imgSaveOnClick(view: View) {
val bitmap = drawBitmap()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//Q 버전 이상일 경우. (안드로이드 10, API 29 이상일 경우)
saveImageOnAboveAndroidQ(bitmap)
Toast.makeText(baseContext, "이미지 저장이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} else {
// Q 버전 이하일 경우. 저장소 권한을 얻어온다.
val writePermission = ActivityCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
if(writePermission == PackageManager.PERMISSION_GRANTED) {
saveImageOnUnderAndroidQ(bitmap)
Toast.makeText(baseContext, "이미지 저장이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} else {
val requestExternalStorageCode = 1
val permissionStorage = arrayOf(
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)
ActivityCompat.requestPermissions(this, permissionStorage, requestExternalStorageCode)
}
}
}
이미지 저장 버튼 클릭 메서드.
안드로이드 Q 버전에 따라 호출되는 메서드가 다르다.
우선 drawBitmap() 메서드로 현재 화면에 그려진 뷰들로 비트맵을 그린다.
그러고 난 후에는 Q버전에 따라 나뉜다.
- Q버전 이상일 경우 : 특별한 권한이 필요 없다. 바로 saveImageOnAboveAndroidQ()메서드를 호출해서 비트맵을 이미지로 저장한다.
- Q버전 아래일 경우 : 저장소 권한이 필요하게 된다. 만일 저장소 권한이 없을 경우 Manifest에서 요청했던 것처럼, android.Manifest.permission.WRITE_EXTERNAL_STORAGE 권한을 요청한다. 저장소 권한이 있다면saveImageOnUnderAndroidQ() 메서드를 호출해서 비트맵을 이미지로 저장하게 된다.
저장소 권한을 확인하는 checkSelfPermission() 메서드는 Deprecated되어 있었다.
따라서 ActivityCompat의 checkSelfPermission() 메서드로 Manifest에 작성했었던 외부 저장소 권한을 확인한다.
만일 체크한 권한이 PackageManager.PERMISSION_GRANTED와 같다면 외부 저장소 권한이 있다는 뜻이고, 없다면
ActivityCompat.requestPermissions() 메서드로 권한을 요청하게 된다.
이 메서드는 다음과 같은 매개변수를 갖는다.
- ActivityCompat.requestPermissions(액티비티, Array<>형태의 권한, 사용자가 정의한 requestCode)
// 화면에 나타난 View를 Bitmap에 그릴 용도.
private fun drawBitmap(): Bitmap {
//기기 해상도를 가져옴.
val backgroundWidth = resources.displayMetrics.widthPixels
val backgroundHeight = resources.displayMetrics.heightPixels
val totalBitmap = Bitmap.createBitmap(backgroundWidth, backgroundHeight, Bitmap.Config.ARGB_8888) // 비트맵 생성
val canvas = Canvas(totalBitmap) // 캔버스에 비트맵을 Mapping.
val bgColor = binding.viewModel?.background?.value // 뷰모델의 현재 설정된 배경색을 가져온다.
if(bgColor != null) {
val color = ContextCompat.getColor(baseContext, bgColor)
canvas.drawColor(color) // 캔버스에 현재 설정된 배경화면색으로 칠한다.
}
val imageView = binding.iv
val imageViewBitmap = Bitmap.createBitmap(imageView.width, imageView.height, Bitmap.Config.ARGB_8888)
val imageViewCanvas = Canvas(imageViewBitmap)
imageView.draw(imageViewCanvas)
/*imageViewCanvas를 통해서 imageView를 그린다.
*이 때 스케치북은 imageViewBitmap이므로 imageViewBitmap에 imageView가 그려진다.
*/
val imageViewLeft = ((backgroundWidth - imageView.width) / 2).toFloat()
val imageViewTop = ((backgroundHeight - imageView.height) / 2).toFloat()
/*이미지가 그려질 곳 계산. 정 가운데에 ImageView를 그릴것이다.
* 기기의 가로크기 - 이미지의 가로크기 를 2로 나눈 후 왼쪽에 해당 크기만큼 마진을 준다.
* 세로 크기도 마찬가지로 계산해준다.
* */
canvas.drawBitmap(imageViewBitmap, imageViewLeft, imageViewTop, null)
//아래는 TextView. 위에 ImageView와 같은 로직으로 비트맵으로 만든 후 캔버스에 그려준다.
val textView = binding.tv
if(textView.length() > 0) {
//textView가 공백이 아닐때만
val textViewBitmap = Bitmap.createBitmap(textView.width, textView.height, Bitmap.Config.ARGB_8888)
val textViewCanvas = Canvas(textViewBitmap)
textView.draw(textViewCanvas)
val marginTop = (20 * resources.displayMetrics.density).toInt() // 20dp의 마진
val textViewLeft = ((backgroundWidth - textView.width) / 2).toFloat()
val textViewTop = imageViewTop + imageView.height + marginTop
canvas.drawBitmap(textViewBitmap, textViewLeft, textViewTop, null)
}
return totalBitmap
}
뷰를 비트맵으로 그리는 코드.
다음과 같은 로직으로 작동한다.
1. resources.displayMetrics를 통해서 기기의 가로x세로 해상도를 가져온다.
2. totalBitmap이라는 비트맵을 만든다. 이 비트맵은 가장 바탕이 되는 비트맵으로 생각하면 된다. 우리는 이 도화지에 배경색도 넣고, 이미지뷰, 텍스트뷰도 넣을 것이다. ARGB_8888은 투명한 값을 넣을 수 있음을 의미한다. ARGB_565와는 반대이다.
3. canvas라는 캔버스를 만든다. 이 캔버스로 totalBitmap에 그림을 그릴 것이다.
4. 현재 뷰모델에 저장된 배경화면 색상을 bgColor 변수에 저장해 놓는다. 그리고 ContextCompat의 getColor()를 통해서 저장된 Int형 배경색을 Color형으로 바꾼 후에 캔버스로 해당 색을 칠한다.
위에서 설명했듯이 비트맵이 스케치북, 캔버스가 그리는 도구이다. 캔버스로 색칠한다는 것은?
totalBitmap에 색칠한다는 것과 같다.
5. 뷰 바인딩으로 이미지뷰를 가져온다. 이미지뷰비트맵, 캔버스를 만들어서 이미지뷰를 이미지뷰캔버스에 그린다.
뷰를 캔버스에 그릴때는 {view}.draw({canvas})의 형태로 코드를 짜면 된다.
6. 이미지뷰를 그릴 위치를 계산한다. 왼쪽으로부터 얼마나 떨어져 있는지, 위에서부터 얼마나 떨어져 있는지 계산한다.
나는 정 가운데에 이미지 뷰를 그릴 것이다. 따라서 각각 다음과 같다.
- 왼쪽 떨어진 거리 : (기기 가로 크기 - 이미지뷰 가로 크기) / 2
- 위쪽 떨어진 거리 : (기기 세로 크기 - 이미지뷰 세로 크기) / 2
후에 캔버스에 drawBitmap() 메서드를 통해서 이미지뷰 비트맵을 그려준다.
텍스트뷰도 마찬가지로 그려준다. 다른점이라면,
1. 공백이 아닐 경우에만 텍스트뷰를 그린다.
2. 이미지뷰 아래에 그리는데 20dp의 마진을 주도록 설정했다.
3. 그리는 세로의 위치가 이미지뷰의 위 높이 + 이미지뷰 세로 크기 + 20dp이다.
그리고 totalBitmap을 return한다.
//Android Q (Android 10, API 29 이상에서는 이 메서드를 통해서 이미지를 저장한다.)
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveImageOnAboveAndroidQ(bitmap: Bitmap) {
val fileName = System.currentTimeMillis().toString() + ".png" // 파일이름 현재시간.png
/*
* ContentValues() 객체 생성.
* ContentValues는 ContentResolver가 처리할 수 있는 값을 저장해둘 목적으로 사용된다.
* */
val contentValues = ContentValues()
contentValues.apply {
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/ImageSave") // 경로 설정
put(MediaStore.Images.Media.DISPLAY_NAME, fileName) // 파일이름을 put해준다.
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.IS_PENDING, 1) // 현재 is_pending 상태임을 만들어준다.
// 다른 곳에서 이 데이터를 요구하면 무시하라는 의미로, 해당 저장소를 독점할 수 있다.
}
// 이미지를 저장할 uri를 미리 설정해놓는다.
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
try {
if(uri != null) {
val image = contentResolver.openFileDescriptor(uri, "w", null)
// write 모드로 file을 open한다.
if(image != null) {
val fos = FileOutputStream(image.fileDescriptor)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
//비트맵을 FileOutputStream를 통해 compress한다.
fos.close()
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) // 저장소 독점을 해제한다.
contentResolver.update(uri, contentValues, null, null)
}
}
} catch(e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
}
}
Android Q, Android 10 (API 29) 이상에서 이미지를 저장하는 메서드.
파일 이름은 '현재 시간.png'의 형태로 만들어지게 했다.
ContentValues 객체를 하나 생성한다.
ContentValues는 ContentResolver가 처리할 수 있는 값을 저장해둘 목적으로 사용된다.
ContentValues를 통해서 파일이 저장될 경로, 파일 이름, 파일 타입을 설정한다.
이 때 저장 경로는 DCIM, Pictures 등과 같은 경로만 사용 가능하다. 특별히 지정하지 않을 경우에는
Pictures 경로로 지정된다.
그리고 IS_PENDING 이 부분을 1로 만들어준다. 해당 파일을 현재 앱에서 독점한다는 의미이다.
기본으로 제공되는 contentResolver 객체를 통해서 contentValues에 이전에 넣어놨던 값들을 통해 uri를 생성해놓는다.
MediaStore.Images.Media.EXTERNAL_CONTENT_URI로 만들었기 때문에 외부 저장소에 저장할 이미지라는 것을 명시한다.
그리고 이제 파일로 저장할 것이다.
contentResolver 객체의 openFileDescriptor 메서드로 위에 미리 만들어놓은 uri를 write모드로 FileDescriptor를 실행한다.
만일 해당 FileDescriptor가 null이 아니라면, FileOutputStream 객체(이하 fos)를 만든다.
위에서 만들어놓은 image 객체의 fileDescriptor를 매개변수로 사용한다.
image 객체에 저장된 정보로 파일을 쓰겠다는 뜻이다.
fos를 통해 저장된 정보(파일의 정보)에 bitmap을 PNG 포맷으로 compress한 정보를 image 객체에 저장된 정보로 넣게 된다.
다 작성하고 난 후에 contentValues를 clear()한 후에 IS_PENDING을 0으로 만들어서 해당 파일의 독점 권한을 해제한다.
그리고 contentResolver 객체로 update() 메서드를 호출한다. 해당 uri에 contentValues에 담긴 정보로 업데이트 되었음을 알린다.
private fun saveImageOnUnderAndroidQ(bitmap: Bitmap) {
val fileName = System.currentTimeMillis().toString() + ".png"
val externalStorage = Environment.getExternalStorageDirectory().absolutePath
val path = "$externalStorage/DCIM/imageSave"
val dir = File(path)
if(dir.exists().not()) {
dir.mkdirs() // 폴더 없을경우 폴더 생성
}
try {
val fileItem = File("$dir/$fileName")
fileItem.createNewFile()
//0KB 파일 생성.
val fos = FileOutputStream(fileItem) // 파일 아웃풋 스트림
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
//파일 아웃풋 스트림 객체를 통해서 Bitmap 압축.
fos.close() // 파일 아웃풋 스트림 객체 close
sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(fileItem)))
// 브로드캐스트 수신자에게 파일 미디어 스캔 액션 요청. 그리고 데이터로 추가된 파일에 Uri를 넘겨준다.
} catch (e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
}
}
안드로이드 Q 아래 버전에서 비트맵을 이미지로 저장하는 메서드.
파일이름은 마찬가지로 '현재시간.png'로 만들었다.
Environment.getExternalStorageDirectory().absolutePath를 통해서 외부 저장소의 절대경로를 얻어온다.
해당 메서드는 Deprecated 되었지만, Q 아래 버전이므로 문제없다.
그리고 이미지를 저장할 path를 설정해준다.
나같은 경우에는 DCIM 폴더에 imageSave 폴더를 만들어서 저장할 것이다.
dir 변수로 해당 path의 File 객체를 하나 만들어서 해당 경로가 존재하지 않을경우 생성하는 로직을 만든다.
그리고 fileItem이라는 File객체를 만든다. pathName은 dir에 위에서 지정해놓은 fileName을 합친것이다.
물론 중간에 '/'는 빼먹으면 안된다.
그러고 난 후에 createNewFile() 메서드로 해당 파일을 새로 생성한 후에 FileOutputStream 객체를 통해서 비트맵을 해당 파일에 압축한다.
다 진행한 후에 fos를 close()하고, 브로드캐스트를 송신하게 된다. 이는 미디어 스캐너에게 파일 스캔을 요청하는 것이다. (파일 상태가 업데이트 됐음을 알리는 것이다.)
실행화면 - API 30
위는 API 30버전에서 테스트한 화면이다.
특별한 권한 없이도 바로 이미지가 저장이 된다.
실행화면 - API 26
API 26버전에서 테스트한 모습.
권한을 요청하는 창이 나오게 된다.
위 내용들을 구현할 줄 알면,
안드로이드에서 인스타그램 공유하기 기능도 만들어볼 수 있게된다.
다음번에는 이 프로젝트에 인스타그램 공유하기 기능을 추가해 보겠다.
전체 프로젝트 예시 파일은 위에서 확인이 가능하다.
'안드로이드 > 개발 관련' 카테고리의 다른 글
[Kotlin] 안드로이드 웹 뷰 사용해보기 (+@ 다이얼로그로 웹 뷰 띄워보기, 프래그먼트로 웹 뷰 띄워보기) (0) | 2021.10.27 |
---|---|
[Kotlin] 안드로이드 인스타그램 스토리 공유하기 기능 사용해보기. (예제 프로젝트) (3) | 2021.10.25 |
[Kotlin] 안드로이드 리사이클러 뷰 새로고침 기능 사용해보기. SwipeRefreshLayout 사용. (1) | 2021.10.14 |
[Kotlin] 안드로이드 리사이클러뷰 안에 리사이클러뷰 사용해보기, 이중 리사이클러뷰 (Feat. 뷰 바인딩, 데이터 바인딩) (0) | 2021.10.13 |
[Kotlin] 안드로이드 데이터 바인딩, 데이터 결합, Data Binding (0) | 2021.10.12 |