본문 바로가기

Android + Kotlin

Paging 3.0 + MVVM + Flow를 이용하여 리스트 구현하기

반응형

앱을 개발하면 빠지지않고 사용하는 페이징 리스트를 구현해 보도록 하겠습니다.

자동으로 페이징을 해주는 Paging 3.0 라이브러리를 이용하여 MVVM 모델과 Flow를 사용하여 연동할 예정입니다.

 

지난 게시물에서 상세 화면을 구현하였던 무료 이미지 사이트인 Lorem Picssum(https://picsum.photos/) 에서 이미지 목록를 가져와서 리스트를 구현하도록 하겠습니다.

 

이미지 상세화면 구현은 아래 게시글을 읽어주세요.

 

Koin + MVVM + Coroutine + Flow 를 이용한 상세화면 만들기

앱개발에서 가장 기본이라 할수 있는 Rest API로 서버에서 데이터를 요청하고, 화면에 표시하는 일련의 과정을 정리해보려고 합니다. 요즘 Android 개발을 하면서 가장 많이 사용하고 있는(실제로도

heeeju4lov.tistory.com

 


 

Paging 3.0 라이브러리는 MVVM 모델과 Flow 를 이용하여 효율적이고 안전하게 데이터를 로드합니다.

그리고, 스크롤이 끝까지 가는 경우, RecyclerView adapter가 자동으로 다음 페이지를 로드하고 표시하도록 합니다.

자세한 내용은 아래 구글 가이드를 확인해 주세요.

 

페이징 라이브러리 개요  |  Android 개발자  |  Android Developers

페이징 라이브러리 개요   Android Jetpack의 구성요소 페이징 라이브러리를 사용하면 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 데이터 페이지를 로드하고 표시할 수 있습니다.

developer.android.com

 


 

이전 게시물 개발 소스코드에 이어서 구현을 하겠습니다.

(이전 구현 내용이 궁금하신 분은 위의 링크(상세 화면 구현 게시물)을 확인해 주세요.)

 

 

1. 네트워크를 통해서 가져올 데이터를 확인해 보겠습니다.

https://picsum.photos/ 사이트에 맨 아래 List Images 항목이 있습니다.

JSON 데이터를 보니, 기존 구현했던 Image Detail 데이터를 리스트로 감싼 데이터임을 확인할 수 있습니다.

기존에 만들어 놓았던 ImageInfo 데이터 모델을 활용할 수 있을것 같습니다.

 


 

2. 안드로이드 프로젝트를 열고, build.gradle에 paging 3.0 라이브러리를  implementation 합니다.

dependencies {
    ...
    
    // paging
    def paging_version = "3.1.0"
    implementation("androidx.paging:paging-runtime-ktx:$paging_version")
}

 


 

3. ImageInfo(ImageInfo.kt) 데이터 모델에 thumbnail URL을 저장할 변수를 추가해 줍니다.

data class ImageInfo (
    ...
    val thumbnail_url: String?
)

 

 

4. 네트워크 수신 데이터 클래스(ResImageInfo.kt)의 mapper() 함수에서 thumbnail_url에 값을 채워줍니다.

별도의 thumbnail URL이 없기때문에, download URL을 분해하여 width와 height를 추가하여 thumbnail URL을 만들어 줍니다.(이미지 URL 정책은 Lorem Picsum 사이트를 확인해 주세요.)

class ResImageInfo {

    data class Response(
        ...
        val download_url: String?
    ): BaseResponse<ImageInfo> () {
        override fun mapper(): ImageInfo {

            val thumbnailUrl = download_url?.let {
                val uri = Uri.parse(it)
                "${uri.scheme}://${uri.host}/${uri.pathSegments[0]}/${uri.pathSegments[1]}/300/200"
            }
            return ImageInfo(
                ...
                thumbnail_url = thumbnailUrl
            )
        }
    }
}

 


 

5. 이제 API를 구현할 차례입니다.

리스트 요청 URL(https://picsum.photos/v2/list?page=2&limit=100) 에 맞춰서, LoremPicsumApiService.kt 파일에 인터페이스를 추가해 줍니다.

한번에 요청하는 item 개수는 20개로 설정합니다.

interface LoremPicsumApiService {

    ...
    
    @GET("/v2/list")
    suspend fun imageList(
        @Query("page") page: Int?,
        @Query("limit") limit: Int? = 20
    ): List<ResImageInfo.Response>
}

 


 

6. Paging 라이브러리에서 실제 데이터를 요청하는 PagingSource 클래스를 구현하도록 하겠습니다.

LoremPicsumDataSource.kt 파일을 생성하여 아래와 같이 만들어 줍니다.

class LoremPicsumDataSource(
    private val service: LoremPicsumApiService
): PagingSource<Int, ImageInfo>() {

    override fun getRefreshKey(state: PagingState<Int, ImageInfo>): Int {
        return 0
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ImageInfo> {
        return try {
            val page = params.key?: 0
            val results = service.imageList(page).map { it.mapper() }.toMutableList()
            val nextPage = if(results.count() == 20) page + 1 else null
            LoadResult.Page(data = results, nextKey = nextPage, prevKey = null)
        } catch (e: Exception) {
            e.printStackTrace()
            LoadResult.Error(e)
        }
    }
}

 

여기서는 load() 함수가 중요한데, params.key로는 다음 페이지의 값이 전달 됩니다.

그리고, imageList()로 목록 요청을 해서 mutableList()로 전달 받습니다.

다음 페이지 값을 확인하여 LoadResult로 전달해주면 찰떡(?)같이 알아서 RecyclerView에 아이템을 표시해줍니다.

 

 

7. 이제 PagingSource를 이용하여 flow를 만드는 코드를 repository(LoremPicsumRepository.kt)에 추가해 줍니다.

class LoremPicsumRepository(private val service: LoremPicsumApiService) {

    ...

    fun fetchImageList(): Flow<PagingData<ImageInfo>> {
        return Pager(
            config = PagingConfig(pageSize = 20, enablePlaceholders = false),
            pagingSourceFactory = { LoremPicsumDataSource(service = service) }
        ).flow
    }

}

 

pageSize는 실제 서버에 요청하는 아이템 개수와 동일해야 합니다.

(LoremPicsumDataSource() 함수에 파라메터로 넘겨주거나 static 값으로 선언하여 공통으로 사용해도 좋습니다.)

 

 

8. ViewModel(LoremPicsumViewModel.kt)에 repository에 추가한 함수를 호출하는 함수를 추가합니다.

class LoremPicsumViewModel(private val repository: LoremPicsumRepository): ViewModel() {

    ...

    fun fetchImageList(): Flow<PagingData<ImageInfo>> {
        return repository.fetchImageList()
    }

}

 

PagingData를 Flow로 넘겨줍니다.

그러면, PagingDataAdapter에서 받아서, 알아서 페이지를 불러오게 됩니다.

 


 

이제 데이터쪽 구현은 완료했습니다. UI쪽 구현을 할 차례입니다.

 

 

9. PagingDataAdapter(LoremPicsumListAdapter.kt)를 생성해 줍니다.

class LoremPicsumListAdapter:
    PagingDataAdapter<ImageInfo, LoremPicsumListAdapter.ImageViewHolder>(
        object : DiffUtil.ItemCallback<ImageInfo>() {
            override fun areItemsTheSame(oldItem: ImageInfo, newItem: ImageInfo): Boolean {
                return oldItem.id == newItem.id && oldItem.author == newItem.author
            }

            override fun areContentsTheSame(oldItem: ImageInfo, newItem: ImageInfo): Boolean {
                return oldItem.id == newItem.id && oldItem == newItem
            }

        }
    ) {
    override fun onBindViewHolder(holder: LoremPicsumListAdapter.ImageViewHolder, position: Int) {
        val item = getItem(position)?: return
        holder.onBind(item)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): LoremPicsumListAdapter.ImageViewHolder {
        return ImageViewHolder(binding = ListItemImageBinding.inflate(
            LayoutInflater.from(parent.context), parent, false)
        )
    }

    inner class ImageViewHolder(private val binding: ListItemImageBinding):
        RecyclerView.ViewHolder(binding.root) {
            fun onBind(item: ImageInfo) {
                binding.item = item
            }
    }
}

 

위의 코드에서 DiffUtil 이라는 것이 있습니다.

DiffUtil은 리스트 갱신시에 사용되는데, 아이템을 비교하여 추가/삭제/ 업데이트를 해줍니다.

필수적으로 들어가는 것으로, 자세한 내용은 구글 검색이나 추후 정리해서 게시글을 올리겠습니다.

그밖에는 보통 recyclerView의 adapter와 동일합니다.

 

 

10. 아이템 layout(list_item_image.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>
        <variable
            name="item"
            type="com.remind.sampleapp.lorem_picsum.model.ImageInfo" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="8dp">
    
        <androidx.appcompat.widget.AppCompatImageView
            android:id="@+id/thumbnail"
            android:layout_width="160dp"
            android:layout_height="120dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            android:scaleType="centerCrop"
            app:url="@{item.thumbnail_url}"/>
    
        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintLeft_toRightOf="@id/thumbnail"
            app:layout_constraintRight_toRightOf="parent"
            android:layout_marginStart="8dp"
            android:textSize="24sp"
            android:textStyle="bold"
            android:text="@{item.author}"/>
    
    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

데이터 바인딩을 사용하고, 이미지와 작가 이름을 표시하는 구성입니다.

imageview의 이미지 로드(app:url="....")하는 것은 BindingAdapter를 이용한 것으로 자세한 내용은 이전 게시물을 확인해주세요.

 


 

11. 이제 리스트 화면(LoremPicsumListActivity.kt)을 생성합니다.

class LoremPicsumListActivity:
    BaseActivity<ActivityLorempicsumListBinding>(R.layout.activity_lorempicsum_list) {

    private val viewModel: LoremPicsumViewModel by viewModel()
    private val imageListAdapter: LoremPicsumListAdapter = LoremPicsumListAdapter()

    override fun initView() {
        super.initView()
        binding.rvList.apply {
            layoutManager = LinearLayoutManager(
                this@LoremPicsumListActivity,
                RecyclerView.VERTICAL,
                false)
            adapter = imageListAdapter
        }
    }

    override fun initViewModel() {
        super.initViewModel()
        binding.lifecycleOwner = this
    }

    override fun afterOnCreate() {
        super.afterOnCreate()
        lifecycleScope.launchWhenStarted {
            viewModel.fetchImageList().collect {
                imageListAdapter.submitData(it)
            }
        }
    }
}

 

BaseActivity를 상속 받아서 사용하는데, 자세한 내용은 아래 게시물을 확인해주세요.

 

DataBinding을 결합한 BaseActivity 만들기

우리는 개발을 하면서, 반복적이고 공통적인 코드를 기계적으로 코딩(Bolierplate 코드)을 하게됩니다. 그로인해 코드가 늘어나고, 간결하게 보이지 않아 코드 분석이 불편한 경험도 하게 됩니다.

heeeju4lov.tistory.com

 

activity 코드에서 중요한 부분은 fetchImageList()를 요청하여, 응답 값(Flow)를 adapter에 submitData()를 하는 부분입니다.

이렇게 설정해 놓으면 스크롤이 아래에 내려가면 자동으로 다음 페이지를 요청하여 리스트를 업데이트해 줍니다.

 

 

12. activty layout(activity_lorepicsum_list.xml)을 생성합니다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</layout>

 

데이터 바인딩을 하는 RecyclerView가 있는 layout 입니다.

 

그리고, 마지막으로 AndroidManifest.xml에 activity를 등록하면 완료입니다.

<activity android:name=".lorem_picsum.ui.LoremPicsumListActivity"/>

 

 

이제 실행해봅시다!!

위와 같이 보이면 성공입니다.

 

전체 구현 코드는 아래 Github 링크를 확인하시면 됩니다.

 

GitHub - rcbuilders/RemindSampleApp: https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함.

https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함. - GitHub - rcbuilders/RemindSampleApp: https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함.

github.com

 

반응형