본문 바로가기

Android/Kotlin

Gallery 사진 가져와 Paging 처리!!!

반응형

오늘은 AAC-Paging Library를 써봤습니다. 해당 글은 미디움에 써봤습니다!!

 

 

갤러리에서 전체 사진 가져오기

여기서는 구글에 다양한 예제들을 토대로 구성했습니다.

 

갤러리에서 가져온 사진들을 RecyclerView 보여주기

이건 사진들을 가져와 보여주기만 하면 되기 때문에 쉽습니다.

fun getImages(limit: Int? = null, offset: Int? = null): MutableList<PhotoItem> {
        val photos: MutableList<PhotoItem> = mutableListOf()

        val projection = arrayOf(
            MediaStore.Images.Media.DATA,
            MediaStore.Images.Media.DISPLAY_NAME
        )

        val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        val order = MediaStore.Video.Media.DATE_TAKEN
        val sortOrder =
            if (limit == null) "$order DESC"
            else "$order DESC LIMIT $limit OFFSET $offset"

        val imageCursor = contentResolver.query(
            uri, projection, null, null, sortOrder
        )

        if (imageCursor != null) {
            val columnIndex = imageCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
            while (imageCursor.moveToNext()) {
                val imageDataPath = imageCursor.getString(columnIndex)
                photos.add(PhotoItem(imageDataPath))
            }
        }

        return photos
    }

paging 처리하기

이 부분에선 “Paging Library”의 존재를 몰랐기 때문에 고생한 적이 있습니다. 서버에서 데이터를 받아와 paging이 되도록 구성해 보았을 땐 서버에서 몇 개씩 잘라서 보내줘 할 수 있었지만, 여기선 많은 데이터를 가져와 보여줘야 했기에 고민을 했지만, 사수의 도움으로 paging library의 존재를 알았고 그 이후 공부를 시작했습니다. 사수짱!

Paging Library를 사용해 보기 전에 뭐가 어떻게 동작하는지 알아야 하니 공부를 했습니다.

— — — — — — — — — 이 아래 부분은 개념이므로 넘어가셔도 됩니다

Paging Library 란?

앱이 데이터 소스로부터 필요한 정보를 점진적으로 읽어 오는 작업을 쉽게 만들어주는 라이브러리로, 디바이스에 과부하가 걸리거나 큰 DB로부터의 쿼리를 기다리지 않도록 만들어 준다.

PagedList

DataSource로 부터 가져온 불변 데이터 및 페이지에 대한 정보를 들고 있으면서, PagedListAdapter 와 DataSource를 이어주는 중재자

PagedList.Config

PagedList가 DataSource에서 가져오는 데이터의 크기, 미리 불러오는 데이터 크기 등을 정의

DataSource

  1. Network, Memory, DB 등으로부터 페이징 데이터를 질의하는 역할의 추상 클래스
  2. 페이징 리스트 안에 사용되는 데이터를 로딩하기 위한 기본 클래스
  3. DataSource는 서버 혹은 클라이언트가 페이징 하는 방식에 따라서 추상 클래스인 PageKeyedDataSource, ItemKeyedDataSource, PositionalDataSource 중 하나를 상속받도록 디자인되어 있음.

PageKeyedDataSource<Key, Value>

  1. 페이지에 다음 가져올 페이지 시작 키 값이 있는 경우 상속해야 할 DataSource (참고 — 1. 소스 코드 및 설명 참조)
  2. 데이터가 다음, 이전 키를 포함하고 있을 때(ex, nextPageToken 값이 존재할 때 등)
  3. 응답으로 인접 페이지에 대한 정보가 내려올 때 사용 → Cusor-based 기반의 페이징은 가장 많이 사용되는 기법이기 때문에 페이스북, 트위터, 슬랙, 레딧과 같은 개발자 문서에서 이런 형태의 REST API를 어렵지 않게 찾아볼 수 있습니다.

loadAfter : 추가할(스크롤을 아래로 내릴 때) 데이터를 로드하여, 파라미터로 받은 콜백의 onResult 메서드 호출

loadBefore : 이전(스크롤을 위로 올릴 때) 데이터를 로드하여, 파라미터로 받은 콜백의 onResult 메서드 호출

loadInitial : 최초 데이터를 로드하여, 파라미터로 받은 콜백의 onResult 메서드 호출

