Skip to content

Latest commit

ย 

History

History
471 lines (400 loc) ยท 21.8 KB

summary.md

File metadata and controls

471 lines (400 loc) ยท 21.8 KB

Paging3

Index

Paging3๋ž€?

๋กœ์ปฌ ์ €์žฅ์†Œ์—์„œ๋‚˜ ๋„คํŠธ์›Œํฌ๋ฅผ ํ†ตํ•ด ๋Œ€๊ทœ๋ชจ ๋ฐ์ดํ„ฐ ์„ธํŠธ์˜ ๋ฐ์ดํ„ฐ ํŽ˜์ด์ง€๋ฅผ ๋กœ๋“œํ•˜๊ณ  ํ‘œ์‹œํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ๋Œ€์—ญํญ๊ณผ ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค๋ฅผ ๋ชจ๋‘ ๋” ํšจ์œจ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค.
์‰ฝ๊ฒŒ ๋งํ•˜๋ฉด ์•ˆ๋“œ๋กœ์ด๋“œ์—์„œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋˜๋Š” ๋„คํŠธ์›Œํฌ(Remote)์˜ ๋ฐ์ดํ„ฐ๋ฅผ ํŽ˜์ด์ง€ ๋‹จ์œ„๋กœ UI์— ์‰ฝ๊ฒŒ ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋‹ค.

๊ธฐ์กด Paging ๊ตฌํ˜„ ๋ฐฉ์‹

๊ธฐ์กด์— ํŽ˜์ด์ง•์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์€ RecyclerView์™€ ๊ฐ™์€ ๋ฆฌ์ŠคํŠธ UI๊ฐ€ ์ƒ๋‹จ ๋˜๋Š” ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ๋Š”์ง€ ํŒ๋‹จํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋’ค,
ํ•˜๋‹จ์— ๋„๋‹ฌํ•˜์˜€์„๋•Œ ๋‹ค์Œ ํŽ˜์ด์ง€๋ฅผ ๋กœ๋“œ(or Refresh)ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋ฐฉ์‹์ด ์ฃผ๋ฅผ ์ด๋ฃจ์—ˆ๋‹ค.
ํŽ˜์ด์ง•์ด ํ•„์š”ํ•œ ๋ชจ๋“  ํ™”๋ฉด์— ๋™์ผํ•œ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด์•ผ๋งŒ ํ–ˆ๊ณ  ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜, ์Šคํฌ๋กค ์˜ค๋ฅ˜๋“ฑ์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ์ฝ”๋“œ๊ฐ€ ๋งŽ์ด ํ•„์š”ํ•˜์˜€๋‹ค.
Paging ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ์œ„์™€ ๊ฐ™์€ ๊ณผ์ •์—†์ด ์‰ฝ๊ฒŒ ํŽ˜์ด์ง•์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

Paging3 ๊ธฐ๋Šฅ ๋ฐ ์žฅ์ 

Paging 3์€ ์ด์ „ Paging ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ฒ„์ „๊ณผ ํฌ๊ฒŒ ๋‹ฌ๋ผ์กŒ๋‹ค.

  • ํŽ˜์ด์ง•๋œ ๋ฐ์ดํ„ฐ์˜ ๋ฉ”๋ชจ๋ฆฌ ๋‚ด ์บ์‹ฑ ์ง€์›
    • ์•ฑ์ด ํŽ˜์ด์ง• ๋ฐ์ดํ„ฐ๋กœ ์ž‘์—…ํ•˜๋Š” ๋™์•ˆ ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์žˆ์Œ
  • ์š”์ฒญ ์ค‘๋ณต ์ œ๊ฑฐ ๊ธฐ๋Šฅ์ด ๊ธฐ๋ณธ์œผ๋กœ ์ œ๊ณต
    • ์•ฑ์—์„œ ๋„คํŠธ์›Œํฌ ๋Œ€์—ญํญ๊ณผ ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ
  • ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๋“œ๋œ ๋ฐ์ดํ„ฐ์˜ ๋๊นŒ์ง€ ์Šคํฌ๋กคํ•  ๋•Œ ๊ตฌ์„ฑ ๊ฐ€๋Šฅํ•œ RecyclerView ์–ด๋Œ‘ํ„ฐ๊ฐ€ ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์š”์ฒญ
  • Kotlin Coroutine ๋ฐ Flow๋ฟ๋งŒ ์•„๋‹ˆ๋ผ LiveData ๋ฐ RxJava ์ตœ๊ณ  ์ˆ˜์ค€ ์ง€์›
  • ์ƒˆ๋กœ๊ณ ์นจ ๋ฐ ์žฌ์‹œ๋„ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•˜์—ฌ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๊ธฐ๋ณธ์œผ๋กœ ์ง€์›
  • ๊ธฐ์กด Paging2์˜ DataSource๋“ค์„ ํ†ตํ•ฉ, ์‹ฌํ”Œํ•œ DataSource ์ธํ„ฐํŽ˜์ด์Šค ์ œ๊ณต
    • DatPageKeyedDataSource, PositionalDataSource, ItemKeyedDataSource ํ†ตํ•ฉ
  • Header, Footer ์ง€์›

