33

Android App 開發實戰系列 Part 3. Paging + Repository

 3 years ago
source link: https://enginebai.com/2020/09/16/moviehunt-part-3/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Part 2. 我們講解資料來源和 API,而 Part3. 則是要開始講解 Model 層,裡面會提到如何實作資料提供者 Repository、如何宣告 Model 以及介紹 Paging 基本原理和導入使用。

這章節對應到Part 1. 所提到的 MVVM Architecture 架構圖就是底層的 Remote + Repository(以下簡稱 Repo):

vmyyqyz.png!mobile

完整程式碼 https://github.com/enginebai/MovieHunt 已經釋出 ,可以下載程式碼邊看碼邊學習,歡迎給星 :star: 支持。這一系列文章是有連貫性的,如果還沒看過前面文章,建議先去看過前面的章節。傳送門:(Part 1.) (Part 2.)

分頁載入的需求

首先要先來講一個簡單且常見的需求,現在很多 App 都需要呈現列表,列表資料從 API 來的,如果資料量很大的時候,列表 API 不會一次把所有資料都回傳,因為這樣做會影響到效能和流量,使用者也可能不會一次看完所有資料。

所以我們設計 API 的用法時,會考量分頁載入 ( Pagination ),API 一次會回傳小數量的資料 (10 ~ 20 筆),當使用者滾動列表到底或者需要看更多資料時,再去拉下一頁的資料。

mq2uQj.png!mobile 分頁機制

TMDB API 列表也是有這樣的設計,看 API 文件舉 GET /movie/popular 為例,是有 page 的參數可以設定,可以指定要取得哪一個分頁:

jy6JJvY.jpg!mobile

針對分頁載入 Google 有推出了 Paging Library 來解決這樣的需求,讓我們來探究原理和用法。

Android Paging Library 詳解

在使用之前我們來看一下這個官方套件的大致原理,Paging Library 主要是用在 RecyclerView ,我們會在使用 RecyclerView 的時候會寫 Adapter ,而在 Adapter.onBindViewHolder() 會去拿資料 List.getItem(position) 來顯示,呼叫 List.getItem(position) 的時候我們就會知道幾個資訊:

  1. 列表有資料了嗎? → 沒有的話表示第一頁還沒完成載入 → 該先去載入第一頁。
  2. 有資料了 → 現在拿到列表的哪個位置的資料。
  3. 是不是已經滑到底沒資料了 → 該載入下一頁資料了。
jayMveA.png!mobile

所以原理就在在 Adapter 可以有一個 List 的變數,然後 List.getItem(position) 的時候可以知道要不要載入第 n 頁的資料 (n >= 1,包含第一頁),如果沒有更多資料時需要載入的時候,就會向資料來源要資料。

基於這樣的原理,可以轉換成 Paging 三種主要的元件:

  1. PagedListAdapter : 和傳統 RecyclerView.Adapter 扮演一樣的職責角色,而多賦予支援 Paging 功能。
  2. PagedList : 用來儲存分頁資料,是繼承一般傳統的 List ,多了支援 Paging 的方法。
  3. DataSource : 負責把資料載入到 PagedList 的類別。

這三個主要元件的使用方式以虛擬碼呈現大概的樣子如下:

class MovieDataSource : DataSource() {
   private val api: MovieApiService by inject()


   fun getMovieList(): PagedList<MovieListResponse> {
       return api.fetchMovieList()
   }
}


class MoviePagedListAdapter: PagedListAdapter() {
   val pagedList: PagedList<MovieListResponse>


   override fun onBindViewHolder(holder: Holder, position: Int) {
       val movie = getItem(position)
       ... // bind data
   }
}


class MovieListActivity() : BaseActivity() {


   private val dataSource: MovieDataSource by inject()
  
   private fun fetchList() {
       val adapter = MoviePagedListAdapter
       adapter.pagedList = dataSource.getMovieList()
       adapter.notifyDataChanged()
   }
}

view raw MovieDataSource.kt hosted with ❤ by GitHub

各元件運作的流程圖:

AJjeme3.png!mobile
  1. 使用者滑 RecyclerView 到列表的底部。
  2. PagedListAdapter 會去觸發 PagedList.getItem(position) 來取得資料顯示。
  3. PagedList.getItem(position) 沒有資料了,要去呼叫 DataSource 載入更多資料。
  4. DataSource 完成載入後, PagedList.getItem(position) 有資料可以回傳。
  5. PagedListAdapter 拿到資料後可以繼續顯示。

大致原理和運作流程就是這樣,現在我們可以來實作。

