Android Jetpack Paging3

導(dǎo)語

Jetpack簡(jiǎn)介及其它組件文章
分頁(yè)加載是開發(fā)中非常常用的一種場(chǎng)景,我們會(huì)采用RecyclerView來進(jìn)行開發(fā),自己來維護(hù)列表數(shù)據(jù),通過改變數(shù)據(jù)刷新列表展示,Jetpack為我們提供了一個(gè)庫(kù)——Paging,Paging也是經(jīng)歷了多次更新,到Paging3時(shí),之前和之前完全不一樣了。

主要內(nèi)容

  • 什么是Paging3
  • Paging3的優(yōu)勢(shì)
  • 使用與結(jié)構(gòu)分層
  • Paging3重要類
  • 官方Demo
  • 實(shí)際項(xiàng)目中使用
  • 文檔

具體內(nèi)容

什么是Paging3

Paging3,分頁(yè)加載庫(kù),基于Paging2的基礎(chǔ)上做了很大的改動(dòng),可以說完全是兩個(gè)庫(kù),剛好現(xiàn)有的項(xiàng)目也用到了Paging2,可以說是痛并快樂著。而Paging3依然沒有處理呼聲最高的兩個(gè)“需求”:局部增刪的實(shí)現(xiàn)。當(dāng)然到第三個(gè)版本仍然沒有改動(dòng),Google肯定是有著自己的思考,這里是IssueTracker。

Paging3的優(yōu)勢(shì)

  • 內(nèi)置對(duì)錯(cuò)誤處理功能,重試、刷新等。
  • 對(duì)Kotlin協(xié)程和流Flow提供了一流的支持。
  • 對(duì)于結(jié)合RecyclerView使用時(shí),自帶自動(dòng)請(qǐng)求下一頁(yè),也就是分頁(yè)功能,可以說是絲滑。
  • 內(nèi)置請(qǐng)求信息去重功能,避免流量浪費(fèi),資源利用率較高,同時(shí)支持內(nèi)存緩存,處理分頁(yè)時(shí)高效利用系統(tǒng)資源。

使用與結(jié)構(gòu)分層

依賴
//引入依賴
dependencies {
  def paging_version = "3.1.0"
  implementation "androidx.paging:paging-runtime:$paging_version"
}
結(jié)構(gòu)分層

一般主要層級(jí)會(huì)分為三層,請(qǐng)求層、ViewModel層、UI頁(yè)面層。

  • 請(qǐng)求層
    主要是對(duì)數(shù)據(jù)源的定義與處理PagingSource,劃重點(diǎn)Google推薦的是單一可信數(shù)據(jù)源,即是官方的Demo,配合網(wǎng)絡(luò)請(qǐng)求與本地?cái)?shù)據(jù)庫(kù),即是從網(wǎng)絡(luò)獲取數(shù)據(jù)也并不是直接顯示到UI頁(yè)面上。而是更新了數(shù)據(jù)庫(kù)后,數(shù)據(jù)庫(kù)的變動(dòng)驅(qū)動(dòng)UI頁(yè)面的展示。確保了數(shù)據(jù)來源單一。這么做有什么好處與不足,一步步探討。RemoteMediator主要是用來處理數(shù)據(jù)的分頁(yè),同時(shí)數(shù)據(jù)可以來自于網(wǎng)絡(luò)與本地,起到了整合的作用。
  • ViewModel層
    數(shù)據(jù)配置Pager配合PagingConfig對(duì)數(shù)據(jù)流PagingData進(jìn)行配置,如請(qǐng)求的數(shù)據(jù)size大小,是否開啟null占位符等。
  • UI層
    適配器的定義PagingDataAdapter,分頁(yè)處理加載數(shù)據(jù)的核心類。跟普通的適配器區(qū)別不是很大,但是需要配合DiffUtil對(duì)數(shù)據(jù)進(jìn)行去重判斷。
  • 結(jié)構(gòu)分層圖


    結(jié)構(gòu)分層圖

Paging3重要類

PagingSource