ํ•ต์‹ฌ๊ฐœ๋…

  • PagingData: ํŽ˜์ด์ง€๋กœ ๋‚˜๋ˆˆ ๋ฐ์ดํ„ฐ์˜ ์ปจํ…Œ์ด๋„ˆ. ๋ฐ์ดํ„ฐ๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•  ๋•Œ๋งˆ๋‹ค ์ƒ์‘ํ•˜๋Š” PagingData๊ฐ€ ๋ณ„๋„๋กœ ์ƒ์„ฑ๋œ๋‹ค
  • PagingSource: PagingSource๋Š” ๋ฐ์ดํ„ฐ์˜ ์Šค๋ƒ…์ƒท์„ PagingData์˜ ์ŠคํŠธ๋ฆผ์œผ๋กœ ๋กœ๋“œํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ๋ณธ ํด๋ž˜์Šค
  • Pager.flow: ๊ตฌํ˜„๋œ PagingSource์˜ ๊ตฌ์„ฑ ๋ฐฉ๋ฒ•์„ ์ •์˜ํ•˜๋Š” ํ•จ์ˆ˜์™€ PagingConfig๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Flow<PagingData>๋ฅผ ๋นŒ๋“œ
  • PagingDataAdapter: RecyclerView์— PagingData๋ฅผ ํ‘œ์‹œํ•˜๋Š” RecyclerView.Adapter. PagingDataAdapter๋Š” Kotlin Flow, LiveData, RxJava Flowable ๋˜๋Š” RxJava Observable์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค. PagingDataAdapter๋Š” ํŽ˜์ด์ง€๊ฐ€ ๋กœ๋“œ๋  ๋•Œ ๋‚ด๋ถ€ PagingData ๋กœ๋”ฉ ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹  ๋Œ€๊ธฐํ•˜๊ณ  ์—…๋ฐ์ดํŠธ๋œ ์ฝ˜ํ…์ธ ๊ฐ€ ์ƒˆ๋กœ์šด PagingData ๊ฐ์ฒด ํ˜•ํƒœ๋กœ ์ˆ˜์‹ ๋  ๋•Œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ DiffUtil๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์„ธ๋ถ„ํ™”๋œ ์—…๋ฐ์ดํŠธ๋ฅผ ๊ณ„์‚ฐํ•œ๋‹ค
  • RemoteMediator: ๋„คํŠธ์›Œํฌ ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํŽ˜์ด์ง€๋กœ ๋‚˜๋ˆ„๊ธฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐ ์œ ์šฉ

PagingSource

PagingSource๋ฅผ ์ •์˜ํ•˜๋ ค๋ฉด ์•„๋ž˜์˜ ํ•ญ๋ชฉ์„ ์ •์˜ํ•ด์•ผํ•œ๋‹ค.

  • ํŽ˜์ด์ง• ํ‚ค์˜ ์œ ํ˜•: ํ˜„์žฌ ๋กœ๋“œํ•œ ๋ฐ์ดํ„ฐ์˜ ํŽ˜์ด์ง€ ์ •๋ณด ๋ฐ์ดํ„ฐ์˜ ํƒ€์ž…, ์˜ˆ์‹œ์—์„œ ๊ฒ€์ƒ‰ API์—์„œ ํŽ˜์ด์ง€์— 1์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•˜๋Š” ์ƒ‰์ธ ๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์œ ํ˜•์€ Int
  • ๋กœ๋“œ๋œ ๋ฐ์ดํ„ฐ์˜ ์œ ํ˜•: ์‘๋‹ต ๋ชจ๋ธ ํƒ€์ž… SearchDocument
  • ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์œ„์น˜: Retrofit์—์„œ ๊ฐ€์ ธ์˜ค๋ฏ€๋กœ SearchApi
