本文是Android Jetpack Paging
系列的第二篇文章;強烈建議 讀者將本系列作為學習Paging
閱讀優先順序最高的文章,如果讀者對Paging
還沒有系統性的認識,請參考:
前言
Paging
是一個非常優秀的分頁元件,與其它熱門的分頁相關庫不同的是,Paging
更偏向注重服務於 業務 而非 UI 。——我們都知道業務型別的開源庫的質量非常依賴程式碼 整體的架構設計(比如Retofit
和OkHttp
);那麼,如何說服自己或者同事去嘗試使用Paging
?顯然原始碼中蘊含的優秀思想更具有說服力。
反過來說,若從Google
工程師們設計、研發和維護的原始碼中有所借鑑,即使不在專案中真正使用它,自己依然能受益匪淺。
本文章節如下:
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/5698e429c7046d2af0b841a9902e3fe40d945c0f18b3c06d8439e3352ae55b02.png)
架構設計與原理解析
1、通過建造者模式進行依賴注入
建立流程毫無疑問是架構設計中最重要的環節。
作為元件的門板,向外暴露的API
對於開發者越簡單友善方便呼叫越好,同時,作為API
呼叫者的我們也希望框架越靈活,可配置選項越多越好。
這聽起來似乎有點違反常理—— 如何才能保證既保證 簡單幹淨的介面設計 易於開發者上手,同時又有 足夠多的可配置項 保證框架的靈活呢?
Paging
的API
設計中使用了經典的 建造者(Builder)模式,並通過依賴注入將依賴一層層向下傳遞,最終依次構建了各個層級的物件例項。
對於開發者而言,只需要配置自己關心的引數,而不關心(甚至可以是不知道)的引數配置,全交給Builder
類使用預設引數:
// 你可以這樣複雜地配置
val pagedListLiveData =
LivePagedListBuilder(
dataSourceFactory,
PagedList.Config.Builder()
.setPageSize(PAGE_SIZE) // 分頁載入的數量
.setInitialLoadSizeHint(20) // 初始化載入的數量
.setPrefetchDistance(10) // 預載入距離
.setEnablePlaceholders(ENABLE_PLACEHOLDERS) // 是否啟用佔位符
.build()
).build()
// 也可以這樣簡單地配置
val pagedListLiveData =
LivePagedListBuilder(dataSourceFactory, PAGE_SIZE).build()
複製程式碼
需要注意的是,分頁相關功能配置物件的構建 和 可觀察者物件的構建 是否是兩個不同的職責?顯然是有必要的,因為:
LiveData<PagedList>
=DataSource
+PagedList.Config
(即 分頁資料的可觀察者 = 資料來源 + 分頁配置)
因此,這裡Paging
的配置使用到了2個Builder
類,即使是決定使用 建造者模式 ,設計者也需要對Builder
類的定義有一個清晰的認知,這裡也是設計過程中 單一職責原則 的優秀體現。
最終,Builder
中的所有配置都通過依賴注入的方式對PagedList
進行了例項化:
// PagedList.Builder.build()
public PagedList<Value> build() {
return PagedList.create(
mDataSource,
mNotifyExecutor,
mFetchExecutor,
mBoundaryCallback,
mConfig,
mInitialKey);
}
// PagedList.create()
static <K, T> PagedList<T> create(@NonNull DataSource<K, T> dataSource,
@NonNull Executor notifyExecutor,
@NonNull Executor fetchExecutor,
@Nullable BoundaryCallback<T> boundaryCallback,
@NonNull Config config,
@Nullable K key) {
// 這裡我們僅以ContiguousPagedList為例
// 可以看到,所有PagedList都是將建構函式的依賴注入進行的例項化
return new ContiguousPagedList<>(contigDataSource,
notifyExecutor,
fetchExecutor,
boundaryCallback,
config,
key,
lastLoad);
}
複製程式碼
依賴注入 是一個非常簡單而又樸實的編碼技巧,Paging
的設計中,幾乎沒有用到單例模式,也幾乎沒有太多的靜態成員——所有物件中除了自身的狀態,其它所有通過依賴注入的配置項都是 final (不可變)的:
// PagedList.java
public abstract class PagedList<T> {
final Executor mMainThreadExecutor;
final Executor mBackgroundThreadExecutor;
final BoundaryCallback<T> mBoundaryCallback;
final Config mConfig;
final PagedStorage<T> mStorage;
}
// ItemKeyedDataSource.LoadInitialParams.java
public static class LoadInitialParams<Key> {
public final Key requestedInitialKey;
public final int requestedLoadSize;
public final boolean placeholdersEnabled;
}
複製程式碼
上文說到 幾乎沒有用到單例模式,實際上執行緒切換的設計有些許例外,但其本身依然可以通過
Builder
進行依賴注入以覆蓋預設的執行緒獲取邏輯。
通過 依賴注入 保證了物件的例項所需依賴有跡可循,類與類之間的依賴關係非常清晰,而例項化的物件內部 成員的不可變 也極大保證了PagedList
分頁資料的執行緒安全。
2、構建懶載入的LiveData
對於被觀察者而言,只有當真正被訂閱的時候,其資料的更新才有意義。換句話說,當開發者構建出一個LiveData<PagedList>
時候,這時立即通過後臺執行緒開始非同步請求分頁資料是沒有意義的。
反過來理解,若沒有訂閱就請求資料,當真正訂閱的時候,
DataSource
中的資料已經過時了,這時還需要重新請求拉取最新資料,這樣之前的一系列行為就沒有意義了。
真正的請求應該放在LiveData.observe()
的時候,即被訂閱時才去執行,筆者這裡更偏向於稱其為“懶載入”——如果讀者對RxJava
比較熟悉的話,會發現這和Observable.defer()
操作符概念比較相似:
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/8159721b93420a6c02d391fc0c06d499023dee7d9659636a5ae09e788cb3c4f6.png)
那麼,如何構建“懶載入”的LiveData<PagedList>
呢?Google
的設計者使用了ComputableLiveData
類對LiveData
的資料發射行為進行了包裝:
// @hide
public abstract class ComputableLiveData<T> {}
複製程式碼
這是一個隱藏的類,開發者一般不能直接使用它,但它被應用的地方可不少,Room
元件生成的原始碼中也經常可以看到它的身影。
用一句話描述ComputableLiveData
的定義,筆者覺得 LiveData的資料來源 比較適合,感興趣的讀者可以仔細研究一下它的原始碼,筆者有機會會為它單獨開一篇文章,這裡不繼續展開。
總之,通過ComputableLiveData
類,Paging
實現了訂閱時才執行非同步任務的功能,更大程度上減少了做無用功的情況。
3、為分頁資料賦予生命週期
分頁資料PagedList
理應也有屬於自己的生命週期。
正常的生命週期內,PagedList
不斷從DataSource
中嘗試載入分頁資料,並展示出來;但資料來源中的資料總有過期失效的時候,這意味著PagedList
生命週期走到了盡頭。
Paging
需要響應式地建立一個新的DataSource
資料快照以及新的PagedList
,然後交給PagedListAdapter
更新在UI上。
為此,PagedList
類中增加了對應的一個mDetached
欄位:
public abstract class PagedList<T> extends AbstractList<T> {
//...
private final AtomicBoolean mDetached = new AtomicBoolean(false);
public boolean isDetached() {
return mDetached.get();
}
public void detach() {
mDetached.set(true);
}
}
複製程式碼
這個AtomicBoolean
型別的欄位是有意義的:我們知道PagedList
對分頁資料的載入是非同步的,因此嘗試載入下一頁資料時,若此時mDetached.get()
為true
,意味著此時的分頁資料已經失效,因此非同步的分頁請求任務不再需要被執行:
class ContiguousPagedList<K, V> extends PagedList<V> {
//...
public void onPagePlaceholderInserted(final int pageIndex) {
mBackgroundThreadExecutor.execute(new Runnable() {
@Override
public void run() {
// 不再非同步載入分頁資料
if (isDetached()) {
return;
}
// 若資料來源失效,則將mDetached.set(true)
if (mDataSource.isInvalid()) {
detach();
} else {
// ... 載入下頁資料
}
}
});
}
}
複製程式碼
通過上述程式碼片段讀者也可以看到,PagedList
的生命週期是否失效,則依賴DataSource
的isInvalid()
函式,這個函式表示當前的DataSource
資料來源是否失效:
public abstract class DataSource<Key, Value> {
private AtomicBoolean mInvalid = new AtomicBoolean(false);
private CopyOnWriteArrayList<InvalidatedCallback> mOnInvalidatedCallbacks =
new CopyOnWriteArrayList<>();
// 通知資料來源失效
public void invalidate() {
if (mInvalid.compareAndSet(false, true)) {
for (InvalidatedCallback callback : mOnInvalidatedCallbacks) {
// 資料來源失效的回撥函式,通知上層建立新的PagedList
callback.onInvalidated();
}
}
}
// 資料來源是否失效
public boolean isInvalid() {
return mInvalid.get();
}
}
複製程式碼
當資料來源DataSource
失效時,則會通過回撥函式,通知上文我們提到的ComputableLiveData<T>
建立新的PagedList
,並通知給LiveData
的觀察者更新在UI
上。
因此,PagedList
作為分頁資料,DataSource
作為資料來源,ComputableLiveData<T>
作為PagedList
的建立和分發者三者形成了一個閉環:
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/5c814cce4d860b0e242143198a81fd7e5bcedfcb7539f992e804506d9c24132e.png)
4、提供Room
的響應式支援
我們知道Paging
原生提供了對Room
元件的響應式支援,當資料庫資料發生了更新,Paging
能夠響應到並自動構建新的PagedList
,然後更新到UI
上。
這似乎是一個神奇的操作,但原理卻十分簡單,上一小節我們知道,DataSource
呼叫了invalidate()
函式時,意味著資料來源失效,DataSource
會通過回撥函式重新構建新的PagedList
。
Room
元件也是根據這個特性額外封裝了一個新的DataSource
:
public abstract class LimitOffsetDataSource<T> extends PositionalDataSource<T> {
protected LimitOffsetDataSource(...) {
// 1.定義一個"命令資料來源失效"的回撥函式
mObserver = new InvalidationTracker.Observer(tables) {
@Override
public void onInvalidated(@NonNull Set<String> tables) {
invalidate();
}
};
// 2.為資料庫的失效跟蹤器(InvalidationTracker)配置觀察者
db.getInvalidationTracker().addWeakObserver(mObserver);
}
}
複製程式碼
這之後,每當資料庫中資料失效,都會自動執行DataSource.invalidate()
函式。
現在讀者回顧最初學習Paging
的時候,Room
中開發者定義的Dao
類,返回的DataSource.Factory
到底是怎樣的一個物件?
@Dao
interface RedditPostDao {
@Query("SELECT * FROM posts WHERE subreddit = :subreddit ORDER BY indexInResponse ASC")
fun postsBySubreddit(subreddit : String) : DataSource.Factory<Int, RedditPost>
}
複製程式碼
答案不言而喻,正是LimitOffsetDataSource
的工廠類:
@Override
public DataSource.Factory<Integer, RedditPost> postsBySubreddit(final String subreddit) {
return new DataSource.Factory<Integer, RedditPost>() {
// 返回能夠響應資料庫資料失效的 LimitOffsetDataSource
@Override
public LimitOffsetDataSource<RedditPost> create() {
return new LimitOffsetDataSource<RedditPost>(__db, _statement, false , "posts") {
// ....
}
}
複製程式碼
原理上講,這些程式碼平淡無奇,但設計者通過註解的一層封裝,大幅簡化了開發者的程式碼量。對於開發者而言,只需要配置一個介面,而無需去了解內部的程式碼實現細節。
中場:更多的困惑
上一篇文章中對DataSource
進行了簡單的介紹,很多朋友反應DataSource
這一部分的原始碼過於晦澀,對於DataSource
的選擇也是懵懵懂懂。
複雜問題的解決依賴於問題的切割細分,本文將其細分成以下2個小問題,並進行一一探討:
- 1、為什麼設計出這麼多的
DataSource
和其子類,它們的使用場景各是什麼? - 2、為什麼設計出這麼多的
PagedList
和其子類?
5、資料來源的連續性與分頁載入策略
為什麼設計出這麼多的
DataSource
和其子類,它們的使用場景各是什麼?
Paging
分頁元件的設計中,DataSource
是一個非常重要的模組。顧名思義,DataSource<Key, Value>
中的Key
對應資料載入的條件,Value
對應資料集的實際型別, 針對不同場景,Paging
的設計者提供了幾種不同型別的DataSource
實現類:
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/707c1842df46e4861bfb52593c3ee5ce27f41440b9d43188a13711c82f3b0007.png)
關於這些DataSource
的介紹,請參考上一篇文章的這一小節,本文不再贅述。
第一次閱讀這一部分原始碼時,筆者最困惑的是,ContiguousDataSource
和PositionalDataSource
的區別到底是什麼呢?
翻閱過原始碼的讀者也許曾經注意到,DataSource
有這樣一個抽象函式:
public abstract class DataSource<Key, Value> {
// 資料來源是否是連續的
abstract boolean isContiguous();
}
class ContiguousDataSource<Key, Value> extends DataSource<Key, Value> {
// ContiguousDataSource 是連續的
boolean isContiguous() { return true; }
}
class PositionalDataSource<T> extends DataSource<Integer, T> {
// PositionalDataSource 是非連續的
boolean isContiguous() { return false; }
}
複製程式碼
那麼,資料來源的連續性 到底是什麼概念?
對於一般的網路分頁載入請求而言,下一頁的資料總是需要依賴上一頁的載入,這種時候,我們通常稱之為 資料來源是連續的 —— 這似乎毫無疑問,這也是ItemKeyedDataSource
和PageKeyedDataSource
被廣泛使用的原因。
但有趣的是,在 以本地快取作為分頁資料來源 的業務模型下,這種 分頁資料來源應該是連續的 常識性的認知被打破了。
每個手機都有通訊錄,因此本文以通訊錄APP
為例,對於通訊錄而言,所有資料取自於本地持久層,而考慮到手機內也許會有成千上萬的通訊錄資料,APP
本身列表資料也應該進行分頁載入。
這種情況下,分頁資料來源是連續的嗎?
讀者仔細思考可以得知,這時分頁資料來源 一定不能是連續的 。誠然,對於滑動操作而言,資料的連續分頁請求沒有問題,但是當使用者從通訊錄頁面的側邊點選Z
字母,嘗試快速跳轉Z
開頭的使用者時,分頁資料請求的連續性被打破了:
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/cbcc9b7e993d29de7c92934570caecb0849ee4d5725428e7a7031bbbaf89db9c.png)
這便是PositionalDataSource
的使用場景:通過特定的位置載入資料,這裡Key
是Integer
型別的位置資訊,每一條分頁資料並不依賴上一條分頁資料,而是依賴資料所處資料來源本身的位置(Position
)。
分頁資料的連續性 是一個十分重要的概念,理解了這個概念,讀者也就能理解DataSource
各個子類的意義了:
無論是PositionalDataSource
、ItemKeyedDataSource
還是PageKeyedDataSource
,這些類都是不同的 分頁載入策略。開發者只需要根據不同業務的場景(比如 資料的連續性),選擇不同的 分頁載入策略 即可。
6、分頁資料模型與分頁資料副本
為什麼設計出這麼多的
PagedList
和其子類?
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/c688f3b60ccc8d0e022a7cb047f35d6e93663d813b877c2be7a0b5315485269a.png)
和DataSource
相似,PagedList
同樣擁有一個isContiguous()
介面:
public abstract class PagedList<T> extends AbstractList<T> {
abstract boolean isContiguous();
}
class ContiguousPagedList<K, V> extends PagedList<V> {
// ContiguousPagedList 內部持有 ContiguousDataSource
final ContiguousDataSource<K, V> mDataSource;
boolean isContiguous() { return true; }
}
class TiledPagedList<T> extends PagedList<T> {
// TiledPagedList 內部持有 PositionalDataSource
final PositionalDataSource<T> mDataSource;
boolean isContiguous() { return false; }
}
複製程式碼
讀者應該理解,PagedList
內部持有一個DataSource
,而 分頁資料載入 的行為本質上是從DataSource
中非同步獲取資料—— 在分頁資料請求的過程中,不同的DataSource
也會有不同的引數需求,從而導致PagedList
內部的行為也不盡相同;因此PagedList
向下匯出了ContiguousPagedList
和TiledPagedList
類,用於不同業務情況的分頁請求處理。
那麼SnapshotPagedList
又是一個什麼類呢?
PagedList
額外有一個snapshot()
介面,以返回當前分頁資料的快照:
public abstract class PagedList<T> extends AbstractList<T> {
public List<T> snapshot() {
return new SnapshotPagedList<>(this);
}
}
複製程式碼
這個snapshot()
函式非常重要,其用於儲存分頁資料的前一個狀態,並且用於AsyncPagedListDiffer
進行資料集的差異性計算,新的PagedList
到來時(通過PagedListAdapter.submitList()
),並未直接進行資料的覆蓋和差異性計算,而是先對之前PagedList
中的資料集進行拷貝。
篇幅原因不詳細展示,有興趣的讀者可以自行閱讀
PagedListAdapter.submitList()
相關原始碼。
接下來簡單瞭解下SnapshotPagedList
內部的實現:
class SnapshotPagedList<T> extends PagedList<T> {
SnapshotPagedList(@NonNull PagedList<T> pagedList) {
// 1.這裡我們看到,其它物件都沒有改變堆內地址的引用
// 除了 pagedList.mStorage.snapshot(),最終執行 -> 2
super(pagedList.mStorage.snapshot(),
pagedList.mMainThreadExecutor,
pagedList.mBackgroundThreadExecutor,
null,
pagedList.mConfig);
mDataSource = pagedList.getDataSource();
mContiguous = pagedList.isContiguous();
mLastLoad = pagedList.mLastLoad;
mLastKey = pagedList.getLastKey();
}
}
final class PagedStorage<T> extends AbstractList<T> {
PagedStorage(PagedStorage<T> other) {
// 2.對當前分頁資料進行了一次拷貝
mPages = new ArrayList<>(other.mPages);
}
}
複製程式碼
此外,mSnapshot
還用於狀態的儲存,當差異性計算未執行完畢時,若此時開發者呼叫getCurrentList()
函式,則會嘗試將mSnapshot
——即之前資料集的副本進行返回,有興趣的讀者可以研究一下。
7、執行緒切換與Paging設計中的"Bug"
Google
的工程師們設計Paging
的初衷就希望能夠讓開發者 無感知地進行執行緒切換 ,因此大部分執行緒切換的程式碼都封裝在內部:
public class ArchTaskExecutor extends TaskExecutor {
// 主執行緒的Executor
private static final Executor sMainThreadExecutor = new Executor() {
@Override
public void execute(Runnable command) {
getInstance().postToMainThread(command);
}
};
// IO執行緒的Executor
private static final Executor sIOThreadExecutor = new Executor() {
@Override
public void execute(Runnable command) {
getInstance().executeOnDiskIO(command);
}
};
}
複製程式碼
有興趣的讀者可以研究ArchTaskExecutor
內部的原始碼,其內部sMainThreadExecutor
原理依然是通過Looper.getMainLooper()
建立對應的Handler
並向主執行緒傳送訊息,本文不贅述。
原始碼的設計者希望,使用Paging
的開發者能夠在執行資料的分頁載入任務時,內部切換到IO
執行緒,而分頁資料載入成功後,則內部切換回到主執行緒更新UI。
從設計上講,這是一個非常優秀的設計,但是開發者真正使用時,卻很難注意到DataSource
中對資料載入的回撥方法,本身就是執行在IO
執行緒的:
public abstract class PositionalDataSource<T> extends DataSource<Integer, T>{
// 通過註解提醒開發者回撥在子執行緒
@WorkerThread
public abstract void loadInitial(...);
@WorkerThread
public abstract void loadRange(...);
}
複製程式碼
回撥本身在子執行緒執行,意味著,開發者對分頁資料的載入最好不要使用非同步方法,否則很可能出問題。
對於OkHttp
的使用者而言,開發者應該使用execute()
同步方法:
override fun loadInitial(..., callback: LoadInitialCallback<RedditPost>) {
// 使用同步方法
val response = request.execute()
callback.onResult(...)
}
複製程式碼
對於
RxJava
而言,則應該使用blocking
相關的方法進行阻塞操作。
如果說PositionalDataSource
還有@WorkerThread
提醒,那麼另外的ItemKeyedDataSource
和PageKeyedDataSource
乾脆就沒有@WorkerThread
註解:
public abstract class ItemKeyedDataSource<Key, Value> extends ContiguousDataSource<Key, Value> {
public abstract void loadInitial(...);
public abstract void loadAfter(...);
}
// PageKeyedDataSource也沒有`WorkerThread`註解,不贅述
複製程式碼
因此如果沒有注意到這些細節,開發者很可能誤入歧途,從而導致未知的一些問題,對此,開發者可以嘗試參考Google
這個示例程式碼。
奇怪的是,即使是Google
官方的程式碼示例中,對於loadInitial
和loadAfter
兩個函式,也只有loadInitial
中使用了同步方法進行請求,而loadAfter
中依然是使用enqueue()
進行非同步請求。儘管註釋中明確宣告瞭這點,但筆者還是無法理解這種行為,因為這的確有可能令一些開發者誤入歧途。
![反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析](https://i.iter01.com/images/419bd65e550baa69bd1680962f0b4a5597f90dbc6554cf86db95ecc408b78973.png)
總之,Paging
的設計中,其初衷將執行緒切換的實現細節進行隱藏是好的,但是結果的確沒有達到很好的效果,相反還有可能導致錯誤的理解和使用(筆者踩坑了)。
也許執行緒切換不交給內部的預設引數去實現(尤其是不要交給Builder模式去配置,這太容易被忽視了),而是強制要求交給開發者去指定更好?
歡迎有想法的朋友在本文下方留言,思想的交流會更容易讓人進步。
總結
本文對Paging
的原理實現進行了系統性的講解,那麼,Paging
的架構設計上,到底有哪些優點值得我們學習?
首先,依賴注入。Paging
內部所有物件的依賴,包括配置引數、內部回撥、執行緒切換,絕大多數都是通過依賴注入進行的,簡單 且 樸實 ,類與類之間的依賴關係皆有跡可循。
其次,類的抽象和將不同業務的下沉,DataSource
和PagedList
分工明確,並向上抽象為一個抽象類,並將不同業務情況下的分頁邏輯下沉到各自的子類中去。
最後,明確物件的邊界:設計分頁資料的生命週期,當資料來源無效時,避免執行無效的非同步分頁任務;使用 懶載入的LiveData ,保證未訂閱時不執行分頁邏輯。
參考 & 更多
如果對Paging
感興趣,歡迎閱讀筆者更多相關的文章,並與我一起討論:
關於我
Hello,我是 卻把清梅嗅 ,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的 部落格 或者 Github。
如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?