Model

實作 Paging 之前,我們先定義好我們的 Model 資料結構,我們的 Data Model 設計 主要是要參考 UI 介面來定義我們要的資料欄位和型態 ,我們不依照 API 來設計我們的 Model,而是要照 UI 來決定,這樣做的原因在於未來即便資料來源有變動,也不太影響我們實作 UI 的程式碼。

這邊我們總共有三種 item views 需要定義,找出需要呈現的資料來定義變數:

3qM7viI.png!mobile
data class MovieModel(
    val id: String,
    val posterPath: String?,
    val title: String?,
    val voteAverage: Float?,
    val voteCount: Int?,
    val releaseDate: String?,
    val genreList: List<Genre>?,
    val runtime: Int?
)

view raw MovieModel.kt hosted with ❤ by GitHub

這邊要注意的的是跟 API 的 Respnose Data Class 欄位一樣,要把所有欄位型態 設定為 Nullable (除了必要欄位,「必要」是指說沒有這欄位值後面都運作不了,像是 id)。

DataSource

這邊我們要來實作資料來源,Paging 的 DataSource 有三種:

AB3QF3E.png!mobile
  1. PageKeyedDataSource (最常見): 下一頁的資訊(要知道如何取得下一頁的資料)要從上一頁的資料取得。
  2. ItemKeyedDataSource : 下一頁的資訊要從上一頁的最後一筆資料而得。
  3. PositionalDataSource : 可以拉列表任意位置和分頁大小。

以我們的情境是提供 page 參數來指定分頁,所以選用 PagKeyedDataSource 來實作:

class MovieListDataSource(
    private val category: MovieCategory,
    private val initLoadState: BehaviorSubject<NetworkState>,
    private val loadMoreState: BehaviorSubject<NetworkState>
) : PageKeyedDataSource<Int, MovieModel>(), KoinComponent {


    private val api: MovieApiService by inject()
    private var currentPage: Int = -1


    override fun loadInitial(
        params: LoadInitialParams<Int>,
        callback: LoadInitialCallback<Int, MovieModel>
    ) {
        currentPage = 1
        api.fetchMovieList(category.key, currentPage)
            .doOnSubscribe { initLoadState.onNext(NetworkState.LOADING) }
            .doOnSuccess {
                it.results?.run {
                    callback.onResult(
                        this.mapToMovieModels(),
                        null,
                        calculateNextPage(it.totalPages)
                    )
                }
                initLoadState.onNext(NetworkState.IDLE)
            }
            .doOnError { initLoadState.onNext(NetworkState.ERROR) }
            .subscribe()
    }


    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
        if (-1 == params.key || NetworkState.LOADING == loadMoreState.value) return
        api.fetchMovieList(category.key, params.key)
            .doOnSubscribe { loadMoreState.onNext(NetworkState.LOADING) }
            .doOnSuccess {
                it.results?.run {
                    callback.onResult(this.mapToMovieModels(), calculateNextPage(it.totalPages))
                }
                loadMoreState.onNext(NetworkState.IDLE)
            }
            .doOnError { loadMoreState.onNext(NetworkState.ERROR) }
            .subscribe()
    }


    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
        // we don't need this
    }


    private fun calculateNextPage(totalPage: Int?): Int {
        totalPage?.run {
            currentPage = if (currentPage in 1 until totalPage) {
                currentPage.plus(1)
            } else {
                -1
            }
        }
        return currentPage
    }
}

view raw MovieListDataSource.kt hosted with ❤ by GitHub

基於 Paging 的設計,DataSource 將資料載入到 PagedList 時,實際上是把資料快照(snapshot)過去 PagedList,在 PagedList 的資料是不能做修改或更新,如果我們需要更新 PagedList 的資料,則需要產生一組新的 DataSource 和 PagedList。

因為這樣的設計,所以我們要需要實作一個 DataSource.Factory 來產生新的 DataSource:

class MovieListDataSourceFactory(private val category: MovieCategory): DataSource.Factory<Int, MovieModel>() {


   val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
   val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
   var dataSource: MovieListDataSource? = null


   override fun create(): DataSource<Int, MovieModel> {
       dataSource = MovieListDataSource(category, initLoadState, loadMoreState)
       return dataSource!!
   }
}

view raw MovieListDataSourceFactory.kt hosted with ❤ by GitHub