class SearchPagingSource(
        private val query: String,
        private val api: SearchApi
) : PagingSource<Int, SearchDocument>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SearchDocument> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, SearchDocument>): Int? {
        TODO("Not yet implemented")
    }
}

PagingSource์—์„œ๋Š” ๋‘ ๊ฐ€์ง€ ํ•จ์ˆ˜๋ฅผ ๊ตฌํ˜„ํ•ด์•ผํ•œ๋‹ค.

  • public abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value>
  • public abstract fun getRefreshKey(state: PagingState<Key, Value>): Key?

load ํ•จ์ˆ˜

load ํ•จ์ˆ˜๋Š” LoadResult๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผํ•œ๋‹ค.

  • LoadResult.Page: ๋กœ๋“œ์— ์„ฑ๊ณตํ•œ ๊ฒฝ์šฐ
  • LoadResult.Error: ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฒฝ์šฐ

LoadResult.Page๋ฅผ ๊ตฌ์„ฑํ•  ๋•Œ ์ƒ์‘ํ•˜๋Š” ๋ฐฉํ–ฅ์œผ๋กœ ๋ชฉ๋ก์„ ๋กœ๋“œํ•  ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ nextKey ๋˜๋Š” prevKey์— null์„ ์ „๋‹ฌํ•œ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด ๋„คํŠธ์›Œํฌ ์‘๋‹ต์— ์„ฑ๊ณตํ–ˆ์ง€๋งŒ ๋ชฉ๋ก์ด ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋Š” ๋กœ๋“œํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒƒ์œผ๋กœ ๊ฐ„์ฃผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ nextKey๊ฐ€ null์ผ ์ˆ˜ ์žˆ๋‹ค.

getRefreshKey ํ•จ์ˆ˜

์ƒˆ๋กœ๊ณ ์นจํ‚ค๋Š” PagingSource.load()์˜ ํ›„์† ์ƒˆ๋กœ๊ณ ์นจ ํ˜ธ์ถœ์— ์‚ฌ์šฉ ๋œ๋‹ค.
์ฒซ ๋ฒˆ์งธ ํ˜ธ์ถœ์€ Pager์— ์˜ํ•ด ์ œ๊ณต๋˜๋Š” initialKey๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
์ƒˆ๋กœ๊ณ ์นจ์€ ์Šค์™€์ดํ”„ํ•˜์—ฌ ์ƒˆ๋กœ๊ณ ์นจํ•˜๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ, ๊ตฌ์„ฑ ๋ณ€๊ฒฝ, ํ”„๋กœ์„ธ์Šค ์ค‘๋‹จ ๋“ฑ์œผ๋กœ ์ธํ•ด ๋ฌดํšจํ™”๋˜์–ด
Paging ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ˜„์žฌ ๋ชฉ๋ก์„ ๋Œ€์ฒดํ•  ์ƒˆ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๋ ค๊ณ  ํ•  ๋•Œ๋งˆ๋‹ค ๋ฐœ์ƒํ•œ๋‹ค.
์ผ๋ฐ˜์ ์œผ๋กœ ํ›„์† ์ƒˆ๋กœ๊ณ ์นจ ํ˜ธ์ถœ์€ ๊ฐ€์žฅ ์ตœ๊ทผ์— ์•ก์„ธ์Šคํ•œ ์ธ๋ฑ์Šค๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” PagingState.anchorPosition ์ฃผ๋ณ€ ๋ฐ์ดํ„ฐ์˜ ๋กœ๋“œ๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•œ๋‹ค.

PagingData

PagingData๋Š” PagingData๋ฅผ ๋‹ค๋ฅธ ๋ ˆ์ด์–ด๋กœ ์ „๋‹ฌํ•  API๋ฅผ ๊ฒฐ์ •ํ•ด์•ผํ•œ๋‹ค.

  • Kotlin Flow: Pager.flow ์‚ฌ์šฉ
  • LiveData: Pager.liveData ์‚ฌ์šฉ
  • RxJava Flowable: Pager.flowable ์‚ฌ์šฉ
  • RxJava Observable: Pager.observable ์‚ฌ์šฉ

