整合 Android Paging Library: Part 2

上一篇 Part 1 講解 Paging 單純使用 Remote 當作資料來源後,我們這篇要來講解另一種常見的資料架構:Remote + Local,我們會先從 Remote 拉資料到 Local 端,然後 App 統一使用 Local 當作資料來源,讓我們來看看是如何實作這樣的資料架構。 

Remote + Local 資料架構

首先我們展示如何拉 Remote 資料到 Local 後,再讓 View 統一用 Local 資料顯示,這邊先不含 Paging 整合,只看一般資料如何從 Remote → Local → View 這樣的流程顯示出來,熟悉這流程後,再來看 Paging Library 是如何支援這樣的資料架構。 Remote 就是我們一般常用的 API,而 Local 我們選用 Room 當作資料庫,採用 MVVM + RxJava 這樣的 Reactive 架構,如果你不熟悉 Android Room 資料庫,可以參考我之前寫的 Room 入門介紹

我們沿用 Part 1. 的顯示貼文動態牆作為講解範例,我們會拉訊息回來存到資料庫,然後讓 View 訂閱資料庫的資料。

架構設計

我們會採用 MVVM 當作 App 架構,Room 本身也支援 Paging,所以我們會採用 Paging + RxJava + Room 來實作,架構和資料流如下圖:

藍線呈現資料流訂閱的狀態,View 訂閱 ViewModel,ViewModel 訂閱 Model,Model 觸發 Remote DataSource 去載入資料回來存到 Local DataSource,最後紅線呈現在資料流訂閱 onNext(Post) 方法被觸發得到新資料:

首先是我們的 Entity class,用來儲存 API 以及資料庫的資料類別:

@Entity
data class Post(
@PrimaryKey
@SerializedName("id")
val id: String,
@SerializedName("sender")
@ColumnInfo
val sender: String?,
@SerializedName("caption")
@ColumnInfo
val caption: String?,
@SerializedName("media")
@ColumnInfo
val media: String?,
@SerializedName("timestamp")
@ColumnInfo
val timestamp: Long?
)
view raw Post.kt hosted with ❤ by GitHub

再來是 ApiService 和 Dao:

interface PostApiService {
@GET("/feed")
fun getFeed(): Single<Response<List<Post>>>
}
@Dao
interface PostDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(post: Post): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(post: Post)
@Transaction
fun upsert(post: Post) {
if (-1L == insert(post))
update(post)
}
@Query("SELECT * FROM `post`")
fun getPostList(): Observable<List<Post>>
}
view raw PostApiServiceDao.kt hosted with ❤ by GitHub

最後是 Repository,裡面要實作從 Remote 拉資料回來後寫入 Local 的邏輯,這邊我們提供兩個方法,一個是 getFeeds(),這個方法回傳 Observeable 可以讓其他人訂閱資料流,另一個是 fetchFeeds() 這個會去拉 Remote 資料然後寫入 Local,這個方法是回傳 Completable,完成資料更新後就 complete,因為當資料寫到 Local的時候,getFeeds() 就會自動發出資料變更的通知。(Room 幫我們實作了發出資料變更的通知)

interface PostRepository {
fun fetchFeeds(): Completable
fun getFeeds(): Observable<List<Post>>
}
class PostRepositoryImpl : PostRepository {
private val remoteDataSource: PostApiService by inject()
private val localDataSource: PostDao by inject()
override fun fetchFeeds(): Completable {
return remoteDataSource.fetchFeeds()
.flatMapObservable {
if (it.isSuccessful)
Observable.fromIterable(it.body())
else
throw HttpException(it)
}.concatMapCompletable {
Completable.fromAction {
localDataSource.upsert(it)
}
}
}
override fun getFeeds(): Observable<List<Post>> {
return localDataSource.getPostList()
}
}
view raw PostRepo.kt hosted with ❤ by GitHub

原理介紹

上述的流程熟悉後,換套用 Paging 就會很快上手,我們會向資料庫要資料,當資料庫有資料的時候,可以直接回傳顯示,當所有資料都已經回傳、沒新的資料可以顯示的時候,就需要向 Remote 拉新分頁的資料回來,寫入資料庫,再讓資料庫回傳給介面顯示。 這就是 Paging 實作 Remote + Local 資料來源的原理。