ItemKeyedDataSource<Key, Value>

  1. 아이템에 다음 가져올 페이지 시작 키 값이 있는 경우 상속해야 할 DataSource (참고 — 1. 소스 코드 및 설명 참조)
  2. N번째 데이터로 N-1 / N+1의 데이터를 가져올 때(ex, 날짜별 정렬, 정렬된 ID 등)
  3. 현재 리스트의 마지막 요소를 이용해서 다음 페이지를 불러오는 페이징 방식에서 사용 → 서버 혹은 클라이언트가 위와 같이 Time-based 기반으로 페이징을 제공한다면 ItemKeyedDataSource를 사용해 페이지를 요청할 수 있습니다.
  4.  PageKeyedDataSource과 차이점은 다음으로 가져와야 할 페이지의 시작 키 값이 네트워크 응답 어디에 있는가의 차이

getKey : 파라미터로 받은 아이템의 키값 반환

PositionalDataSource<T>

  1. 고정된 항목 수를 제공할 수 있는 경우 사용. Room은 PositionalDataSource인 DataSource.Factory를 반환
  2. 특정 위치의 데이터를 가져올 때(ex, 100번째 위치에서 10개의 데이터를 가져올 때)
  3. 페이지 번호 또는 오프셋을 이용하는 페이징 방식에서 사용

loadInitial : 최초 데이터를 로드하여, 파라미터로 받은 콜백의 onResult 메서드 호출

loadRange : 특정 범위의 데이터를 로드하여, 파라미터로 받은 콜백의 onResult 메서드 호출

위 3가지 차이점 :

PageKeyedDataSource, ItemKeyedDataSource는 순차적으로 페이지를 요청할 수밖에 없는 구조

PositionalDataSource는 다음 페이지 호출을 위해 이전 페이지의 정보를 몰라도 되는 오프셋(또는 페이지 번호)을 사용함으로써 병렬적으로 페이지를 요청할 수 있음

PagedListAdapter

페이징을 처리하기 위한 RecyclerView.Adapter

DataSource.Factory

DataSource를 감싸 LivePagedListBuilder 로 넘김. 내부적으로 DataSource의 인스턴스 생성을 제어하기 위함

LivePagedListBuilder

DataSouce.Factory 와 PagedList.Config 혹은 page size 와 같은 설정을 인자로 받아 LiveData<PagedList<Value>> 를 생성

참고 :

https://medium.com/@wickedev/android-architecture-components-paging-간단-정리-69091eb87e78

http://dktfrmaster.blogspot.com/2018/11/paging-library.html

— — — — — — — — — 여기까지

정말 간단히 써 봤습니다. 쉽게 말해서 “앱이 데이터 소스로부터 필요한 정보를 점진적으로 읽어 오는 작업을 쉽게 만들어주는 라이브러리”입니다. 간단히 이론을 공부해 봤으니(저도 이해를 전부 하지는 못했습니다만 그냥 일단 해봤습니다…) 코딩해보겠습니다.

Paging library 사용 방법 크게 아래와 같이 잡았습니다.

  • DataSource와 DataSourceFactory 정의
  • PagedListAdapter를 통한 Adapter 정의
  • ViewModel or Rx 이용한 데이터 정의

DataSource와 DataSourceFactory 정의

데이터를 로딩하는 객체”로 로컬 또는 Backend의 데이터를 가져오는 역할로, 셀 수 있는 데이터, 고정된 사이즈의 데이터를 로딩할 때 좋은 PositionalDataSource를 사용했습니다.

class GalleryDataSource(private val contentResolver: ContentResolver) : PositionalDataSource<PhotoItem>() {
    private val TAG: String = GalleryDataSource::class.java.simpleName

    override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<PhotoItem>) {
        Log.i(TAG, "loadInitial start: ${params.requestedStartPosition}, size: ${params.requestedLoadSize}")

        getTotalImages().initGalleryItems() // 초기 갤러리에서 사진 전부를 들고와 개수만큼 초기화시킨 array를 만든다(추후 데이터 사용을 위한 작업)
        callback.onResult(getImages(params.requestedLoadSize, params.requestedStartPosition), 0)
    }

    override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<PhotoItem>) {
        Log.i(TAG, "loadRange start: ${params.startPosition}, size: ${params.loadSize}")
        callback.onResult(getImages(params.loadSize, params.startPosition))
    }
}
class GalleryDataSourceFactory(private val contentResolver: ContentResolver) : DataSource.Factory<Int, PhotoItem>() {
    override fun create(): DataSource<Int, PhotoItem> {
        return GalleryDataSource(contentResolver)
    }
}

 