๋˜ํ•œ PageData๋Š” ์•„๋ž˜์˜ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์ „๋‹ฌํ•ด์•ผ ํ•œ๋‹ค.

  • PagingConfig
    • ๋กœ๋“œ ๋Œ€๊ธฐ ์‹œ๊ฐ„, ์ดˆ๊ธฐ ๋กœ๋“œ์˜ ํฌ๊ธฐ ์š”์ฒญ ๋“ฑ PagingSource์—์„œ ์ฝ˜ํ…์ธ ๋ฅผ ๋กœ๋“œํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๊ด€ํ•œ ์˜ต์…˜ ์„ค์ •
    • ์ •์˜ํ•ด์•ผ ํ•˜๋Š” ์œ ์ผํ•œ ํ•„์ˆ˜ ๋งค๊ฐœ๋ณ€์ˆ˜๋Š” ๊ฐ ํŽ˜์ด์ง€์— ๋กœ๋“œํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ ์ˆ˜๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” ํŽ˜์ด์ง€ ํฌ๊ธฐ์ด๋‹ค
    • ๊ธฐ๋ณธ์ ์œผ๋กœ Paging์€ ๋กœ๋“œํ•˜๋Š” ๋ชจ๋“  ํŽ˜์ด์ง€๋ฅผ ๋ฉ”๋ชจ๋ฆฌ์— ์œ ์ง€ํ•œ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์Šคํฌ๋กคํ•  ๋•Œ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ๋‚ญ๋น„ํ•˜์ง€ ์•Š์œผ๋ ค๋ฉด PagingConfig์—์„œ maxSize ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•ด์•ผํ•œ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ Paging์€ ๋กœ๋“œ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์„ ์ง‘๊ณ„ํ•  ์ˆ˜ ์žˆ๊ณ  enablePlaceholders ๊ตฌ์„ฑ ํ”Œ๋ž˜๊ทธ๊ฐ€ true์ธ ๊ฒฝ์šฐ ์•„์ง ๋กœ๋“œ๋˜์ง€ ์•Š์€ ์ฝ˜ํ…์ธ ์˜ ์ž๋ฆฌํ‘œ์‹œ์ž๋กœ null ํ•ญ๋ชฉ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค
    • PagingConfig.pageSize๋Š” ์—ฌ๋Ÿฌ ํ™”๋ฉด์˜ ํ•ญ๋ชฉ์ด ํฌํ•จ๋  ๋งŒํผ ์ถฉ๋ถ„ํžˆ ์ปค์•ผํ•œ๋‹ค. ํŽ˜์ด์ง€๊ฐ€ ๋„ˆ๋ฌด ์ž‘์œผ๋ฉด ํŽ˜์ด์ง€์˜ ์ฝ˜ํ…์ธ ๊ฐ€ ์ „์ฒด ํ™”๋ฉด์„ ๊ฐ€๋ฆฌ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๋ชฉ๋ก์ด ๊นœ๋ฐ•์ผ ์ˆ˜ ์žˆ๋‹ค. ํŽ˜์ด์ง€ ํฌ๊ธฐ๊ฐ€ ํด์ˆ˜๋ก ๋กœ๋“œ ํšจ์œจ์ด ์ข‹์ง€๋งŒ ๋ชฉ๋ก์ด ์—…๋ฐ์ดํŠธ๋  ๋•Œ ์ง€์—ฐ ์‹œ๊ฐ„์ด ๋Š˜์–ด๋‚  ์ˆ˜ ์žˆ๋‹ค
    • ๊ธฐ๋ณธ์ ์œผ๋กœ PagingConfig.maxSize๋Š” ๋ฌด์ œํ•œ์ด๋ฏ€๋กœ ํŽ˜์ด์ง€๊ฐ€ ์‚ญ์ œ๋˜์ง€ ์•Š๋Š”๋‹ค. ํŽ˜์ด์ง€๋ฅผ ์‚ญ์ œํ•˜๋ ค๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ์Šคํฌ๋กค ๋ฐฉํ–ฅ์„ ๋ณ€๊ฒฝํ•  ๋•Œ ๋„คํŠธ์›Œํฌ ์š”์ฒญ์ด ๋„ˆ๋ฌด ๋งŽ์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š๋„๋ก maxSize๋ฅผ ์ถฉ๋ถ„ํžˆ ํฐ ์ˆ˜๋กœ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. ์ตœ์†Ÿ๊ฐ’์€ pageSize + prefetchDistance * 2์ด๋‹ค
  • PagingSource๋ฅผ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์„ ์ •์˜ํ•˜๋Š” ํ•จ์ˆ˜