PagingSource的實(shí)例用于為PagingData的實(shí)例加載數(shù)據(jù)頁(yè)面,每次刷新數(shù)據(jù)都會(huì)有一個(gè)單獨(dú)PagingData與之對(duì)應(yīng),而配合的DiffUtil則可以處理重復(fù)內(nèi)容的去重工作。Key在請(qǐng)求網(wǎng)絡(luò)數(shù)據(jù)時(shí)可以表示對(duì)應(yīng)的頁(yè)碼,請(qǐng)求的是數(shù)據(jù)庫(kù)的數(shù)據(jù)時(shí)也可以表示為位置Position。Value則是對(duì)應(yīng)DTO或者PO,當(dāng)然通常的項(xiàng)目中對(duì)于上層UI所使用的數(shù)據(jù)一般并不會(huì)直接使用原始數(shù)據(jù)。首先服務(wù)端返回的數(shù)據(jù)并不是都能夠被完全用于UI,為了簡(jiǎn)潔都會(huì)通過Mapper做一次映射,轉(zhuǎn)化成合理的VO數(shù)據(jù)。也即是DTO/PO ----Mapper<>–>VO.

RemoteMediator

協(xié)同網(wǎng)絡(luò)數(shù)據(jù)與本地?cái)?shù)據(jù)庫(kù)Room,但是官方的推薦做法并不是直接使用網(wǎng)絡(luò)數(shù)據(jù)作為數(shù)據(jù)源,是將網(wǎng)絡(luò)數(shù)據(jù)緩存到本地?cái)?shù)據(jù)庫(kù),由數(shù)據(jù)庫(kù)擔(dān)任唯一的數(shù)據(jù)源來驅(qū)動(dòng)頁(yè)面。實(shí)際開發(fā)過程中,數(shù)據(jù)是有實(shí)效性的,應(yīng)該在合適的時(shí)機(jī)使本地?cái)?shù)據(jù)失效而以服務(wù)端數(shù)據(jù)為主,并刷新到本地?cái)?shù)據(jù)庫(kù)。這就要定義初始化類型initialize:

  • LAUNCH_INITIAL_REFRESH:完全刷新本地?cái)?shù)據(jù),會(huì)阻塞包括PREPEND、APPEND,直到全量刷新成功以后返回新的數(shù)據(jù)
  • SKIP_INITIAL_REFRESH:加載本地?cái)?shù)據(jù),跳過遠(yuǎn)程刷新。
    對(duì)于RemoteMediator加載方法中Load里包含了一個(gè)參數(shù)LoadType,那么這個(gè)LoadType是什么呢?其實(shí)就是定義了刷新機(jī)制,集合RecyclerView的用戶操作,不斷上滑的過程中,Paging請(qǐng)求下一頁(yè)的內(nèi)容?;蛘咔袚Q了不同搜索條件那么自然是全量刷新,而查看已經(jīng)加載過的數(shù)據(jù),可以理解為從內(nèi)存數(shù)據(jù)中加載某一段數(shù)據(jù),也即是中間部分的數(shù)據(jù)。簡(jiǎn)單的里脊就是LoadType是用來監(jiān)聽UI操作的。
public enum class LoadType {
  //全量刷新
  REFRESH,
  //從初始開始加載數(shù)據(jù)(PaingData)
  PREPEND,
  //從PagingData最后一條開始加載數(shù)據(jù),需要從網(wǎng)絡(luò)獲取
  APPEND
}
Pager

可以直接創(chuàng)建一個(gè)單純的網(wǎng)絡(luò)數(shù)據(jù)分頁(yè),同時(shí)也支持本地與網(wǎng)絡(luò)共享的狀態(tài)。唯一的區(qū)別就是需要提供數(shù)據(jù)庫(kù)Room的查詢方法,并且提供RemoteMediator實(shí)例。當(dāng)然還包括一些配置條件PagingConfig,如網(wǎng)絡(luò)加載數(shù)據(jù)一頁(yè)的條目,是否開啟null占位。

//...
val customDao = database.customDao()
val pager = Pager(
  config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false),
  remoteMediator = CustomRemoteMediator(query,service,database),
  pagingSourceFactory = pagingSourceFactory
).flow
//....
companion object {
  const val NETWORK_PAGE_SIZE = 50
}

官方Demo