코드를 보면, 갤러리에서 데이터를 불러와 callback.onResult를 통해 데이터를 보내줍니다. 여기서 중요한 점은, loadInitial는 최초 한번만 호출되니, 여기서 리스트 객체를 만들기로 했습니다. 리스트 개수는 갤러리에서 가져온 전체 사진들 수 만큼 만들었습니다. (이는 추후 클릭 이벤트 사용 시 쓰입니다.)

그 후 loadRange를 통해 범위만큼 지속적으로 사진을 호출하도록 구성했습니다.

DataSourceFactory는 DataSource를 생성하는 역할을 합니다.

PagedListAdapter를 통한 Adapter 정의

PagedList 의 데이터를 RecyclerView 에 보여주기 위한 RecyclerView.Adapter 입니다.

class GalleryPagedAdapter : PagedListAdapter<PhotoItem, GalleryPagedAdapter.ViewHolder>(diffCallback) {
    companion object {
        private val diffCallback = object : DiffUtil.ItemCallback<PhotoItem>() {
            override fun areItemsTheSame(oldItem: PhotoItem, newItem: PhotoItem): Boolean = oldItem.imageDataPath == newItem.imageDataPath

            override fun areContentsTheSame(oldItem: PhotoItem, newItem: PhotoItem): Boolean = oldItem.imageDataPath == newItem.imageDataPath
        }
    }

    interface ACallback {
        fun onClickEvent(position: Int,
                         uri: Uri,
                         isClickEvent: Boolean)
        fun onClickLongEvent(uri: Uri)
    }

    private var callback: ACallback? = null
    private var isClick: Boolean = false

    fun setCallback(callback: ACallback) {
        this.callback = callback
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent)

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        getItem(position)?.let { item ->
            with(holder) {
                ivShadow.visibility = View.VISIBLE.takeIf { getGalleryItems()[position].isClick } ?: View.GONE
                val uri = item.imageDataPath.toUri()

                Glide.with(holder.containerView.context)
                    .load(item.imageDataPath)
                    .into(ivGallery)

                ivGallery.setOnClickListener {
                    isClick = !getGalleryItems()[position].isClick
                    callback?.onClickEvent(position, uri, isClick)

                    ivShadow.visibility = View.VISIBLE.takeIf { getGalleryItems()[position].isClick } ?: View.GONE
                }

                ivGallery.setOnLongClickListener {
                    callback?.onClickLongEvent(uri)
                    return@setOnLongClickListener true
                }
            }
        }
    }

    inner class ViewHolder(parent: ViewGroup):
        BaseViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_gallery, parent, false))
}

위의 코드와 같고 받아온 uri주소를 glide로 띄워 줬습니다. 짧게 클릭 시 쉐도우 이미지를 보여주기 위한 코드와 롱 클릭 시 이미지를 팝업창에 나타내 주기 위한 코드도 추가 되어있습니다.

ViewModel or Rx 이용한 데이터 정의

VM내 개념 대한 설명은 코드 내에 있습니다.

fun setPagingRx() {
        val config = PagedList.Config.Builder()
            .setInitialLoadSizeHint(60)
            .setPageSize(20)
//            .setPrefetchDistance(5)
            .setEnablePlaceholders(false)
            .build()

        val builder = RxPagedListBuilder<Int, PhotoItem>(object : DataSource.Factory<Int, PhotoItem>() {
            override fun create(): DataSource<Int, PhotoItem> {
                return GalleryDataSource(contentResolver)
            }
        }, config)

        val adapter = GalleryPagedAdapter().apply {
            setCallback(this@MainActivity)
        }

        getBinding().mainRv.run {
            this.adapter = adapter
            this.layoutManager = GridLayoutManager(this@MainActivity, 2)
        }

        builder.buildObservable()
            .subscribe {
                adapter.submitList(it)
            }
    }
class GalleryViewModel(application: Application): AndroidViewModel(application) {
    private val activity = application.applicationContext