Paging.flow

PagingConfig๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Flow<PagingData<T>>๋ฅผ ์ „๋‹ฌ

fun getSearchResultStream(query: String): Flow<PagingData<SearchDocument>> {
        return Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
            ),
            pagingSourceFactory = { SearchPagingSource(query, api) }
        ).flow
    }

PagingDataAdapter

PagingData๋ฅผ RecyclerView์— ๋ฐ”์ธ๋”ฉํ•˜๋ ค๋ฉด PagingDataAdapter๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด๋œ๋‹ค.
PagingData ์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๋งˆ๋‹ค PagingDataAdapter์—์„œ ์•Œ๋ฆผ์„ ๋ฐ›์€ ๋‹ค์Œ RecyclerView์— ์—…๋ฐ์ดํŠธํ•˜๋ผ๋Š” ์‹ ํ˜ธ๋ฅผ ๋ณด๋‚ธ๋‹ค.

PagingDataAdapter<T : Any, VH : RecyclerView.ViewHolder>

T๋Š” PagingData์˜ ํƒ€์ž… ์ด๋‹ค.

class SearchPagingAdapter : PagingDataAdapter<SearchUiItem, RecyclerView.ViewHolder>(SearchDiffUtil()) {
    // body is unchanged
}

LoadStateAdapter๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Header, Footer ์ถ”๊ฐ€ํ•˜๊ธฐ

LoadStateAdapter๋Š” ๋กœ๋“œ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ์ž๋™์œผ๋กœ ์•Œ๋ฆผ์„ ๋ฐ›๋Š”๋‹ค. ์ƒํƒœ๋Š” LoadState๊ฐ’์œผ๋กœ onCreateViewHolder, onBindViewHolder ํ•จ์ˆ˜๋กœ ๋„˜์–ด์˜ค๊ฒŒ ๋œ๋‹ค.

class SearchPagingLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<SearchLoadStateViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): SearchLoadStateViewHolder {
        return SearchLoadStateViewHolder.create(parent, retry)
    }
    
    override fun onBindViewHolder(holder: SearchLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }
}

LoadState๊ฐ€ LoadState.Loading์ผ๋•Œ๋Š” ProgressBarํ‘œ์‹œ, LoadState.Error์ผ๋•Œ๋Š” Retry๋ทฐ๋ฅผ ํ‘œ์‹œํ•ด์ฃผ๋ฉด ๋œ๋‹ค.
์žฌ์‹œ๋„ ๋งค์ปค๋‹ˆ์ฆ˜์€ PagingDataAdapter์˜ retryํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
PagingDataAdapter์— ํ—ค๋”์™€ ํ‘ธํ„ฐ๋Š” ์•„๋ž˜์ฒ˜๋Ÿผ ๋“ฑ๋กํ•œ๋‹ค.

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = SearchPagingLoadStateAdapter { adapter.retry() },
            footer = SearchPagingLoadStateAdapter { adapter.retry() }
    )
}

๊ตฌ๋ถ„์ž(Separator) ์ถ”๊ฐ€ํ•˜๊ธฐ

๊ตฌ๋ถ„์ž๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ชฉ๋ก์˜ ๊ฐ€๋…์„ฑ์„ ๊ฐœ์„ ํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ์˜ˆ๋ฅผ๋“ค์–ด ๋‚ ์งœ๋ณ„๋กœ ์ •๋ ฌํ•œ๋‹ค๊ณ  ํ• ๋•Œ ์›” ๋ณ„๋กœ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ตฌ๋ถ„์ž๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค.

