cotentProvider에서 데이터를 검색하려면 애플리케이션에 해당 provider에 대한 '읽기 권한'이 필요합니다.
//manifest
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
//READ_EXTERNAL_STORAGE는 런타임권한이므로 사용자에게 직접 권한 요청해야한다.
private fun requestPermission() {
val locationResultLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
if (!it) {
Toast.makeText(this, "스토리지에 접근 권한을 허가해주세요", Toast.LENGTH_SHORT).show()
}
}
locationResultLauncher.launch(
android.Manifest.permission.READ_EXTERNAL_STORAGE
)
}
contentProvider란?
- contentProvider는 중앙 저장소로의 데이터 액세스를 관리합니다.
- 다른 애플리케이션에서 기존 콘텐츠 제공자에 액세스하기 위한 코드를 구현
👉 지금 우리가 하고자 하는 것(내 앱에서 기존 contentProvider(Media provider)에 접근)- 내 애플리케이션에서 새로운 콘텐츠 제공자를 생성하여 다른 애플리케이션과 데이터를 공유하고자 할 수 있습니다.
- contentProvider는 외부 애플리케이션에 데이터를 표시하며, 이때 데이터는 관계형 데이터베이스에서 찾을 수 있는 테이블과 유사한 하나 이상의 테이블로 표시됩니다.
행은 제공자가 수집하는 특정 데이터 유형의 인스턴스를 나타내고, 행의 각 열은 인스턴스에 대해 수집된 개별 데이터를 나타냅니다.
ContentProvider 접근 방법
- contentProvider 내의 데이터에 액세스하고자 하는 경우, 애플리케이션의 Context에 있는 ContentResolver 객체를 사용하여 클라이언트로서 contentProvider과 통신을 주고받으면 됩니다.
ㅤ
ContentResolver 객체가 contentProvider 객체와 통신하며, 이 객체는 ContentProvider를 구현하는 클래스의 인스턴스입니다. contentProvider 객체가 클라이언트로부터 데이터 요청을 받아 요청된 작업을 실행하고 결과를 반환합니다.
ㅤ
UI에서 ContentProvider에 액세스하기 위한 일반적인 패턴에서는 CursorLoader를 사용하여 백그라운드에서 비동기식 쿼리를 실행합니다. UI의 Activity 또는 Fragment가 쿼리에 대해 CursorLoader를 호출하고 ContentResolver를 사용하여 ContentProvider를 가져옵니다. 이렇게 하면 쿼리를 실행하는 동안 사용자에게 UI를 계속 제공할 수 있습니다.
ㅤ- 사진 속 CursorLoader는 api 28에서 deprecated 되었다. 공식문서에 따르면 'CursorLoad는 AsyncTaskLoader를 기반으로 하여 애플리케이션의 UI를 차단하지 않도록 백그라운드 스레드에서 커서 쿼리를 수행합니다.' 즉, 비동기 쿼리작업을 위해 필요한 클래스 이다. 그런데 deprecated 되었으니 코루틴 사용 등 그냥 적절한 방식으로 비동기 처리를 해주면 될 것 같다.
👇아래는 공식문서 설명
명확한 이해를 돕기 위해 이 섹션의 코드 스니펫은 'UI 스레드'에서 ContentResolver.query()를 호출합니다. 그러나 실제 코드에서는 별도의 스레드에서 비동기식으로 쿼리를 실행해야 합니다. 이를 위한 한 가지 방식으로 CursorLoader 클래스를 사용하는 방식이 있습니다. 이 내용은 로더 가이드에서 더 자세히 설명합니다. 또한 이 코드는 스니펫일 뿐이며 애플리케이션을 전체적으로 표시한 것이 아닙니다.
ㅤ- ContentResolver.query()를 호출합니다. query() 메서드는 사용자 사전 제공자가 정의한 ContentProvider.query() 메서드를 호출합니다.
private fun getCursor(): Cursor? {
//커서란?
//ContentResolver.query() 클라이언트 메서드는 언제나 쿼리 선택 기준과 일치하는 행에 대해 쿼리 프로젝션이 지정한 열을 포함하는 Cursor를 반환합니다.
//데이터베이스 쿼리에서 반환된 결과 테이블의 행들을 가르키는 것
//이 인터페이스는 데이터베이스 쿼리에서 반환된 결과 집합에 대한 임의의 읽기-쓰기 액세스를 제공합니다.
val projection = arrayOf(
MediaStore.Images.ImageColumns._ID,
MediaStore.Images.ImageColumns.TITLE,
MediaStore.Images.ImageColumns.DATE_TAKEN
) //mediaStore provider의 사진의 id, title, date_taken을 가져오겠다.
//가져오고 싶은 행 Filter 하는 법
//val selection = "${MediaStore.Images.ImageColumns.DATE_TAKEN} >= ?"
//? 이후에 찍힌 것만
//val selectionArgs = arrayOf(
//dateToTimestamp(day = 1, month = 1, year = 1970).toString()) //?는 1970년 1월 1일
//모두 가져오고 싶으면 selection과 selectionArgs에 null을 넣어주면 된다.
val selection = null
val selectionArgs = null
val sortOrder = "${MediaStore.Images.ImageColumns.DATE_TAKEN} DESC" //내림차순
//"${MediaStore.Images.ImageColumns.DATE_TAKEN} ASC" //오름차순
val cursor = contentResolver.query(
//Uri: 찾고자하는 데이터의 Uri입니다. 접근할 앱에서 정의됨. 내 앱에서 만들고 싶다면 manifest에서 만들 수 있음.
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
//Projection: 일반적인 DB의 column과 같습니다. 결과로 받고 싶은 데이터의 종류를 알려줍니다. (표1.에서는 각 행에 포함 되어야 하는 열의 배열이다.)
projection,
//Selection: DB의 where 키워드와 같습니다. 어떤 조건으로 필터링된 결과를 받을 때 사용합니다. (표1. 에서는 행을 선택하는 기준)
selection,
//Selection args: Selection과 함께 사용됩니다. SELECT 절에 있는 ? 자리표시자를 대체합니다.
selectionArgs,
//SortOrder: 쿼리 결과 데이터를 sorting할 때 사용합니다.(반환된 Cursor 내에 행이 나타나는 순서를 지정합니다.)
sortOrder
)
//1건만 가져오려면?
//Uri 및 Uri.Builder 클래스에는 문자열에서 올바른 형식의 URI 객체를 구성하기 위한 편의 메서드가 포함되어 있습니다.
//Uri.Builder는 URI 참조를 빌드하거나 조작하기 위한 도우미 클래스입니다.
//appendQueryParameter : Encodes the key and value and then appends the parameter to the query string.
//val queryUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
//queryUri.buildUpon().appendQueryParameter("limit", "1").build()
return cursor
}
private fun getImage() {
//내부 오류가 발생하는 경우, 쿼리 결과는 특정 제공자에 따라 달라집니다. null을 반환하기로 선택할 수도 있고, Exception을 발생시킬 수도 있습니다.
//따라서 try catch & try 내에서도 cursor이 null로 반환되는 경우를 모두 처리해줌.
lifecycleScope.launch { //비동기 처리
try {
val cursor = getCursor()
when (cursor?.count) {
null -> {
/*
* 에러 대응 코드 작성. cursor 사용하지 말것!!
* You may want to call android.util.Log.e() to log this error.
*
*/
}
0 -> {
/*
*사용자에게 검색이 실패했음을 알리려면 여기에 코드를 삽입하십시오.
* 무조건 에러는 아니다. 테이블을 못찾은게 아니라 말 그대로 테이블에 행이 0개 일 수도.
* 사용자에게 새 항목을 삽입할 수 있는 옵션을 제공할 수 있습니다.
* 행 또는 검색어를 다시 입력하십시오.
*/
}
else -> {
//결과가 1개이상 검색 됨
//커서를 맨 앞으로 이동.
//true를 반환해야 데이터가 있는 것임.
while (cursor.moveToNext()) {
//1. 각 컬럼의 열 인덱스를 취득한다.
val idColNum =
cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)
val titleColNum =
cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.TITLE)
val dateTakenColNum =
cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)
//2. 인덱스를 바탕으로 각 행의 열 값(마지막 행에 도달할 때 까지 1행의 id,title,dateTaken, 2행의 id,title,dateTaken...)을 Cursor로부터 취득하기
val id = cursor.getLong(idColNum)
val title = cursor.getString(titleColNum)
val dateTaken = cursor.getLong(dateTakenColNum)
/*Cursor를 통해 얻은 ID로 Uri 정보를 얻을 수 있습니다.
쿼리를 요청한 Uri와 파일의 ID가 다음과 같이 주어졌다면,
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI : content://media/external/audio/media
File ID : 13492
이 파일의 Uri는 다음처럼 두개의 스트링을 합친 값이 됩니다.
content://media/external/audio/media/13492
String이 아닌 Uri 객체로 얻으려면 다음처럼 Uri.withAppendedPath()를 이용하시면 됩니다.*/
val imageUri =
ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
imageList.add(imageUri)//recylcerview에 넣기 위한 uri list
Log.d(
"LOGGING",
"id: ${id}, title:$title, dateTaken : $dateTaken, imageUri : $imageUri"
)
}
cursor.close() //사용한 cursor는 꼭 close 해줘야함
adapter.setImageList(imageList)
}
}
}
} catch (e: Exception) {
//에러 대응 코드 작성
Toast.makeText(this, "스토리지에 접근 권한을 허가해주세요", Toast.LENGTH_SHORT).show()
finish()
}
}
//cursor의 값을 가공하지 않고 바로 ui에 띄우고 싶다면 simpleCursorAdapter를 이용해서 listView에 띄우면 된다.
//simpleCursorAdpater : 커서의 열을 XML 파일에 정의된 TextView 또는 ImageView로 매핑하는 간편한 어댑터입니다.
Content URI
- 콘텐츠 URI는 ContentProvider에서 데이터를 식별하는 URI입니다. 콘텐츠 URI에는 전체 제공자의 상징적인 이름(제공자의 권한)과 테이블을 가리키는 이름(경로)이 포함됩니다. 제공자 내의 테이블에 액세스하기 위해 클라이언트 메서드를 호출하는 경우, 테이블의 콘텐츠 URI는 인수 중 하나가 됩니다.
ㅤ- 앞의 코드에는 상수 CONTENT_URI에 사용자 사전 '단어' 테이블의 콘텐츠 URI가 포함되어 있습니다. ContentResolver 객체가 이 URI의 권한을 파싱한 다음, 이를 이용해 제공자를 '확인'합니다. 즉 이 권한을 알려진 제공자로 이루어진 시스템 테이블과 비교하는 것입니다. 그러면 ContentResolver가 쿼리 인수를 올바른 제공자에게 발송할 수 있습니다.
ㅤ
ContentProvider는 콘텐츠 URI의 경로 부분을 사용하여 액세스할 테이블을 선택합니다. 일반적으로 제공자에는 제공자가 노출하는 테이블마다 경로가 있습니다.
ㅤ
(대충 contentResolver가 uri의 경로를 보고 적절한 테이블-provider-을 찾아 갈 수 있다는 내용)ㅤ
ㅤ
이전의 코드에서 '단어' 테이블의 전체 URI는 다음과 같습니다.content://user_dictionary/words
- 여기에서 user_dictionary 문자열은 제공자의 권한이고 words 문자열은 테이블의 경로입니다. 문자열 content://(구성표)는 항상 표시되며, 이를 콘텐츠 URI로 식별합니다.
ㅤ
대다수의 제공자에서는 URI의 맨 끝에 ID 값을 추가하면 테이블 내 하나의 행에 액세스할 수 있습니다. 예를 들어 사용자 사전에서 _ID가 4인 행을 검색하려면 이 콘텐츠 URI를 사용하면 됩니다. 일련의 행을 검색한 다음, 그중 하나를 업데이트하거나 삭제하고자 하는 경우 종종 ID 값을 사용합니다.val singleUri: Uri = ContentUris.withAppendedId(UserDictionary.Words.CONTENT_URI, 4)
cursor을 순회하면서 얻은 이미지 uri를 list에 add 해놓고, 그 list를 recyclerview에 뿌려주는 형식으로 구현했습니다.
테스트 폰에서는 이미지가 몇개 없어서 괜찮았는데 , 이미지가 엄청 많은 경우 어떻게 될지 궁금합니다.. 비동기로 처리하니까 별로 상관없나?
//MainActivity.kt
private fun setAdapter() {
binding.recyclerView.adapter = adapter
// adapter.setImageList(imageList)
}
//adapter.kt
package com.eunji.privatestudy
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.eunji.privatestudy.databinding.ItemGalleryBinding
class GalleryAdapter : RecyclerView.Adapter<GalleryAdapter.GalleryViewHolder>() {
private val imageList = mutableListOf<Uri>()
fun setImageList(list: List<Uri>) {
imageList.addAll(list)
}
inner class GalleryViewHolder(private val binding: ItemGalleryBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind() {
val item = imageList[absoluteAdapterPosition]
Glide.with(itemView)
.load(item)
.into(binding.imageView)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GalleryViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = ItemGalleryBinding.inflate(inflater)
return GalleryViewHolder(binding)
}
override fun onBindViewHolder(holder: GalleryViewHolder, position: Int) {
holder.bind()
}
override fun getItemCount(): Int = imageList.size
}
//item_gallery.xml
<?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">
<data>
</data>
<androidx.appcompat.widget.AppCompatImageView
android:background="@color/gray_lighter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/image_view" />
</layout>
//activity_main.xml
<?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">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recyclerView"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>