那我們要如何實作這樣的流程呢?Paging有一個關鍵元件可以達成: PagedList.BoundaryCallback,這個抽象類別可以實作兩個方法:

  • onZeroItemsLoaded(): 當 Local 完全沒有任何資料時會觸發,這邊通常都是要實作向 Remote 拉第一個分頁資料寫回 Local 的邏輯。
  •  onItemAtEndLoaded():當 Local 的資料都回傳完了,已經沒有更多資料可以顯示的時候會觸發,這邊通常都是實作向 Remote 拉下一頁資料寫回 Local 的邏輯。

介紹完這元件後,讓我們來實作這 Callback 然後整合到我們的上面 Remote → Local → View 實作內。

實作

class PostBoundaryCallback : PagedList.BoundaryCallback<Post>(), KoinComponent {
private val remoteDataSource: PostApiService by inject()
private val localDataSource: PostDao by inject()
private val httpClient: OkHttpClient by inject()
private val gson: Gson by inject()
private var nextPageUrl: String? = null
override fun onZeroItemsLoaded() {
super.onZeroItemsLoaded()
val response = remoteDataSource.getFeed().execute()
if (response.isSuccessful) {
nextPageUrl = parseNextPageUrl(response.headers())
val postList: List<Post> = response.body()
upsertPostList(postList)
}
}
override fun onItemAtEndLoaded(itemAtEnd: MessageModel) {
super.onItemAtEndLoaded(itemAtEnd)
nextPageUrl?.run {
val response = httpClient.newCall(
Request.Builder()
.url(this)
.build()
).execute()
if (response.isSuccessful) {
nextPageUrl = parseNextPageUrl(response.headers())
val listType = object : TypeToken<List<Post>>() {}.type
val postList: List<Post> = gson.fromJson(response.body()?.string(), listType)
upsertPostList(postList)
}
}
}
private fun upsertPostList(postList: List<Post>) {
postList.forEach { post ->
localDataSource.upsert(post)
}
}
}

再來,要把這個 BoundaryCallback 整合到我們的現有程式內,DAO 和 Repository 都要做相對應的改變。

DAO 要把列表查詢的方法 getPostList() 回傳值改為 DataSource.Factory<String, Post>,Room 底層的實作就已經支援分頁,所以可以直接回傳 DataSource.Factory。

@Dao
interface PostDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(post: Post): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(post: Post)
@Transaction
fun upsert(post: Post) {
if (-1L == insert(post))
update(post)
}
@Query("SELECT * FROM `post`")
fun getPostList(): DataSource.Factory<Int, Post>
}

Repository 要把 DataSource 換成 Local 的資料來源 ( getPostList(): DataSource.Factory<Int, Post>,然後在 RxPagedListBuilder()裡面把 BoundaryCallback 加入。

interface PostRepository {
fun getFeeds(): Observable<PagedList<Post>>
}
class PostRepositoryImpl : PostRepository {
private val remoteDataSource: PostApiService by inject()
private val localDataSource: PostDao by inject()
private val postBoundaryCallback: postBoundaryCallback by inject()
override fun getFeeds(): Observable<PagedList<Post>> {
val dataSource = localDataSource.getPostList()
val pagedListConfig = PagedList.Config.Builder()
.setPageSize(10)
.setPrefetchDistance(4)
.build()
return RxPagedListBuilder(dataSource, pagedListConfig)
.setBoundaryCallback(postBoundaryCallback)
.buildObservable()
}
}

其他 ViewModel 和 View 的部分皆不需要更動,可以直接使用 Remote + Local 的 Paging 資料來源。我們的方法觸發順序和資料流的流向如下:

總結

當我們實作 Paging 資料架構從 Remote 轉換為 Remote + Local 的時候,我們只需要實作 BoundaryCallback、新增 Room DAO 回傳 DataSource.Factory 的方法作為 Local DataSource 的主要資料來源即可,而如果你是採用 MVVM(推薦採用這架構),則 View 和 ViewModel 原本實作的 Paging 邏輯則不需要做任何改動。


3 thoughts on “整合 Android Paging Library: Part 2

Add yours

    1. 嗨,Kevin,你的 DataSource 是 Remote only 還是 Remote + Local?? 如果是前者,應該呼叫 `DataSource.invalidate()` 就可以才對,如果是後者,則是要清除 Local DB 的資料後才會觸發 BoundaryCallback 去重新拉資料。

      Like

  1. 您好, 請問一下 如果call api failed的話, onBoundarycallback 就不會再次呼叫了,就算一直scrolling up/down, 請問該怎麼辦呢(network+db)

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