Upgrade to Pro — share decks privately, control downloads, hide ads and more …

実践 Paging 3

tick-taku
September 28, 2021

実践 Paging 3

https://pepabo.connpass.com/event/225504/

で発表した Paging 3 の導入の話です!

tick-taku

September 28, 2021
Tweet

More Decks by tick-taku

Other Decks in Programming

Transcript

  1. WHO ARE YOU? 伊藤 拓海 (tick-taku) ▸ 2020/12 GMO ϖύϘ

    minne ࣄۀ෦ ೖࣾ ▸ Android ΞϓϦΤϯδχΞ ▸ ޷͖ͳݴޠ: Kotlin, Python ▸ Twitter: @tick_taku77 ▸ ࠷ۙ NEW GAME! ͕׬݁ͯ͠͠·͍࢓ࣄͷϞνϕʔγϣϯΛ୳͠த
  2. ΞδΣϯμ ▸ Paging 3 ͱ͸ ▸ ಋೖ ▸ PagingSource ▸

    ViewModel ▸ PagindDataAdapter ▸ LoadState ▸ SwipeRefreshLayout ▸ ࠾༻ͯ͠Έͯ
  3. લఏ class CatRepository( private val catSearchApi: CatSearchApi ) { suspend

    fun cats(limit: Int, page: Int): Result<List<Cat>> = runCatching { withContext(Dispatchers.IO) { catSearchApi.cats(limit, page) } } } interface CatSearchApi { @Headers("x-api-key: apiKey") @GET("images/search") suspend fun cats(@Query("limit") limit: Int, @Query("page") page: Int): List<Cat> } @Serializable data class Cat( val id: String, val url: String, val breeds: List<Breed> )
  4. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } }
  5. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } 1ͭ໨ Key ͷܕ 2ͭ໨ σʔλͷܕ PagingSource ͷδΣωϦΫε
  6. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } LoadResult Ͱϖʔδϯάͷ Success or Failure Λ ϋϯυϦϯά LoadResult.Page ੒ޭ LoadResult.Error ࣦഊ
  7. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } औಘ͢ΔϖʔδͷΩʔͱͳΔ ύϥϝʔλ params.key લճ load ͕ݺ͹Εͨ࣌ʹ ࢦఆͨ͠ nextKey or prevKey ( ॳճ͸ null ) nextKey ࣍ʹ load ͕ݺ͹Εͨ࣌ɺ ݱࡏΑΓ΋ޙͷϖʔδΛ औಘ͢Δ࣌ͷΩʔ (null Λ౉͢ͱऴΘΓ) prevKey ࣍ʹ load ͕ݺ͹Εͨ࣌ɺ ݱࡏΑΓ΋લͷϖʔδΛ औಘ͢Δ࣌ͷΩʔ (null Λ౉͢ͱऴΘΓ)
  8. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } औಘ͢ΔϖʔδͷΩʔͱͳΔ ύϥϝʔλ params.key લճ load ͕ݺ͹Εͨ࣌ʹ ࢦఆͨ͠ nextKey or prevKey ( ॳճ͸ null ) nextKey ࣍ʹ load ͕ݺ͹Εͨ࣌ɺ ݱࡏΑΓ΋ޙͷϖʔδΛ औಘ͢Δ࣌ͷΩʔ (null Λ౉͢ͱऴΘΓ) prevKey ࣍ʹ load ͕ݺ͹Εͨ࣌ɺ ݱࡏΑΓ΋લͷϖʔδΛ औಘ͢Δ࣌ͷΩʔ (null Λ౉͢ͱऴΘΓ)
  9. PagingSource class CatPagingSource( private val repository: CatRepository ): PagingSource<Int, Cat>()

    { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> { val currentKey = params.key ?: 1 return repository.cats(params.loadSize, currentKey) .fold( onSuccess = { LoadResult.Page( data = it, prevKey = (currentKey - 1).takeIf { key -> key > 0 }, nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() } ) }, onFailure = { LoadResult.Error(it) } ) } override fun getRefreshKey(state: PagingState<Int, Cat>): Int? = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } refresh() ͞Εͨ࣌ʹ params.key ʹ౉͢ϖʔδΩʔ
  10. PagingSource class CatUseCase( private val repository: CatRepository ) { val

    limit: Int = 20 fun cats(): CatPagingSource = CatPagingSource(repository) } ϖʔδϯά͸ΞϓϦέʔγϣϯݻ༗ͷϩδοΫ
  11. PagingSource class CatUseCaseTest { private val mockCats = listOf( Cat("1",

    "", emptyList()), Cat("2", "", emptyList()), Cat("3", "", emptyList()) ) private val repository: CatRepository = mock { onBlocking { cats(any(), any()) } doReturn Result.success(mockCats) } private val useCase: CatUseCase by lazy { CatUseCase(repository) } private val firstPage = 1 private val mockLoadParams by lazy { PagingSource.LoadParams.Refresh( key = firstPage, loadSize = useCase.limit, placeholdersEnabled = true ) } @Test @Throws(Exception::class) fun loadResult_success() = runBlocking { useCase.cats().load(mockLoadParams).let { assertTrue { it is PagingSource.LoadResult.Page } (it as PagingSource.LoadResult.Page).let { result -> result.itemsBefore assertEquals(result.data.size, mockCats.size) assertEquals(result.data.first().id, "1") assertNotNull(result.nextKey) assertEquals(result.nextKey, firstPage + 1) assertEquals(result.prevKey, firstPage - 1) } } } @Test @Throws(Exception::class) fun loadResult_failure() = runBlocking { whenever(repository.cats(any(), any())) doReturn Result.failure(mock<HttpException>()) useCase.cats().load(mockLoadParams).let { assertTrue { it is PagingSource.LoadResult.Error } assertTrue { (it as PagingSource.LoadResult.Error).throwable is HttpException } } } }
  12. ViewModel class CatViewModel( private val useCase: CatUseCase ): ViewModel() {

    val cats: LiveData<PagingData<Cat>> = Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) { useCase.cats() }.liveData.cachedIn(this) }
  13. ViewModel class CatViewModel( private val useCase: CatUseCase ): ViewModel() {

    val cats: LiveData<PagingData<Cat>> = Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) { useCase.cats() }.liveData.cachedIn(this) }
  14. ViewModel class CatViewModel( private val useCase: CatUseCase ): ViewModel() {

    val cats: LiveData<PagingData<Cat>> = Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) { useCase.cats() }.liveData.cachedIn(this) } pageSize Ұ౓ʹऔಘ͢Δϖʔδͷσʔλ਺ LoadParams.loadSize ʹೖΔ initialLoadSize ॳճϩʔυ࣌ʹऔಘ͢Δσʔλ਺ σϑΥϧτ஋͸ pageSize ✖ 3 enablePlaceholders σʔλ͕ null ͷ৔߹ʹϓϨʔεϗϧμʔΛදࣔ͢Δ͔ PagingConfig ͷओͳύϥϝʔλ
  15. ViewModel class CatViewModel( private val useCase: CatUseCase ): ViewModel() {

    val cats: LiveData<PagingData<Cat>> = Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) { useCase.cats() }.liveData.cachedIn(this) } val <Key : Any, Value : Any> Pager<Key, Value>.liveData: LiveData<PagingData<Value>> get() = flow.asLiveData()
  16. ViewModel class CatViewModel( private val useCase: CatUseCase ): ViewModel() {

    val cats: LiveData<PagingData<Cat>> = Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) { useCase.cats() }.liveData.cachedIn(this) } ▸ Lifecycle (lifecycleScope) ▸ ViewModel (viewModelScope) ▸ CoroutineScope Ωϟογϡͷείʔϓ
  17. PagingDataAdapter class CatPagingAdapter: PagingDataAdapter<Cat, CatPagingAdapter.ViewHolder>(diffCallBack) { inner class ViewHolder(val binding:

    ViewCatBinding): RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder( ViewCatBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.binding.cat = getItem(position) } } private val diffCallBack = object: DiffUtil.ItemCallback<Cat>() { override fun areItemsTheSame(oldItem: Cat, newItem: Cat): Boolean = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Cat, newItem: Cat): Boolean = oldItem == newItem } ීஈ௨Γͷ RecycleView.Adapter ͱ΄΅มΘΓͳ͠
  18. PagingDataAdapter class CatActivity : AppCompatActivity() { private val viewModel: CatViewModel

    by viewModels() private val listAdapter = CatPagingAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.catList.adapter = listAdapter viewModel.cats.observe(this) { lifecycleScope.launchWhenStarted { listAdapter.submitData(it) } } } }
  19. LoadState <?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> <import type="android.view.View"/>

    <variable name="isLoading" type="boolean" /> <variable name="isError" type="boolean" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="30dp"> <ProgressBar style="Widget.AppCompat.ProgressBar.Indicator" android:layout_width="30dp" android:layout_height="30dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" android:visibility="@{isLoading ? View.VISIBLE : View.GONE}" /> <ImageView android:id="@+id/retry" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:padding="8dp" android:src="@drawable/ic_retry" android:contentDescription="@string/retry" android:visibility="@{isError ? View.VISIBLE : View.GONE}"/> </androidx.constraintlayout.widget.ConstraintLayout> </layout> private typealias RetryListener = () -> Unit class CatPagingLoadStateAdapter( private val retry: RetryListener ): LoadStateAdapter<CatPagingLoadStateAdapter.ViewHolder>() { inner class ViewHolder( private val binding: ViewCatLoadStateBinding ): RecyclerView.ViewHolder(binding.root) { fun bind(loadState: LoadState) { Log.d(this::class.simpleName, "LoadState: $loadState") binding.isLoading = loadState is LoadState.Loading binding.isError = loadState is LoadState.Error binding.retry.setOnClickListener { retry() } binding.executePendingBindings() } } override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder = ViewHolder( ViewCatLoadStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) { holder.bind(loadState) } }
  20. LoadState class CatActivity : AppCompatActivity() { private val listAdapter =

    CatPagingAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.catList.adapter = listAdapter .withLoadStateFooter(CatPagingLoadStateAdapter { listAdapter.retry() }) } }
  21. LoadState class CatActivity : AppCompatActivity() { private val listAdapter =

    CatPagingAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.catList.adapter = listAdapter listAdapter.addLoadStateListener { binding.progressBar.isVisible = it.append is LoadState.Loading binding.retryIcon.isVisible = it.append is LoadState.Error } } } or Activity ͷ্ʹࣗલͰϨΠΞ΢τ͢Δ৔߹
  22. LoadState fun withLoadStateFooter( footer: LoadStateAdapter<*> ): ConcatAdapter { addLoadStateListener {

    loadType, loadState -> if (loadType == LoadType.APPEND) { footer.loadState = loadState } } return ConcatAdapter(this, footer) } withLoadStateFooter ͸ͦ΋ͦ΋ APPEND ʹ͔͠൓Ԡ͠ͳ͍
  23. LoadState public enum class LoadType { /** * [PagingData] content

    being refreshed, which can be a result of [PagingSource] * invalidation, refresh that may contain content updates, or the initial load. */ REFRESH, /** * Load at the start of a [PagingData]. */ PREPEND, /** * Load at the end of a [PagingData]. */ APPEND } LoadType ͸ enum ͳͷͰ 1 Πϕϯτʹѻ͑Δͷ͸ 1 ͚ͭͩ
  24. LoadState launch { var prev = LoadStates.IDLE accessor.state.collect { if

    (prev.refresh != it.refresh) { loadStates.set(REFRESH, true, it.refresh) dispatchIfValid(REFRESH, it.refresh) } if (prev.prepend != it.prepend) { loadStates.set(PREPEND, true, it.prepend) dispatchIfValid(PREPEND, it.prepend) } if (prev.append != it.append) { loadStates.set(APPEND, true, it.append) dispatchIfValid(APPEND, it.append) } prev = it } } LoadType ͸·ͣ REFRESH ͔Β൑ఆ͞ΕΔ
  25. SwipeRefreshLayout class CatActivity : AppCompatActivity() { private val listAdapter =

    CatPagingAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.catList.adapter = listAdapter .withLoadStateFooter(CatPagingLoadStateAdapter { listAdapter.retry() }) listAdapter.addLoadStateListener { binding.refreshLayout.isRefreshing = it.refresh is LoadState.Loading } binding.refreshLayout.setOnRefreshListener { listAdapter.refresh() } } }
  26. SwipeRefreshLayout fun <T, VH: RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.andRefreshable( swipeRefreshLayout: SwipeRefreshLayout )

    = apply { addLoadStateListener { swipeRefreshLayout.isRefreshing = it.refresh is LoadState.Loading } swipeRefreshLayout.setOnRefreshListener { refresh() } } class CatActivity : AppCompatActivity() { private val listAdapter = CatPagingAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.catList.adapter = listAdapter .andRefreshable(binding.refreshLayout) .withLoadStateFooter(CatPagingLoadStateAdapter { listAdapter.retry() }) } }
  27. ಋೖલʹ࢖͍ͬͯͨ Paging ػߏ (Ұ෦ൈਮ) abstract class PagingScrollListener( private val layoutManager:

    LinearLayoutManager, private val remainingAmount: Int ): RecyclerView.OnScrollListener() { private var page = 1 private var prevTotalCount = 0 private var isLoading = false override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) if (dx == 0 && dy == 0) return val totalItemCount = layoutManager.itemCount if (totalItemCount != prevTotalCount) { isLoading = false } prevTotalCount = totalItemCount val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + 1 val loadLine = totalItemCount - remainingAmount if (lastVisibleItem >= loadLine && !isLoading) { isLoading = true paging(++page) } } abstract fun paging(page: Int) fun reset() { page = 1 } }
  28. ࠾༻ͯ͠Έͯ ▸ σʔληοτͷ؅ཧίετ࡟ݮɾ࣮૷ϋʔυϧͷ௿Լ ▸ Coroutine ΍ Lifecycle ͱͷ࿈ܞ͕པ΋͍͠ ▸ গͳ͍ίʔυͰ

    Load, Error, Retry, Refresh ͕࣮ݱͰ͖Δ ▸ ύϑΥʔϚϯεͷ޲্ ▸ εϜʔζͳεΫϩʔϧ͕࣮ݱͰ͖ͨ