PagingData์˜ ๋ชจ๋ธ ๋ณ€๊ฒฝ

 sealed class SearchUiItem {
        data class DocumentItem(val document: Document) : SearchUiItem()
        data class SeparatorItem(val description: String) : SearchUiItem()
}
 private fun getPager(requestParam: SearchPagingParam): LiveData<PagingData<SearchUiItem>> {
        return getSearchPagingData(requestParam)
            .map { pagingData -> pagingData.map { document -> SearchUiItem.DocumentItem(document) } }
            .map { pagingData ->
                when (sort) {
                    SortType.TITLE -> insertTitleSeparator(pagingData)
                    else -> insertDateSeparator(pagingData)
                }
            }
            .cachedIn(viewModelScope)
            .asLiveData()
    }

    private fun insertTitleSeparator(it: PagingData<SearchUiItem.DocumentItem>): PagingData<SearchUiItem> {
        return it.insertSeparators { before, after ->
            if (after == null) return@insertSeparators null
            if (before == null) return@insertSeparators SearchUiItem.SeparatorItem("${after.document.title.first()}")

            val beforeFirstWord = before.document.title.first()
            val afterFirstWord = after.document.title.first()
            return@insertSeparators when (beforeFirstWord != afterFirstWord) {
                true -> SearchUiItem.SeparatorItem("${after.document.title.first()}")
                else -> null
            }
        }
    }

    private fun insertDateSeparator(it: PagingData<SearchUiItem.DocumentItem>): PagingData<SearchUiItem> {
        return it.insertSeparators { before, after ->
            if (after == null) return@insertSeparators null
            if (before == null) return@insertSeparators SearchUiItem.SeparatorItem(
                dateHelper.convert(after.document.date)
            )

            val beforeDate = before.document.date
            val afterDate = after.document.date

            val beforeDateString = dateHelper.convert(beforeDate, R.string.date_month)
            val afterDateString = dateHelper.convert(afterDate)
            return@insertSeparators when (beforeDateString != afterDateString) {
                true -> SearchUiItem.SeparatorItem(description = afterDateString)
                else -> null
            }
        }
    }

๋„คํŠธ์›Œํฌ ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํŽ˜์ด์ง•(RemoteMediator)

๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜์—ฌ ์•ฑ์— ์˜คํ”„๋ผ์ธ ์ง€์›์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.
์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊ฐ€ ์•ฑ์˜ ์ •๋ณด ์†Œ์Šค๊ฐ€ ๋˜๊ณ  ํ•ญ์ƒ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋กœ๋“œ ๋œ๋‹ค.
๋” ์ด์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๋•Œ๋งˆ๋‹ค ๋„คํŠธ์›Œํฌ์— ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ์š”์ฒญํ•œ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•œ๋‹ค.
๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊ฐ€ ์ •๋ณด ์†Œ์Šค์ด๋ฏ€๋กœ ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜๋ฉด UI๊ฐ€ ์ž๋™์œผ๋กœ ์—…๋ฐ์ดํŠธ ๋œ๋‹ค.

Room ์„ค์ •

Entity ์ถ”๊ฐ€

๋ชฉ๋ก์„ ๋กœ๊ฑธ DB์— ์ €์žฅํ•˜๋ฏ€๋กœ ์•„๋ž˜์™€๊ฐ™์ด Entity(ํ…Œ์ด๋ธ”)๋ฅผ ์ž‘์„ฑํ•œ๋‹ค.

@Entity(tableName = "document_table")
internal data class DocumentTable(
    @field:SerializedName("type")
    val type: DocumentType,
    @PrimaryKey
    @field:SerializedName("url")
    val url: String,
    @field:SerializedName("thumbnail")
    val thumbnail: String,
    @field:SerializedName("title")
    val title: String,
    @field:SerializedName("content")
    val content: String,
    @field:SerializedName("date")
    val date: Date?,
)

RemoteMediator์˜ PagingState์—์„œ ๋งˆ์ง€๋ง‰์œผ๋กœ ๋กœ๋“œ๋œ ํ•ญ๋ชฉ์„ ๊ฐ€์ ธ์˜ค๋ฉด ํ•ญ๋ชฉ์ด ์†ํ•œ ํŽ˜์ด์ง€์˜ ์ƒ‰์ธ์„ ์•Œ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ,
์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด remote_key๋ผ๊ณ  ํ•˜๋Š” ๋‹ค์Œ ๋ฐ ์ด์ „ ํŽ˜์ด์ง€ ํ‚ค๋ฅผ ์ €์žฅํ•˜๋Š” ๋‹ค๋ฅธ Entity(ํ…Œ์ด๋ธ”)์„ ์ถ”๊ฐ€ ํ•œ๋‹ค.