官網(wǎng)的CodeLab基于Paging3與Room結(jié)合的方式實(shí)現(xiàn)了通過關(guān)鍵字從Github搜索代碼倉(cāng)庫(kù)的小應(yīng)用,跟著走一遍可以加深對(duì)Paging3的理解,當(dāng)然目前某些Api還是實(shí)驗(yàn)性質(zhì)的,需要等一等正式版。項(xiàng)目地址CodeLab,源碼地址Github.編譯的時(shí)候可能會(huì)報(bào)錯(cuò),主要原因是因?yàn)镵otlin 1.6.0 版本在Room中(2.3.0)不支持使用 suspend @QUERY,需要升級(jí)Room的版本為2.4.0-alpha03。
官網(wǎng)的這個(gè)Demo將數(shù)據(jù)的唯一來源定為從Room中獲取,網(wǎng)絡(luò)數(shù)據(jù)緩存到本地,本地?cái)?shù)據(jù)庫(kù)的變動(dòng)通知到UI頁(yè)面的刷新。
整體數(shù)據(jù)獲取結(jié)構(gòu)分層:


結(jié)構(gòu)分層圖

數(shù)據(jù)庫(kù)為單一可信數(shù)據(jù)來源Single Source of Truth,而Pager的構(gòu)成部分包括RemoteMediator與本地?cái)?shù)據(jù)庫(kù)PagingSource。首先數(shù)據(jù)是從數(shù)據(jù)庫(kù)獲取的,當(dāng)緩存的數(shù)據(jù)已經(jīng)被完全加載完畢,會(huì)觸發(fā)拉取遠(yuǎn)程數(shù)據(jù)并緩存到本地,本地?cái)?shù)據(jù)的變更驅(qū)動(dòng)UI完成刷新。

數(shù)據(jù)模型Model

服務(wù)端獲取的數(shù)據(jù)實(shí)體定義,并新建數(shù)據(jù)表repos

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)
數(shù)據(jù)庫(kù)DB

遠(yuǎn)程鍵值表定義

@Entity(tableName = "remote_keys")
data class RemoteKeys(
   @PrimaryKey val repoId: Long,
   val prevKey: Int?,
   val nextKey: Int?
)

@Dao
interface RemoteKeysDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

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

@Dao
interface RepoDao {
   @Insert(onConflict = OnConflictStrategy.REPLACE)
   suspend fun insertAll(repos: List<Repo>)

    @Query(
        "SELECT * FROM repos WHERE " +
                "name LIKE :queryString OR description LIKE :queryString " +
                "ORDER BY stars DESC, name ASC"
    )
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

@Database(
    entities = [Repo::class, RemoteKeys::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    companion object {
        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE
                    ?: buildDatabase(context).also { INSTANCE = it }
            }

        private fun buildDatabase(context: Context) =
            Room.databaseBuilder(
                context.applicationContext,
                RepoDatabase::class.java, "Github.db"
            ).build()
    }
}
核心GithubRemoteMediator

主要看load中的具體實(shí)現(xiàn):

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
  val page = when(loadType) {...}
  val apiQuery = query + IN_QUALIFIER
  try {
    val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
    val repos = apiResponse.items
    val endOfPaginationReached = repos.isEmpty()
    repoDatabase.withTransaction {
        // clear all tables in the database
        if (loadType == LoadType.REFRESH) {
            repoDatabase.remoteKeysDao().clearRemoteKeys()
            repoDatabase.reposDao().clearRepos()
        }
        val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
        val nextKey = if (endOfPaginationReached) null else page + 1
        val keys = repos.map {
            RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
        }
        repoDatabase.remoteKeysDao().insertAll(keys)
        repoDatabase.reposDao().insertAll(repos)
      }
      return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
  } catch (){...}
}
  • page:加載的具體頁(yè),可以是全量即初始化時(shí),通過loadType來確定,而loadType則是根據(jù)用戶操作有關(guān)
  • apiQuery:網(wǎng)絡(luò)請(qǐng)求的參數(shù),搜索的關(guān)鍵字
  • 拿到服務(wù)端的結(jié)果后主要做了幾件事:更新本地的遠(yuǎn)程鍵,簡(jiǎn)單的理解就是記錄本次加載的“位置”或者“錨點(diǎn)”,這個(gè)位置對(duì)應(yīng)遠(yuǎn)程服務(wù)端數(shù)據(jù)的位置,并將數(shù)據(jù)存儲(chǔ)到本地(如果是全量刷新,本地?cái)?shù)據(jù)會(huì)先本清空)。
項(xiàng)目具體源碼與效果圖

效果圖

Github

局部刷新

PagingAdapter繼承自Recycler.Adapter,但是需要實(shí)現(xiàn)局部刷新可以通過 snapshot() 拿到一份只讀數(shù)據(jù)的拷貝如:

fun refreshByPotion(position: Int, newItem: CustomVO?) {
    if (position < 0 || position >= snapshot().size || null == newItem) {
            return
    }
    snapshot()[position]?.age = newItem.age
    snapshot()[position]?.name = newItem.name
    notifyItemChanged(position)
}
思考

PagingAdapter并沒有提供remove/add方法,這也是被一直詬病的點(diǎn),但是Paging真的是一個(gè)垃圾的庫(kù)嘛?其實(shí)不然,官方其實(shí)給出了它的使用場(chǎng)景,數(shù)據(jù)變動(dòng)不大,即服務(wù)端數(shù)據(jù)變動(dòng)頻率不高,獲取的數(shù)據(jù)以為展示為主,并沒有太多的交互。那么及時(shí)在離線的情況下系統(tǒng)依然是可以運(yùn)行良好的。普通的業(yè)務(wù)場(chǎng)景可能并合適使用Paging,那么我的理解,既然不合適就沒有必要硬要往上套用,選擇合適的庫(kù)或組件將復(fù)雜的業(yè)務(wù)簡(jiǎn)單化而不是將簡(jiǎn)單的場(chǎng)景復(fù)雜化。

實(shí)際項(xiàng)目中使用

在餐飲行業(yè)中,餐廳一般都會(huì)有點(diǎn)餐系統(tǒng),需要滿足什么需求呢,離線可用。菜品會(huì)變動(dòng),需要及時(shí)更新,但是更新頻率較低??偨Y(jié)如下:

  • 本地需要保存菜品信息(Room數(shù)據(jù)庫(kù)),保證離線時(shí)可用。
  • 首次登錄需要批量拉取服務(wù)端所有菜品信息保存到本地?cái)?shù)據(jù)庫(kù)。
  • 菜品有下架,上架,售罄等狀態(tài),這里的變更基于MQTT通知實(shí)現(xiàn),通過比對(duì)本地菜品的版本號(hào)碼作全量或者增量刷新。
  • 數(shù)據(jù)單一來源僅僅從本地?cái)?shù)據(jù)庫(kù)獲取。

可以發(fā)現(xiàn),這個(gè)場(chǎng)景下天然適合Paging庫(kù),而目前項(xiàng)目中還是使用的Paging2,并沒有遷移到Paging3原因是目前還有很多實(shí)驗(yàn)性的Api。

基于Paging2實(shí)現(xiàn):

  • 根據(jù)分類獲取菜品,涼菜、熱菜等
public LiveData<PagedList<DishSpuVO>> queryByCategoryId(String categoryId) {
   StoreDB storeDB = StoreDBManage.getInstance().getDataBase();
   if (storeDB == null) {
      return null;
   }
   return new LivePagedListBuilder<>(storeDB
          .DishSpuDAO()
          .queryByCategoryId(categoryId)
          .map(DishProductPO::transform),
          50).build();
}
  • 根據(jù)下發(fā)的通知同步本地菜品,并保存版本號(hào)(基于本地版本號(hào)與服務(wù)端版本號(hào)對(duì)比來決定是否更新數(shù)據(jù))
@NotifyType(type = NotifyType.DISH_CHANGE)
public class DishChangeHandler implements INotify {
  @Override
  public void process(String jsonData) {
    //根據(jù)版本號(hào)判斷是否更新本地?cái)?shù)據(jù)庫(kù),當(dāng)數(shù)據(jù)庫(kù)變動(dòng),會(huì)驅(qū)動(dòng)Paging刷新UI
    Flowable<Object> dishFlowable = SyncRepository.syncProduct(jsonData, 0L);
  }
}

當(dāng)然特定的場(chǎng)景使用Paging還是個(gè)不錯(cuò)的選擇,官方預(yù)計(jì)短期內(nèi)也不會(huì)考慮添加局部增刪操作,分頁(yè)庫(kù)數(shù)據(jù)本身就是一個(gè)數(shù)據(jù)快照,如果作類似這種增量的增刪操作,勢(shì)必只能使原先PagingSource快照失效,設(shè)置新的數(shù)據(jù)快照,這顯然浪費(fèi)系統(tǒng)資源,性能上也會(huì)打折扣。

文檔

Paging3 developer
CodeLab
Github

更多內(nèi)容戳這里(整理好的各種文集)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容