[Android] Paging3 예제로 알아보기

이제일·2022년 8월 10일
2

Android

목록 보기
6/15
post-thumbnail

Paging이란

페이징이란 데이터를 가져올 때 한 번에 모든 데이터를 가져오는 것이 아니라 일정한 덩어리(페이지)로 나눠서 가져오는 것을 뜻합니다.
데이터는 안드로이드의 SQLite가 될 수도 있고, 서버-클라이언트 모델의 서버에서 페이징을 구현한 뒤, 클라이언트를 통해 사용자가 열람한 페이지의 정보를 보여주는 것이 될 수도 있습니다.

예를 들어, 구글에서 어떤 키워드로 검색하게 되면 결과의 모든 데이터를 한 번에 가져오는 것이 아니라 페이지로 나누어 데이터를 가져오게 됩니다.
이러한 페이징 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 더 효율적으로 사용하기에 성능, 메모리, 비용 측면에서 굉장히 효율적입니다.

Paging3 장점

  • 페이징 된 데이터의 메모리 내 캐싱. 이렇게 하면 앱이 페이징 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 요청 중복 제거 기능이 기본으로 제공되어 앱에서 네트워크 대역폭과 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 사용자가 로드된 데이터의 끝까지 스크롤할 때 구성 가능한 RecyclerView 어댑터가 자동으로 데이터를 요청합니다.
  • Kotlin 코루틴 및 Flow뿐만 아니라 LiveData 및 RxJava를 최고 수준으로 지원합니다.
  • 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원합니다.

Paging3 구조

라이브러리의 구성요소는 앱의 세 가지 레이어에서 작동합니다.

  • 저장소 계층
    Repository 계층의 기본이되는 구성 요소는 PagingSource입니다.
    각 PagingSource 개체는 데이터 소스와 해당 소스에서 데이터를 검색하는 방법을 정의
    PagingSource 개체는 네트워크 소스 및 로컬 데이터베이스를 포함하여 전체 데이터로부터 부분적으로 데이터를 로드 할 수 있습니다.
    또 다른 구성요소로 RemoteMediator가 있습니다.
    RemoteMediator 개체는 네트워크로 부터 받은 데이터를 로컬 데이터베이스를 통해 캐시 하는 경우 페이징하는데 함께 사용할 수 있습니다.

  • ViewModel 계층
    Pager는 PagingSource 개체 및 PagingConfig 개체를 기반으로 반응형 스트림에서 사용되는 PagingData 인스턴스를 구성하기 위한 공용 API를 제공하는데 이 때 ViewModel 계층을 UI에 연결하는 구성 요소는 PagingData입니다.
    PagingData 개체는 페이지가 매겨진 데이터의 스냅 샷을 위한 컨테이너로, PagingSource 개체를 쿼리하고 결과를 저장합니다.

  • UI 계층
    UI 계층의 기본 Paging 라이브러리 구성 요소는 PagingDataAdapter로 페이지가 매겨진 데이터를 처리합니다.
    만약 PagingDataAdapter가 아닌 RecyclerView.Adapter 등을 확장하는 커스텀 어댑터를 구현하려면 AsyncPagingDataDiffer를 사용할 수 있다.

Room을 이용한 Paging

Room-paging 라이브러리를 이용해 PagingSource를 따로 설정하지 않고 진행하겠습니다

Repository

Room의 반환값으로 PagingSource를 생성할 수 있도록 종속성을 추가합니다.

    implementation 'androidx.room:room-paging:2.4.3'

Room에 관한 내용은 이전 포스팅을 확인해주세요.

User 엔티티를 설정합니다

@Entity
data class User(
    val email: String?,
    val avatar: String?,

    @ColumnInfo(name = "first_name") val firstName: String?,

    @ColumnInfo(name = "last_name") val lastName: String?,
    @PrimaryKey(autoGenerate = true) val id: Int = 0
)

조회를 위한 다오를 설정합니다.

@Dao
interface UserDao {
    @Query("SELECT * FROM User ORDER BY id ASC")
    fun allSelect(): PagingSource<Int, User>

    @Query("SELECT * FROM User ORDER BY id ASC")
    fun select(): List<User>

    @Insert
    fun insert(user: List<User>)
}

DB를 처음 생성시 데이터를 넣기위해
onCreate시 작동할 callback 함수를 등록합니다


@Database(entities = [User::class], version = 1)
abstract class RoomDB : RoomDatabase() {
    abstract fun userDao(): UserDao

    companion object {
        @Volatile
        private var instance: RoomDB? = null

        @Synchronized
        fun getInstance(context: Context): RoomDB = instance ?: synchronized(this) {
            Room.databaseBuilder(
                context.applicationContext,
                RoomDB::class.java, "User"
            ).addCallback(object : RoomDatabase.Callback() {
                override fun onCreate(db: SupportSQLiteDatabase) {
                    fillInDb(context.applicationContext)
                }
            }).build()
        }

        private fun fillInDb(context: Context) {
            CoroutineScope(Dispatchers.IO).launch {
                getInstance(context).userDao().insert(
                    getInitRoomData()
                )
            }
        }
    }
}