@Entity(tableName = "remote_key")
data class RemoteKeyTable(
    @PrimaryKey
    @field:SerializedName("position")
    val position: Int? = -1,
    @field:SerializedName("prev_key")
    val prevKey: Int?,
    @field:SerializedName("next_key")
    val nextKey: Int?
)

Dao ์ถ”๊ฐ€

Entity(ํ…Œ์ด๋ธ”)์— ์ ‘๊ทผํ•˜๊ธฐ์œ„ํ•œ ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€, Title ๋˜๋Š” Date๋กœ ์ •๋ ฌํ•˜์—ฌ ํ…Œ์ด๋ธ”์—์„œ ๊ฐ€์ ธ์˜ค๋„๋ก ์ฟผ๋ฆฌ๋ฅผ ์ž‘์„ฑํ•œ๋‹ค.

@Dao
internal interface DocumentDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertDocuments(documents: List<DocumentTable>)

    @Query(
        "SELECT * FROM document_table " +
                "WHERE title Like '%'||:query||'%' or content Like '%'||:query||'%' " +
                "ORDER BY date DESC"
    )
    fun getDocumentByDate(query: String): PagingSource<Int, DocumentTable>

    @Query(
        "SELECT * FROM document_table " +
                "WHERE title Like '%'||:query||'%' or content Like '%'||:query||'%' " +
                "ORDER BY title ASC"
    )
    fun getDocumentByTitle(query: String): PagingSource<Int, DocumentTable>

    @Query("DELETE from document_table")
    suspend fun clearAllDocuments()
}

๋‹ค์Œ ๋ฐ ์ด์ „ ํŽ˜์ด์ง€ ํ‚ค๋ฅผ ์ €์žฅํ•˜๋Š” ๋‹ค๋ฅธ Entity(ํ…Œ์ด๋ธ”)๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ •์˜.

@Dao
interface RemoteKeyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertKey(remoteKey: RemoteKeyTable)

    @Query("SELECT * FROM remote_key")
    suspend fun getRemoteKeyTable(): Array<RemoteKeyTable>?

    @Query("DELETE FROM remote_key")
    suspend fun clearRemoteKeys()
}

Database ์ •์˜

@Database(
    entities = [DocumentTable::class, RemoteKeyTable::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(DateConverter::class)
internal abstract class AppDatabase : RoomDatabase() {

    abstract fun documentDao(): DocumentDao

    abstract fun remoteKeyDao(): RemoteKeyDao

    companion object {

        private const val NAME = "app_database"

        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context,
                    AppDatabase::class.java,
                    NAME
                ).build()
            }
        }
    }
}

RemoteMediator ๊ตฌํ˜„

RemoteMediator๋Š” load ํ•จ์ˆ˜๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.

public abstract suspend fun load(
        loadType: LoadType,
        state: PagingState<Key, Value>
    ): MediatorResult

loadํ•จ์ˆ˜๋Š” LoadType๊ณผ, PagingState๋ฅผ ์ „๋‹ฌํ•˜๋ฉฐ, ๋ฐ˜ํ™˜๊ฐ’์œผ๋กœ MediatorResult๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผํ•œ๋‹ค.

LoadType

  • LoadType.REFRESH: PagingSource๊ฐ€ ๋ฌดํšจํ™”๋˜์—ˆ๊ณ , PagingData๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ• ๋•Œ, ์ดˆ๊ธฐํ™” ์‹ ํ˜ธ
  • LoadType.PREPEND: PagingSource์˜ ํ˜„์žฌ ๋ฐ์ดํ„ฐ์˜ ์ฒซ๋ฒˆ์งธ ํŽ˜์ด์ง€์— ๋„๋‹ฌํ•˜์˜€์„๊ฒฝ์šฐ
  • LoadType.APPEND: PagingSource์˜ ํ˜„์žฌ ๋ฐ์ดํ„ฐ์˜ ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์— ๋„๋‹ฌํ•˜์˜€์„๊ฒฝ์šฐ

PagingState : ์ด์ „์— ๋กœ๋“œ๋œ ํŽ˜์ด์ง€, ๋ชฉ๋ก์—์„œ ๊ฐ€์žฅ ์ตœ๊ทผ์— ์•ก์„ธ์Šคํ•œ index, ํŽ˜์ด์ง• ์ŠคํŠธ๋ฆผ์„ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ ์ •์˜ํ•œ PagingConfig ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ๋‹ค.