    /**
     * PagedList :
     * DataSource 에서 가져온 데이터는 모두 PagedList 로 전달됩니다.
     * 데이터 로딩이 필요하면 DataSource 를 통해 가져옵니다.
     * 또한, UI에 데이터를 제공하는 역할을 합니다.
     *
     * LivePagedListBuilder :
     * PagedList를 생성하는 빌더입니다. 빌더는 LiveData로 리턴합니다.
     *
     * Placeholders :
     * 데이터가 로딩되지 않아 화면에 보여지지 않을 때, 가상의 객체를 미리 그리고 데이터 로딩이 완료될 때 실제 데이터를 보여주는 것을 말합니다.
     *
     * 장점 :
     * 1. 빠르게 스크롤 할 수 있다
     * 2. 스크롤바 위치가 정확하다
     * 3. 스피너 등으로 더 보기 같은 기능을 만들 필요가 없다
     * 조건 :
     * 1. 아이템이 보여지는 View의 크기가 동일해야 한다
     * 2. Adapter가 null을 처리해야 한다
     * 3. DataSource에서 제공하는 아이템의 개수가 정해져 있어야 한다
     */

    fun getImages(): LiveData<PagedList<PhotoItem>> {
        val dataSourceFactory = GalleryDataSourceFactory(activity.contentResolver)
        val pagedListConfig = PagedList.Config.Builder()
            .setPageSize(20)
            .setInitialLoadSizeHint(60)     // default : page size * 3
//            .setPrefetchDistance(20)        // default : page size
            .setEnablePlaceholders(false)   // default : true
            .build()

        val data = LivePagedListBuilder(dataSourceFactory, pagedListConfig).build()
        return data
    }
}

VM or Rx를 이용해 데이터를 계속해서 가지고 있도록 구현했습니다.

사진 여러장 선택하기

사진을 여러 장 선택하기 위해 Adapter에 callback을 넣어 클릭 시마다 Item List isClick 변수를 true로 변경하도록 구성해봤습니다. 해당 포지션 값과 click시 false를 true로 변경하는 작업을 통해 사진을 여러 장 선택하고, 선택한 데이터를 가지고 있도록 구현했습니다.

사진을 길게 클릭하면 팝업처럼 보여주기

위와 같이 사진 클릭 시 팝업을 띄워주기 위해 Adapter-ViewHolder내에서 처리를 했습니다. 코드는 간단합니다.(이 부분은 수정을 해야할 필요가 있습니다… 어려워…)

— — — — — — — — — — — —

여기까지 구현 내용이며, 해당 소스는

에 있으니 참고하세요!

아래는 구현하면서 발생한 문제입니다.

— — — — — — — — — — — —

구현을 하면서 발생했던 문제

사진을 여러 장을 선택한 것을 보여주기 위해 반투명한 imageView를 띄워 보여주기로 했습니다. 이 부분에선 버그가 발생했습니다. 간단히 Adapter에서 반투명 이미지만 visible/gone으로 처리하면 될 줄 알았지만, 이미지를 받아오면서 같은 싸이클 대에서 반투명한 이미지가 자동으로 visible되었습니다. 이는 스크롤 될때마다 같은 싸이클 에서 같은 포지션에서 발생하는 문제(만약, 스크롤 시마다 20개씩 이미지를 불러오고, 0, 4번 포지션의 이미지를 클릭해 반투명한 이미지를 visible했으면, 20, 24번째 포지션에서, 40, 44번째 포지션에서도 계속 반투명한 이미지가 보였습니다.)로 보였고,

ivShadow.visibility = View.VISIBLE.takeIf { getGalleryItems()[position].isClick } ?: View.GONE

위 코드를 처음 포지션을 가져올 때 넣은 후 해결하였습니다.

또한 이미지를 가져와 RecyclerView내부 imageView에 보여줄 시 이미지가 나타나는게 엄청 느린 현상이 발생했고, 결국 스크롤도 안되는 버그가 생겼습니다. 이 부분에서 발생했던 버그의 문제점은

ivGallery.setImageURI(uri)

이 부분이었습니다. 최초 Adapter내에서 이미지를 보여주기 위해 uri를 setImageUri로 보여주니 이미지 호출이 늦어졌고, 이는 스크롤 시 버벅이며 로딩이 느린 현상이 발생했습니다. 이는 백그라운드에서 작업해주기 위해 Glide Library를 사용해 해결했습니다.

이상입니다!

 

 

 

감사합니당 :)

반응형