fun getInitRoomData():List<User>{
    val data = arrayListOf<User>()
    for(i in 0 until 50){
        data.add(User("${FIRST_NAME[i]}@${LAST_NAME[i]}","https://picsum.photos/200", FIRST_NAME[i],
            LAST_NAME[i]))
    }
    return data
}
private val FIRST_NAME = arrayListOf(
    "Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", "Cras", "pharetra", "nec", "ligula", "vel", "consequat", "Duis", "quis", "neque", "volutpat", "pellentesque", "orci", "id", "laoreet", "magna", "Duis",
    "ullamcorper", "sapien", "in", "tortor", "rutrum", "quis", "egestas", "tortor", "gravida", "Suspendisse", "potenti", "Praesent", "finibus", "ac", "ligula", "et", "sodales", "Ut", "non", "ante", "at", "mauris", "tincidunt", "pulvinar",
    "Orci", "varius"
)
private val LAST_NAME = arrayListOf(
    "Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", "Curabitur", "convallis", "quis", "ante", "a", "laoreet", "Aliquam", "vulputate", "vel", "massa", "ac", "efficitur", "Cras", "pulvinar", "euismod", "purus",
    "Praesent", "ut", "semper", "velit", "In", "varius", "hendrerit", "massa", "et", "eleifend", "Nam", "faucibus", "pulvinar", "eros", "Morbi", "lacinia", "arcu", "sit", "amet", "dui", "luctus", "eget", "viverra", "turpis", "elementum",
    "Fusce"
)

단어들은 lipsum데이터를 활용했습니다.

ViewModel

ViewModel에 관해서는 이전 포스팅을 참고해주세요

ViewModel에서 Pager를 생성해 스트림과 pagerConfig를 설정합니다.


class UserViewModel(private val type: Int, private val dao: UserDao) : ViewModel() {
    var data: Flow<PagingData<User>> = Pager(
        config = PagingConfig(
            pageSize = 10,
            enablePlaceholders = false,
            maxSize = 50
        )
    ) {
        dao.allSelect()
    }.flow.cachedIn(viewModelScope)

}

class UserVMFactory(private val type: Int, private val dao: UserDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return UserViewModel(type, dao) as T
    }
}

ViewModel의 팩토리 패턴으로 Dao를 인자로 받습니다.

  • PagerConfig
    pageSize : 미리 로드할 데이터 개수
    maxSize : 페이지를 삭제하기 전, PagingData에 로드 할 수 있는 최대 항목 수
    enablePlaceholders : true 라면 아직 로드되지 않은 item을 null로 설정

  • dao.allSelect()
    PagingSource 생성자를 직접적으로 전달하는 대신에 DAO로 부터 PagingSource를 반환하는 query 메서드를 제공해야 합니다.

  • flow.cachedIn
    해당 Pager를 Flow로 변환하고 이를 viewModelScope에서 캐싱합니다.

UI

리사이클러뷰를 통해 Paging을 하기 위해 리사이클러뷰 어뎁터가 아닌 PagingDataAdapter를 구현합니다.

PagingDataAdapter는 기존 RecyclerView.Adapter의 구현과 동일하지만
PagingDataAdapter<T : Any, VH : RecyclerView.ViewHolder> 를 상속받고,
DiffUtil.ItemCallback 을 정의해주어야만 한다.

PagingDataAdapter

class UserAdapter : PagingDataAdapter<User, UserViewHolder>(diffCallback) {
    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        return UserViewHolder(
            RowPagingBinding.inflate(
                LayoutInflater.from(parent.context), parent, false
            )
        )
    }


    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<User>() {
            override fun areItemsTheSame(oldItem: User, newItem: User) =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: User, newItem: User) =
                oldItem == newItem
        }
    }
}

DiffUtil callback에서
areItemsTheSame 는 두 개체가 동일한 항목(ID)을 나타내는 지 확인하기 위해 호출 되고,
areContentsTheSame 는 두 항목에 동일한 데이터가 있는지 확인하기 위해 호출 됩니다.

viewHolder

class UserViewHolder(private val binding: RowPagingBinding) : RecyclerView.ViewHolder(binding.root) {
    var user:User? = null

    fun bind(item: User?) {
        item?.let {
            binding.id.text = "id : ${it.id}"
            binding.name.text = "${it.firstName} ${it.lastName}"
            binding.email.text = it.email
            Glide.with(binding.avatar.context)
                .load(it.avatar)
                .transition(DrawableTransitionOptions.withCrossFade())
                .into(binding.avatar)
            user = item
        }
    }
}

리사이클러뷰에 사용할 item layout과 이를 담을 액티비티 xml을 만듭니다

activity_paging.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".PagingActivity">


    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/dataList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager" />
</LinearLayout>

row_paging.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_marginVertical="8dp">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="8dp">

        <ImageView
            android:id="@+id/avatar"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:src="@mipmap/ic_launcher"
            android:padding="5dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

        <TextView
            android:id="@+id/name"
            android:text="name"
            android:textColor="@color/black"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toEndOf="@id/avatar"
            app:drawableStartCompat="@drawable/ic_baseline_account_box_24"
            android:drawablePadding="8dp"
            android:paddingVertical="8dp"/>

        <TextView
            android:id="@+id/email"
            android:text="Email"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toBottomOf="@id/name"
            app:layout_constraintStart_toEndOf="@id/avatar"
            app:drawableStartCompat="@drawable/ic_baseline_mail_24"
            android:drawablePadding="8dp"/>

        <TextView
            android:id="@+id/id"
            android:text="id : "
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

액티비티에서 뷰 모델을 생성하고 페이징데이터를 페이지어뎁터에 넣습니다.
Activity

 class PagingActivity : AppCompatActivity() {
    private lateinit var binding: ActivityPagingBinding
    private lateinit var adapter: UserAdapter
    private lateinit var viewModel: UserViewModel

    companion object{
        const val TYPE_ROOM = 0
        const val TYPE_RETROFIT = 1
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityPagingBinding.inflate(layoutInflater)
        setContentView(binding.root)

        adapter = UserAdapter()
        binding.dataList.adapter = adapter

        viewModel = ViewModelProvider(this, UserVMFactory(
            intent.getIntExtra("type",TYPE_ROOM), RoomDB.getInstance(applicationContext).userDao()
        ))[UserViewModel::class.java]

        lifecycleScope.launch {
            viewModel.data.collectLatest { adapter.submitData(it) }
        }
    }
}

이때 submitData()와 flow는 suspend 함수이기 때문에 코루틴을 사용하여 호출하여야 합니다.


Retrofit을 이용한 Paging

Retrofit과 요청할 사이트에 대한 내용은 이전 포스트에서 확인해주세요

Repository

먼저 Gson의 객체로 활용할 이전에 만들었던 user 엔티티에 SerializedName을 설정합니다.

@Entity
data class User(
    val email: String?,
    val avatar: String?,

    @SerializedName("first_name")
    @ColumnInfo(name = "first_name") val firstName: String?,

    @SerializedName("last_name")
    @ColumnInfo(name = "last_name") val lastName: String?,
    @PrimaryKey(autoGenerate = true) val id: Int = 0
)

이후 Retrofit을 설정합니다

object RetrofitAPI {
    private const val BASE_URL = "https://reqres.in/api/"

    private val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    val request: Request by lazy {
        retrofit.create(Request::class.java)
    }
}



data class ResponseData(
    @SerializedName("data")
    val user: List<User>,
    @SerializedName("total_pages")
    val totalPages: Int
)
interface Request {
    @Headers("Content-Type: application/json")
    @GET("users/")
    suspend fun getUser(@Query("page") page: Int): ResponseData
}

url의 경우 https://reqres.in/api/users?page=1와 같은 GET 메서드로 데이터를 받아옵니다.

이제 Retrofit에서 객체를 받아 PagingSource를 설정합니다.

PagingSource

class UserPagingSource(
    private val service: Request,
) : PagingSource<Int, User>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        val page = params.key ?: 1
        return try {
            val response = service.getUser(page)
            val user = response.user
            LoadResult.Page(
                data = user,
                prevKey = page % 2 -1,
                nextKey = page % 2 +1
            )
        } catch (exception: Exception) {
            LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, User>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey
        }
    }
}

reqres.in/api에서는 page가 두개라 앞뒤로 무한히 스크롤 될 수 있도록 prevKey와 nextKey를 설정합니다.

getRefreshKey는 LoadParams에 PageKey를 전달할 때 사용하는 함수입니다. previousKey가 null이면 첫번째 페이지를 반환하고 nextKey가 null이면 마지막 페이지를 반환한다. 만약 둘 다 null이면 null을 반환한다.

ViewModel

이제는 pager 생성자의 인자로 pagingSource를 넘겨줍니다.

data = Pager(
		config = PagingConfig(
    	pageSize = 6,
    	enablePlaceholders = false
    ),
    	pagingSourceFactory = { UserPagingSource(RetrofitAPI.request) }
    ).flow.cachedIn(viewModelScope)

UI

pagingAdapter와 RecyclerView의 viewHolder, xml 레이아웃은 위 Room방식과 동일합니다.


샘플 코드 및 참고 사이트

샘플 코드
https://github.com/WorldOneTop/AndroidJetpackSample/tree/Paging

참고 사이트
공식문서
https://leveloper.tistory.com/202
https://medium.com/@jungil.han/paging-library-%EA%B7%B8%EA%B2%83%EC%9D%B4-%EC%93%B0%EA%B3%A0%EC%8B%B6%EB%8B%A4-bc2ab4d27b87

작동 영상

profile
세상 제일 이제일

1개의 댓글

comment-user-thumbnail
2024년 4월 15일

감사합니다. 덕분에 잘 공부했습니다.

답글 달기