MediatorResult

  • MediatorResult.Success: ๋„คํŠธ์›Œํฌ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜จ ๊ฒฝ์šฐ, ์—ฌ๊ธฐ์—์„œ ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ์—ฌ๋ถ€(endOfPaginationReached๋กœ)๋ฅผ ์ „๋‹ฌํ•œ๋‹ค
  • MediatorResult.Error: ๋„คํŠธ์›Œํฌ์— ๋ฐ์ดํ„ฐ๋ฅผ ์š”์ฒญํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฒฝ์šฐ ๋ฐ˜ํ™˜

load ํ•จ์ˆ˜ ๊ตฌํ˜„

load ํ•จ์ˆ˜์—์„œ ์ œ๊ณต๋˜๋Š” LoadType์„ ํ™œ์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€๋กœ๋“œ ์—ฌ๋ถ€์— ๋Œ€ํ•œ ๋กœ์ง์„ ๊ตฌํ˜„ํ•œ ๋’ค, ๊ฒฐ๊ณผ๋ฅผ loadํ•จ์ˆ˜์˜ ๋ฐ˜ํ™˜ํ˜•์ธ MediatorResult์— endOfPaginationReached ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋„˜๊ฒจ์ฃผ์–ด ๋ฐ์ดํ„ฐ ๋กœ๋“œ๋ฅผ ๋๋งˆ์น ์ง€ ํŒ๋‹จํ•œ๋‹ค.

  override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, DocumentTable>
    ): MediatorResult {
        try {
            val position: Int = when (loadType) {
                LoadType.REFRESH -> STARTING_POSITION
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val key = database.remoteKeyDao().getRemoteKeyTable()?.lastOrNull()
                    val nextKey = key?.nextKey
                        ?: return MediatorResult.Success(endOfPaginationReached = key != null)
                    nextKey
                }
            }

            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    database.documentDao().clearAllDocuments()
                    database.remoteKeyDao().clearRemoteKeys()
                }
            }

            val documentList = fetchDocumentList(position)

            database.withTransaction {
                val prevKey = if (position == STARTING_POSITION) null else position - 1
                val nextKey = if (documentList.hasMore) position + 1 else null
                val keys = RemoteKeyTable(position = position, prevKey = prevKey, nextKey = nextKey)

                database.documentDao().insertDocuments(documentList.documents.map { it.toTable() })
                database.remoteKeyDao().insertKey(keys)
            }

            return MediatorResult.Success(endOfPaginationReached = !documentList.hasMore)
        } catch (exception: IOException) {
            return MediatorResult.Error(exception)
        } catch (exception: HttpException) {
            return MediatorResult.Error(exception)
        }
    }

Pager ๋นŒ๋”๋กœ PageData flow ์ƒ์„ฑ

Pager์— remoteMediator์™€ pagingSourceFactory๋ฅผ ์ „๋‹ฌํ•œ๋‹ค.

@ExperimentalPagingApi constructor(
    config: PagingConfig,
    initialKey: Key? = null,
    remoteMediator: RemoteMediator<Key, Value>?,
    pagingSourceFactory: () -> PagingSource<Key, Value>
) {
    ...

pagingSourceFactory๋Š” ์ •๋ ฌ ํƒ€์ž…์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒŒ ์ „๋‹ฌ๋˜๋„๋ก ๊ตฌํ˜„.

@Singleton
internal class DocumentRepositoryImpl @Inject constructor(
    private val searchRemoteMediatorFactory: SearchRemoteMediatorFactory,
    private val documentDao: DocumentDao
) : DocumentRepository {
    override fun fetchDocumentPagingData(param: SearchPagingParam): Flow<PagingData<Document>> {
        @OptIn(ExperimentalPagingApi::class)
        return Pager(
            config = PagingConfig(
                pageSize = SearchRemoteMediator.PER_PAGE_SIZE,
                prefetchDistance = 3

            ),
            remoteMediator = searchRemoteMediatorFactory.create(param)
        ) {
            when (param.sortType) {
                SortType.TITLE -> documentDao.getDocumentByTitle(param.query)
                else -> documentDao.getDocumentByDate(param.query)
            }
        }.flow.map {
            it.map { documentTable ->
                documentTable.toEntity()
            }
        }
    }
}

์ƒ์„ฑํ•œ PagingData๋Š” PagingDataAdapter๋กœ ์ „๋‹ฌํ•œ๋‹ค.