我們在顯示列表的需求上,常常會有載入中的動畫或者載入失敗的錯誤訊息需要在畫面呈現,因為只有 DataSource 才曉得真正資料是否載入中或者發生錯誤的狀態,所以我們在 DataSourceFactory 有兩個 Subject 來做為載入狀態的事件來源,每當有一個新的 DataSource 產生時,傳給新的實體去做狀態的變更設定,這樣這兩個 Subject 就可以提供給其他外部的元件訂閱來顯示載入中或錯誤訊息。

Repo

實作完 DataSource 之後我們來實作 Repo,這是 MVVM 當中 Model 的統一出口,負責提供每種 Model 的原始資料, 資料的方式會是使用可觀察的資料流來提供給外部的觀察者使用或做 Data binding ,這邊我們就要實作提供 PagedList 的方法:

interface MovieRepo {
   fun fetchMovieList(category: MovieCategory, pageSize: Int = DEFAULT_PAGE_SIZE): Observable<PagedList<MovieModel>>
}


class MovieRepoImpl : MovieRepo, KoinComponent {


   override fun fetchMovieList(
       category: MovieCategory,
       pageSize: Int
   ): Observable<PagedList<MovieModel>> {
       val dataSourceFactory = MovieListDataSourceFactory(category)
       val pagedListConfig = PagedList.Config.Builder()
           .setPageSize(DEFAULT_PAGE_SIZE)
           .setEnablePlaceholders(false)
           .build()
       return RxPagedListBuilder(dataSourceFactory, pagedListConfig)
           .setFetchScheduler(Schedulers.io())
           .buildObservable()
   }
}

view raw MovieRepo.kt hosted with ❤ by GitHub

Repo 我們使用 Interface 來做控制反轉,方法都是提供 RxJava 的 Observable 來外部訂閱,這邊我們就提供了 Observable<PagedList<MovieList>> 當作電影列表的分頁資料來源。

PagedList 要使用 Builder 來建立,Paging 提供 RxJava 或者 LiveData 兩種資料流的方式來建立分頁,Builder 要提供 DataSourceFactoryPagedList.Config 兩個參數,這邊 DataSourceFactory 就是我們剛剛實作的, PagedList.Config 則可以指定分頁的一些設定,礙於篇幅的緣故,詳細的可以 自行參考文件

Paging.Listing

我們 Repo 提供了 PagedList 的 Observable,但是似乎漏掉了剛剛提到的載入中狀態讓外部可以訂閱,回頭看看我們的 fetchMovieList() 方法,這個是提供 Observable<PagedList<MovieModel>> ,我們需要修改提供「PagedList + 載入中」的 Observable,我們另外宣告一個可以封裝所有需要提供給外部訂閱的資料類別:

data class Listing<T>(
  // the paged list for UI to observer
  val pagedList: Observable<PagedList<T>>,
  // the network request status for pull-to-refresh or first time refresh
  val refreshState: Observable<NetworkState>? = null,
  // the network request state to show load more progress or error
  val loadMoreState: Observable<NetworkState>? = null,
  // refresh the whole data set and fetch it from scratch
  val refresh: () -> Unit = {}
)

view raw Listing.kt hosted with ❤ by GitHub

MovieRepo.fetchMovieList() 就可以回傳 Listing ,外部元件就可以得到 PagedList 和載入中的狀態,最後修改的 MovieRepo:

interface MovieRepo {
   fun fetchMovieList(category: MovieCategory, pageSize: Int = DEFAULT_PAGE_SIZE): Listing<MovieModel>
}


class MovieRepoImpl : MovieRepo, KoinComponent {


   override fun fetchMovieList(
       category: MovieCategory,
       pageSize: Int
   ): Listing<MovieModel> {
       val dataSourceFactory = MovieListDataSourceFactory(category)
       val pagedListConfig = PagedList.Config.Builder()
           .setPageSize(DEFAULT_PAGE_SIZE)
           .setEnablePlaceholders(false)
           .build()
       val pagedList = RxPagedListBuilder(dataSourceFactory, pagedListConfig)
           .setFetchScheduler(Schedulers.io())
           .buildObservable()
      return Listing(
         pagedList = pagedList,
         refreshState = dataSourceFactory.initLoadState,
         loadMoreState = dataSourceFactory.loadMoreState,
         refresh = { dataSourceFactory.dataSource?.invalidate() }
      )
   }
}

view raw MovieRepo.kt hosted with ❤ by GitHub

這樣 Repo 實作就完成,下一篇我們會開始介紹 Repo 的使用者 — ViewModel 和 View。

如果你有任何和此專案相關的疑問,歡迎留言給我交流或討論。完整程式碼: https://github.com/enginebai/MovieHunt 歡迎 Fork + Star :star: 支持。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK