페이징이란 데이터를 가져올 때 한 번에 모든 데이터를 가져오는 것이 아니라 일정한 덩어리(페이지)로 나눠서 가져오는 것을 뜻합니다.
데이터는 안드로이드의 SQLite가 될 수도 있고, 서버-클라이언트 모델의 서버에서 페이징을 구현한 뒤, 클라이언트를 통해 사용자가 열람한 페이지의 정보를 보여주는 것이 될 수도 있습니다.
예를 들어, 구글에서 어떤 키워드로 검색하게 되면 결과의 모든 데이터를 한 번에 가져오는 것이 아니라 페이지로 나누어 데이터를 가져오게 됩니다.
이러한 페이징 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 더 효율적으로 사용하기에 성능, 메모리, 비용 측면에서 굉장히 효율적입니다.
라이브러리의 구성요소는 앱의 세 가지 레이어에서 작동합니다.
저장소 계층
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 라이브러리를 이용해 PagingSource를 따로 설정하지 않고 진행하겠습니다
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에서 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에서 캐싱합니다.
리사이클러뷰를 통해 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과 요청할 사이트에 대한 내용은 이전 포스트에서 확인해주세요
먼저 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을 반환한다.
이제는 pager 생성자의 인자로 pagingSource를 넘겨줍니다.
data = Pager(
config = PagingConfig(
pageSize = 6,
enablePlaceholders = false
),
pagingSourceFactory = { UserPagingSource(RetrofitAPI.request) }
).flow.cachedIn(viewModelScope)
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
감사합니다. 덕분에 잘 공부했습니다.