繼 上一篇 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? | |
) |
再來是 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>> | |
} |
最後是 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() | |
} | |
} |
原理介紹
上述的流程熟悉後,換套用 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 邏輯則不需要做任何改動。
您好,請問要怎麼做下拉更新?我呼叫了 DataSource.invalidate() 資料也不會變更。
嗨,Kevin,你的 DataSource 是 Remote only 還是 Remote + Local?? 如果是前者,應該呼叫 `DataSource.invalidate()` 就可以才對,如果是後者,則是要清除 Local DB 的資料後才會觸發 BoundaryCallback 去重新拉資料。
您好, 請問一下 如果call api failed的話, onBoundarycallback 就不會再次呼叫了,就算一直scrolling up/down, 請問該怎麼辦呢(network+db)
你自己有實作 retry 嗎?如果沒有的話,可以加入自動重試或者 UI 顯示重試按鈕,讓使用者可以自己重試。
You saved my day!!