android实现箭头流程列表_反思|Android 列表分页组件Paging的设计与实现:系统概述...
作者:卻把清梅嗅
鏈接:https://github.com/qingmei2/blogs/issues/30
前言
本文將對Paging分頁組件的設(shè)計和實現(xiàn)進行一個系統(tǒng)整體的概述,強烈建議 讀者將本文作為學(xué)習Paging 閱讀優(yōu)先級最高的文章,所有其它的Paging中文博客閱讀優(yōu)先級都應(yīng)該靠后。
本文篇幅 較長,整體結(jié)構(gòu)思維導(dǎo)圖如下:
一、起源
手機應(yīng)用中,列表是常見的界面構(gòu)成元素,而對于Android開發(fā)者而言,RecyclerView是實現(xiàn)列表的不二選擇。
在正式討論Paging和列表分頁功能之前,我們首先看看對于一個普通的列表,開發(fā)者如何通過代碼對其進行建模:
如圖所示,針對這樣一個簡單 聯(lián)系人界面 的建模,我們引出3個重要的層級:
1.服務(wù)端組件、數(shù)據(jù)庫、內(nèi)存
為什么說?服務(wù)端組件、數(shù)據(jù)庫?以及 內(nèi)存 是非常重要的三個層級呢?
首先,開發(fā)者為當前頁面創(chuàng)建了一個ViewModel,并通過成員變量在 內(nèi)存 中持有了一組聯(lián)系人數(shù)據(jù),因為ViewModel組件的原因,即使頁面配置發(fā)生了改變(比如屏幕的旋轉(zhuǎn)),數(shù)據(jù)依然會被保留下來。
而?數(shù)據(jù)庫?的作用則保證了App即使在離線環(huán)境下,用戶依然可以看到一定的內(nèi)容——顯然對于上圖中的頁面(聯(lián)系人列表)而言,本地緩存是非常有意義的。
對于絕大多數(shù)列表而言,服務(wù)端?往往意味著是數(shù)據(jù)源,每當用戶執(zhí)行刷新操作,App都應(yīng)當嘗試向服務(wù)端請求最新的數(shù)據(jù),并將最新的數(shù)據(jù)存入?數(shù)據(jù)庫,并隨之展示在UI上。
通常情況下,這三個層級并非同時都是必要的,讀者需正確理解三者各自不同的使用場景。
現(xiàn)在,借助于?服務(wù)端組件、數(shù)據(jù)庫?以及 內(nèi)存,開發(fā)者將數(shù)據(jù)展示在RecyclerView上,這似乎已經(jīng)是正解了。
2.問題在哪?
到目前為止,問題還沒有完全暴露出來。
我們忽視了一個非常現(xiàn)實的問題,那就是?數(shù)據(jù)是動態(tài)的?——這意味著,每當數(shù)據(jù)發(fā)生了更新(比如用戶進行了下拉刷新操作),開發(fā)者都需要將最新的數(shù)據(jù)響應(yīng)在UI上。
這意味著,當某個用戶的聯(lián)系人列表中有10000個條目時,每次數(shù)據(jù)的更新,都會對所有的數(shù)據(jù)進行重建——從而導(dǎo)致?性能非常低下,用戶看到的只是屏幕中的幾條聯(lián)系人信息,為此要重新創(chuàng)建10000個條目?用戶顯然無法接受。
因此,分頁組件的設(shè)計勢在必行。
3.整理需求
3.1、簡單易用
上文我們談到,UI響應(yīng)數(shù)據(jù)的變更,這種情況下,使用?觀察者模式?是一個不錯的主意,比如LiveData、RxJava甚至自定義一個接口等等,開發(fā)者僅需要觀察每次數(shù)據(jù)庫中數(shù)據(jù)的變更,并進行UI的更新:
class MyViewModel : ViewModel() {val users: LiveData>>
}
新的組件我們也希望能擁有同樣的便利,比如使用LiveData或者RxJava,并進行訂閱處理數(shù)據(jù)的更新——?簡單?且?易用。
3.2、處理更多層級
我們希望新的組件能夠處理多層,我們希望列表展示?服務(wù)器?返回的數(shù)據(jù)、 或者?數(shù)據(jù)庫?中的數(shù)據(jù),并將其放入UI中。
3.3、性能
新的組件必須保證足夠的快,不做任何沒必要的行為,為了保證效率,繁重的操作不要直接放在UI線程中處理。
3.4、感知生命周期 如果可能,新的組件需要能夠?qū)ι芷谶M行感知,就像LiveData一樣,如果頁面并不在屏幕的可視范圍內(nèi),組件不應(yīng)該工作。
3.5、足夠靈活
足夠的靈活性非常重要——每個項目都有不同的業(yè)務(wù),這意味著不同的API、不同的數(shù)據(jù)結(jié)構(gòu),新的組件必須保證能夠應(yīng)對所有的業(yè)務(wù)場景。
這一點并非必須,但是對于設(shè)計者來說難度不小,這意味著需要將不同的業(yè)務(wù)中的共同點抽象出來,并保證這些設(shè)計適用在任何場景中。
定義好了需求,在正式開始設(shè)計Paging之前,首先我們先來回顧一下,普通的列表如何實現(xiàn)數(shù)據(jù)的動態(tài)更新的。
4.普通列表的實現(xiàn)方式
我們依然通過?聯(lián)系人列表?作為示例,來描述普通列表如何響應(yīng)數(shù)據(jù)的動態(tài)更新。
首先,我們需要定義一個Dao,這里我們使用了Room組件用于 數(shù)據(jù)庫 中聯(lián)系人的查詢:
@Daointerface UserDao {
@Query("SELECT * FROM user")
fun queryUsers(): LiveData>
}
這里我們返回的是一個LiveData,正如我們前文所言,構(gòu)建一個可觀察的對象顯然會讓數(shù)據(jù)的處理更加容易。
接下來我們定義好ViewModel和Activity:
class MyViewModel(val dao: UserDao) : ViewModel() {// 1.定義好可觀察的LiveData
val users: LiveData> = dao.queryUsers()
}class MyActivity : Activity {val myViewModel: MyViewModelval adapter: ListAdapterfun onCreate(bundle: Bundle?) {// 2.在Activity中對LiveData進行訂閱
myViewModel.users.observe(this) {// 3.每當數(shù)據(jù)更新,計算新舊數(shù)據(jù)集的差異,對列表進行更新
adapter.submitList(it)
}
}
}
這里我們使用到了ListAdapter,它是官方基于RecyclerView.Adapter的AsyncListDiffer封裝類,其內(nèi)創(chuàng)建了AsyncListDiffer的示例,以便在后臺線程中使用DiffUtil計算新舊數(shù)據(jù)集的差異,從而節(jié)省Item更新的性能。
本文默認讀者對ListAdapter一定了解,如果不是很熟悉,請參考DiffUtil、AsyncListDiffer、ListAdapter等相關(guān)知識點的文章。
此外,我們還需要在ListAdapter中聲明DiffUtil.ItemCallback,對數(shù)據(jù)集的差異計算的邏輯進行補充:
class MyAdapter(): ListAdapter<User, UserViewHolder>(object: DiffUtil.ItemCallback<User>() {override fun areItemsTheSame(oldItem: User, newItem: User)= oldItem.id == newItem.idoverride fun areContentsTheSame(oldItem: User, newItem: User)= oldItem == newItem
}
) {
// ...
}
That's all, 接下來我們開始思考,新的分頁組件應(yīng)該是什么樣的。
二、分頁組件簡介
1.核心類:PagedList
上文提到,一個普通的RecyclerView展示的是一個列表的數(shù)據(jù),比如List,但在列表分頁的需求中,List明顯就不太夠用了。
為此,Google設(shè)計出了一個新的角色PagedList,顧名思義,該角色的意義就是?分頁列表數(shù)據(jù)的容器?。
既然有了List,為什么需要額外設(shè)計這樣一個PagedList的數(shù)據(jù)結(jié)構(gòu)?本質(zhì)原因在于加載分頁數(shù)據(jù)的操作是異步的 ,因此定義PagedList的第二個作用是 對分頁數(shù)據(jù)的異步加載 ,這個我們后文再提。
現(xiàn)在,我們的ViewModel現(xiàn)在可以定義成這樣,因為PagedList也作為列表數(shù)據(jù)的容器(就像List一樣):
class MyViewModel : ViewModel() {// before
// val users: LiveData> = dao.queryUsers()
// after
val users: LiveData> = dao.queryUsers()
}
在ViewModel中,開發(fā)者可以輕易通過對users進行訂閱以響應(yīng)分頁數(shù)據(jù)的更新,這個LiveData的可觀察者是通過Room組件創(chuàng)建的,我們來看一下我們的dao:
@Daointerface UserDao {
// 注意,這里 LiveData> 改成了 LiveData>
@Query("SELECT * FROM user")
fun queryUsers(): LiveData>
}
乍得一看似乎理所當然,但實際需求中有一個問題,這里的定義是模糊不清的——對于分頁數(shù)據(jù)而言,不同的業(yè)務(wù)場景,所需要的相關(guān)配置是不同的。那么什么是分頁相關(guān)配置呢?
最直接的一點是每頁數(shù)據(jù)的加載數(shù)量PageSize,不同的項目都會自行規(guī)定每頁數(shù)據(jù)量的大小,一頁請求15個數(shù)據(jù)還是20個數(shù)據(jù)?顯然我們目前的代碼無法進行配置,這是不合理的。
2.數(shù)據(jù)源: DataSource及其工廠
回答這個問題之前,我們還需要定義一個角色,用來為PagedList容器提供分頁數(shù)據(jù),那就是數(shù)據(jù)源DataSource。
什么是DataSource呢?它不應(yīng)該是?數(shù)據(jù)庫數(shù)據(jù)?或者?服務(wù)端數(shù)據(jù), 而應(yīng)該是?數(shù)據(jù)庫數(shù)據(jù)?或者?服務(wù)端數(shù)據(jù)?的一個快照(Snapshot)。
每當Paging被告知需要更多數(shù)據(jù):“Hi,我需要第45-60個的數(shù)據(jù)!”——數(shù)據(jù)源DataSource就會將當前Snapshot對應(yīng)索引的數(shù)據(jù)交給PagedList。
但是我們需要構(gòu)建一個新的PagedList的時候——比如數(shù)據(jù)已經(jīng)失效,DataSource中舊的數(shù)據(jù)沒有意義了,因此DataSource也需要被重置。
在代碼中,這意味著新的DataSource對象被創(chuàng)建,因此,我們需要提供的不是DataSource,而是提供DataSource的工廠。
為什么要提供DataSource.Factory而不是一個DataSource? 復(fù)用這個DataSource不可以嗎,當然可以,但是將DataSource設(shè)置為immutable(不可變)會避免更多的未知因素。
重新整理思路,我們?nèi)绾味xDao中接口的返回值呢?
@Daointerface UserDao {
// Int 代表按照數(shù)據(jù)的位置(position)獲取數(shù)據(jù)
// User 代表數(shù)據(jù)的類型
@Query("SELECT * FROM user")
fun queryUsers(): DataSource.Factory<Int, User>
}
返回的是一個數(shù)據(jù)源的提供者DataSource.Factory,頁面初始化時,會通過工廠方法創(chuàng)建一個新的DataSource,這之后對應(yīng)會創(chuàng)建一個新的PagedList,每當PagedList想要獲取下一頁的數(shù)據(jù),數(shù)據(jù)源都會根據(jù)請求索引進行數(shù)據(jù)的提供。
當數(shù)據(jù)失效時,DataSource.Factory會再次創(chuàng)建一個新的DataSource,其內(nèi)部包含了最新的數(shù)據(jù)快照(本案例中代表著數(shù)據(jù)庫中的最新數(shù)據(jù)),隨后創(chuàng)建一個新的PagedList,并從DataSource中取最新的數(shù)據(jù)進行展示——當然,這之后的分頁流程都是相同的,無需再次復(fù)述。
筆者繪制了一幅圖用于描述三者之間的關(guān)系,讀者可參考上述文字和圖片加以理解:
3.串聯(lián)兩者:PagedListBuilder
回歸第一小節(jié)的那個問題,分頁相關(guān)業(yè)務(wù)如何進行配置?我們雖然介紹了為PagedList提供數(shù)據(jù)的DataSource,但這個問題似乎還是沒有得到解決。
此外,現(xiàn)在Dao中接口的返回值已經(jīng)是DataSource.Factory,而ViewModel中的成員被觀察者則是LiveData>類型,如何 將數(shù)據(jù)源的工廠和LiveData進行串聯(lián) ?
因此我們還需要定義一個新的角色PagedListBuilder,開發(fā)者將?數(shù)據(jù)源工廠?和?相關(guān)配置?統(tǒng)一交給PagedListBuilder,即可生成對應(yīng)的LiveData>:
class MyViewModel(val dao: UserDao) : ViewModel() {val users: LiveData>init {// 1.創(chuàng)建DataSource.Factoryval factory: DataSource.Factory = dao.queryUsers()// 2.通過LivePagedListBuilder配置工廠和pageSize, 對users進行實例化
users = LivePagedListBuilder(factory, 30).build()
}
}
如代碼所示,我們在ViewModel中先通過dao獲取了DataSource.Factory,工廠創(chuàng)建數(shù)據(jù)源DataSource,后者為PagedList提供列表所需要的數(shù)據(jù);此外,另外一個Int類型的參數(shù)則制定了每頁數(shù)據(jù)加載的數(shù)量,這里我們指定每頁數(shù)據(jù)數(shù)量為30。
我們成功創(chuàng)建了一個LiveData>的可觀察者對象,接下來的步驟讀者駕輕就熟,只不過我們這里使用的是PagedListAdapter:
class MyActivity : Activity {val myViewModel: MyViewModel
// 1.這里我們使用PagedListAdapter
val adapter: PagedListAdapterfun onCreate(bundle: Bundle?) {
// 2.在Activity中對LiveData進行訂閱
myViewModel.users.observe(this) {
// 3.每當數(shù)據(jù)更新,計算新舊數(shù)據(jù)集的差異,對列表進行更新
adapter.submitList(it)
}
}
}
PagedListAdapter內(nèi)部的實現(xiàn)和普通列表ListAdapter的代碼幾乎完全相同:
// 幾乎完全相同的代碼,只有繼承的父類不同
class MyAdapter(): PagedListAdapter<User, UserViewHolder>(object: DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User)= oldItem.id == newItem.idoverride fun areContentsTheSame(oldItem: User, newItem: User)= oldItem == newItem
}
) {
// ...
}
準確的來說,兩者內(nèi)部的實現(xiàn)還有微弱的區(qū)別,前者ListAdapter的getItem()函數(shù)的返回值是User,而后者PagedListAdapter返回值應(yīng)該是User?(Nullable),其原因我們會在下面的Placeholder部分進行描述。
4.更多可選配置:PagedList.Config
目前的介紹中,分頁的功能似乎已經(jīng)實現(xiàn)完畢,但這些在現(xiàn)實開發(fā)中往往不夠,產(chǎn)品業(yè)務(wù)還有更多細節(jié)性的需求。
在上一小節(jié)中,我們通過LivePagedListBuilder對LiveData>進行創(chuàng)建,這其中第二個參數(shù)是 分頁組件的配置,代表了每頁加載的數(shù)量(PageSize) :
// beforeval users: LiveData> = LivePagedListBuilder(factory, 30).build()
讀者應(yīng)該理解,分頁組件的配置 本身就是抽象的,PageSize并不能完全代表它,因此,設(shè)計者額外定義了更復(fù)雜的數(shù)據(jù)結(jié)構(gòu)PagedList.Config,以描述更細節(jié)化的配置參數(shù):
// afterval config = PagedList.Config.Builder()
.setPageSize(15) // 分頁加載的數(shù)量
.setInitialLoadSizeHint(30) // 初次加載的數(shù)量
.setPrefetchDistance(10) // 預(yù)取數(shù)據(jù)的距離
.setEnablePlaceholders(false) // 是否啟用占位符
.build()
// API發(fā)生了改變
val users: LiveData<PagedList<User>> = LivePagedListBuilder(factory, config).build()
對復(fù)雜業(yè)務(wù)配置的API設(shè)計來說,建造者模式 顯然是不錯的選擇。
接下來我們簡單了解一下,這些可選的配置分別代表了什么。
4.1.分頁數(shù)量:PageSize
最易理解的配置,分頁請求數(shù)據(jù)時,開發(fā)者總是需要定義每頁加載數(shù)據(jù)的數(shù)量。
4.2.初始加載數(shù)量:InitialLoadSizeHint
定義首次加載時要加載的Item數(shù)量。
此值通常大于PageSize,因此在初始化列表時,該配置可以使得加載的數(shù)據(jù)保證屏幕可以小范圍的滾動。
如果未設(shè)置,則默認為PageSize的三倍。
4.3.預(yù)取距離:PrefetchDistance
顧名思義,該參數(shù)配置定義了列表當距離加載邊緣多遠時進行分頁的請求,默認大小為PageSize——即距離底部還有一頁數(shù)據(jù)時,開啟下一頁的數(shù)據(jù)加載。
若該參數(shù)配置為0,則表示除非明確要求,否則不會加載任何數(shù)據(jù),通常不建議這樣做,因為這將導(dǎo)致用戶在滾動屏幕時看到占位符或列表的末尾。
4.4.是否啟用占位符:PlaceholderEnabled
該配置項需要傳入一個boolean值以決定列表是否開啟placeholder(占位符),那么什么是placeholder呢?
我們先來看未開啟占位符的情況:
如圖所示,沒有開啟占位符的情況下,列表展示的是當前所有的數(shù)據(jù),請讀者重點觀察圖片右側(cè)的滾動條,當滾動到列表底部,成功加載下一頁數(shù)據(jù)后,滾動條會從長變短,這意味著,新的條目成功實裝到了列表中。一言以蔽之,未開啟占位符的列表,條目的數(shù)量和PagedList中數(shù)據(jù)數(shù)量是一致的。
接下來我們看一下開啟了占位符的情況:
如圖所示,開啟了占位符的列表,條目的數(shù)量和DataSource中數(shù)據(jù)的總量是一致的。這并不意味著列表從DataSource一次加載了大量的數(shù)據(jù)并進行渲染,所有業(yè)務(wù)依然交給Paging進行分頁處理。
當用戶滑動到了底部尚未加載的數(shù)據(jù)時,開發(fā)者會看到還未渲染的條目,這是理所當然的,PagedList的分頁數(shù)據(jù)加載是異步的,這時對于Item的來說,要渲染的數(shù)據(jù)為null,因此開發(fā)者需要配置占位符,當數(shù)據(jù)未加載完畢時,UI如何進行渲染——這也正是為何上文說到,對于PagedListAdapter來說,getItem()函數(shù)的返回值是可空的User?,而不是User。
隨著PagedList下一頁數(shù)據(jù)的異步加載完畢,伴隨著RecyclerView的原生動畫,新的數(shù)據(jù)會被重新覆蓋渲染到placeholder對應(yīng)的條目上,就像gif圖展示的一樣。
4.5.關(guān)于Placeholder
這里我專門開一個小節(jié)談?wù)勱P(guān)于placeholder,因為這個機制和我們傳統(tǒng)的分頁業(yè)務(wù)似乎有所不同,但Google的工程師們認為在某些業(yè)務(wù)場景下,該配置確實很有用。
開啟了占位符,用戶總是可以快速的滑動列表,因為列表“持有”了整個數(shù)據(jù)集,因此不會像未開啟占位符時,滑動到底部而被迫暫停滾動,直到新的數(shù)據(jù)的加載完畢才能繼續(xù)瀏覽。順暢的操作總比期望之外的阻礙要好得多 。
此外,開啟了占位符意味著用戶與 加載指示器 徹底告別,類似一個 正在加載更多... 的提示標語或者一個簡陋的ProgressBar效果真的會提升用戶體驗嗎?也許答案是否定的,相比之下,用戶應(yīng)該更喜歡一個灰色的占位符,并等待它被新的數(shù)據(jù)渲染。
但缺點也隨之而來,首先,占位符的條目高度應(yīng)該和正確的條目高度一致,在某些需求中,這也許并不符合,這將導(dǎo)致漸進性的動畫效果并不會那么好。
其次,對于開發(fā)者而言,開啟占位符意味著需要對ViewHolder進行額外的代碼處理,數(shù)據(jù)為null或者不為null?兩種情況下的條目渲染邏輯都需要被添加。
最后,這是一個限制性的條件,您的DataSource數(shù)據(jù)源內(nèi)部的數(shù)據(jù)數(shù)量必須是確定的,比如通過Room從本地獲取聯(lián)系人列表;而當數(shù)據(jù)通過網(wǎng)絡(luò)請求獲取的話,這時數(shù)據(jù)的數(shù)量是不確定的,不開啟Placeholder反而更好。
5.更多觀察者類型的配置
在本文的示例中,我們建立了一個LiveData>的可觀察者對象供用戶響應(yīng)數(shù)據(jù)的更新,實際上組件的設(shè)計應(yīng)該面向提供對更多優(yōu)秀異步庫的支持,比如RxJava。
因此,和LivePagedListBuilder一樣,設(shè)計者還提供了RxPagedListBuilder,通過DataSource數(shù)據(jù)源和PagedList.Config以構(gòu)建一個對應(yīng)的Observable:
// LiveData supportval users: LiveData> = LivePagedListBuilder(factory, config).build()// RxJava support
val users: Observable> = RxPagedListBuilder(factory, config).buildObservable()
三、工作流程原理概述
Paging幕后是如何工作的?
接下來,筆者將針對Paging分頁組件的工作流程進行系統(tǒng)性的描述,探討Paging是 如何實現(xiàn)異步分頁數(shù)據(jù)的加載和響應(yīng) 的。
為了便于理解,筆者將整個流程拆分為三個步驟,并為每個步驟繪制對應(yīng)的一張流程圖,這三個步驟分別是:
1、初次創(chuàng)建流程
2、UI渲染和分頁加載流程
3、刷新數(shù)據(jù)源流程
1、初次創(chuàng)建流程
如圖所示,我們定義了ViewModel和Repository,Repository內(nèi)部實現(xiàn)了App的數(shù)據(jù)加載的邏輯,而其左側(cè)的ViewModel則負責與UI組件的通信。
Repository負責為ViewModel中的LiveData>進行創(chuàng)建,因此,開發(fā)者需要創(chuàng)建對應(yīng)的PagedList.Config分頁配置對象和DataSource.Factory數(shù)據(jù)源的工廠,并通過調(diào)用LivePagedListBuilder相關(guān)的API創(chuàng)建出一個LiveData>。
當LiveData一旦被訂閱,Paging將會嘗試創(chuàng)建一個PagedList,同時,數(shù)據(jù)源的工廠DataSource.Factory也會創(chuàng)建一個DataSource,并交給PagedList持有該DataSource。
這時候PagedList已經(jīng)被成功的創(chuàng)建了,但是此時的PagedList內(nèi)部只持有了一個DataSource,卻并沒有持有任何數(shù)據(jù),這意味著觀察者角色的UI層即將接收到一個空數(shù)據(jù)的PagedList。
這沒有任何意義,因此我們更希望PagedList第一次傳遞到UI層級的同時,已經(jīng)持有了初始的列表數(shù)據(jù)(即InitialLoadSizeHint);因此,Paging嘗試在后臺線程中通過DataSource對PagedList內(nèi)部的數(shù)據(jù)列表進行初始化。
現(xiàn)在,PagedList第一次創(chuàng)建完畢,并持有屬于自己的DataSource和初始的列表數(shù)據(jù),通過LiveData這個管道,即將向UI層邁出屬于自己的第一個腳印。
2.UI渲染和分頁加載流程
通過內(nèi)部線程的切換,PagedList從后臺線程切換到了UI線程,通過LiveData抵達了UI層級,也就是我們通常說的Activity或者Fragment中。
讀者應(yīng)該有印象,在上文的示例代碼中,Activity觀察到PagedList后,會通過PagedListAdapter.submitList()函數(shù)將PagedList進行注入。PagedListAdapter第一次接收到PagedList后,就會對UI進行渲染。
當用戶嘗試對屏幕中的列表進行滾動時,我們接收到了需要加載更多數(shù)據(jù)的信號,這時,PagedList在內(nèi)部主動觸發(fā)數(shù)據(jù)的加載,數(shù)據(jù)源提供了更多的數(shù)據(jù),PagedList接收到之后將會主動觸發(fā)RecyclerView的更新,用戶通過RecyclerView原生動畫觀察到了更多的列表Item。
3.刷新數(shù)據(jù)源流程
當數(shù)據(jù)發(fā)生了更新,Paging幕后又做了哪些工作呢?
正如前文所說,數(shù)據(jù)是動態(tài)的, 假設(shè)用戶通過操作添加了一個聯(lián)系人,這時數(shù)據(jù)庫中的數(shù)據(jù)集發(fā)生了更新。
因此,這時屏幕中RecyclerView對應(yīng)的PagedList和DataSource已經(jīng)沒有失效了,因為DataSource中的數(shù)據(jù)是之前數(shù)據(jù)庫中數(shù)據(jù)的快照,數(shù)據(jù)庫內(nèi)部進行了更新,PagedList從舊的DataSource中再取數(shù)據(jù)毫無意義。
因此,Paging組件接收到了數(shù)據(jù)失效的信號,這意味著生產(chǎn)者需要重新構(gòu)建一個PagedList,因此DataSource.Factory再次提供新版本的數(shù)據(jù)源DataSource V2——其內(nèi)部持有了最新數(shù)據(jù)的快照。
在創(chuàng)建新的PagedList的時候,針對PagedList內(nèi)部的初始化需要慎重考慮,因為初始化的數(shù)據(jù)需要根據(jù)用戶當前屏幕中所在的位置(position)進行加載。
通過LiveData,UI層級再次觀察到了新的PagedList,并再次通過submitList()函數(shù)注入到PagedListAdapter中。
和初次的數(shù)據(jù)渲染不同,這一次我們使用到了PagedListAdapter內(nèi)部的AsyncPagedListDiffer對兩個數(shù)據(jù)集進行差異性計算——這避免了notifyDataSetChanged()的濫用,同時,差異性計算的任務(wù)被切換到了后臺線程中執(zhí)行,一旦計算出差異性結(jié)果,新的PagedList會替換舊的PagedList,并對列表進行 增量更新。
四、DataSource數(shù)據(jù)源簡介
Paging分頁組件的設(shè)計中,DataSource是一個非常重要的模塊。顧名思義,DataSource中的Key對應(yīng)數(shù)據(jù)加載的條件,Value對應(yīng)數(shù)據(jù)集的實際類型, 針對不同場景,Paging的設(shè)計者提供了三種不同類型的DataSource抽象類:
PositionalDataSource<T>ItemKeyedDataSource<Key, Value>
PageKeyedDataSource<Key, Value>
接下來我們分別對其進行簡單的介紹。
本章節(jié)涉及的知識點非常重要,但不作為本文的重點,筆者將在該系列的下一篇文章中針對DataSource的設(shè)計與實現(xiàn)進行更細節(jié)的探究,歡迎關(guān)注。
1.PositionalDataSource
PositionalDataSource?是最簡單的DataSource類型,顧名思義,其通過數(shù)據(jù)所處當前數(shù)據(jù)集快照的位置(position)提供數(shù)據(jù)。
PositionalDataSource?適用于 目標數(shù)據(jù)總數(shù)固定,通過特定的位置加載數(shù)據(jù),這里Key是Integer類型的位置信息,并且被內(nèi)置固定在了PositionalDataSource類中,T即數(shù)據(jù)的類型。
最容易理解的例子就是本文的聯(lián)系人列表,其所有的數(shù)據(jù)都來自本地的數(shù)據(jù)庫,這意味著,數(shù)據(jù)的總數(shù)是固定的,我們總是可以根據(jù)當前條目的position映射到DataSource中對應(yīng)的一個數(shù)據(jù)。
來看Room組件配置的dao對應(yīng)編譯期生成的源碼:
```java
// 1.Room自動生成了 DataSource.Factory
@Override
public DataSource.FactorygetAllStudent() {
// 2.工廠函數(shù)提供了PositionalDataSource
return new DataSource.Factory() {@Overridepublic PositionalDataSourcecreate() {return new PositionalDataSource(__db, _statement, false , "Student") {// ...
};
}
};
}
2.ItemKeyedDataSource
ItemKeyedDataSource適用于目標數(shù)據(jù)的加載依賴特定條目的信息,比如需要根據(jù)第N項的信息加載第N+1項的數(shù)據(jù),傳參中需要傳入第N項的某些信息時。
同樣拿聯(lián)系人列表舉例,另外的一種分頁加載方式是通過上一個聯(lián)系人的name作為Key請求新一頁的數(shù)據(jù),因為聯(lián)系人name字母排序的原因,DataSource很容易針對一個name檢索并提供接下來新一頁的聯(lián)系人數(shù)據(jù)——比如根據(jù)Alice找到下一個用戶Bob(A -> B)。
3.PageKeyedDataSource
更多的網(wǎng)絡(luò)請求API中,服務(wù)器返回的數(shù)據(jù)中都會包含一個String類型類似nextPage的字段,以表示當前頁數(shù)據(jù)的下一頁數(shù)據(jù)的接口(比如Github的API),這種分頁數(shù)據(jù)加載的方式正是PageKeyedDataSource的拿手好戲。
這是日常開發(fā)中用到最多的DataSource類型,和ItemKeyedDataSource不同的是,前者的數(shù)據(jù)檢索關(guān)系是單個數(shù)據(jù)與單個數(shù)據(jù)之間的,后者則是每一頁數(shù)據(jù)和每一頁數(shù)據(jù)之間的。
同樣拿聯(lián)系人列表舉例,這種分頁加載方式是按照頁碼進行數(shù)據(jù)加載的,比如一次請求15條數(shù)據(jù),服務(wù)器返回數(shù)據(jù)列表的同時會返回下一頁數(shù)據(jù)的url(或者頁碼),借助該參數(shù)請求下一頁數(shù)據(jù)成功后,服務(wù)器又回返回下下一頁的url,以此類推。
總的來說,DataSource針對不同種數(shù)據(jù)分頁的加載策略提供了不同種的抽象類以方便開發(fā)者調(diào)用,很多情況下,同樣的業(yè)務(wù)使用不同的DataSource都能夠?qū)崿F(xiàn),開發(fā)者按需取用即可。
五、最佳實踐
現(xiàn)在讀者對多種不同的數(shù)據(jù)源DataSource有了簡單的了解,先拋開 分頁列表 的業(yè)務(wù)不談,我們思考另外一個問題:
當列表的數(shù)據(jù)通過多個層級 網(wǎng)絡(luò)請求(Network) 和 本地緩存 (Database)進行加載該怎么處理?
回答這個問題,需要先思考另外一個問題:
Network+Database的解決方案有哪些優(yōu)勢?
1.優(yōu)勢
讀者認真思考可得,Network+Database的解決方案優(yōu)點如下:
1、非常優(yōu)秀的離線模式支持,即使用戶設(shè)備并沒有鏈接網(wǎng)絡(luò),本地緩存依然可以帶來非常不錯的使用體驗;
2、數(shù)據(jù)的快速恢復(fù),如果異常導(dǎo)致App的終止,本地緩存可以對頁面數(shù)據(jù)進行快速恢復(fù),大幅減少流量的損失,以及加載的時間。
3、兩者的配合的效果總是相得益彰。
看起來Network+Database是一個非常不錯的數(shù)據(jù)加載方案,那么為什么大多數(shù)場景并沒有使用本地緩存呢?
主要原因是開發(fā)成本——本地緩存的搭建總是需要額外的代碼,不僅如此,更重要的原因是,數(shù)據(jù)交互的復(fù)雜性也會導(dǎo)致額外的開發(fā)成本。
2.復(fù)雜的交互模型
為什么說Network+Database會導(dǎo)致 數(shù)據(jù)交互的復(fù)雜性 ?
讓我們回到本文的 聯(lián)系人列表 的示例中,這個示例中,所有聯(lián)系人數(shù)據(jù)都來自 本地緩存,因此讀者可以很輕易的構(gòu)建出該功能的整體結(jié)構(gòu):
如圖所示,ViewModel中的數(shù)據(jù)總是由Database提供,如果把數(shù)據(jù)源從Database換成Network,數(shù)據(jù)交互的模型也并沒有什么區(qū)別—— 數(shù)據(jù)源總是單一的。
那么,當數(shù)據(jù)的來源不唯一時——即Network+Database的數(shù)據(jù)加載方案中會有哪些問題呢?
我們來看看常規(guī)的實現(xiàn)方案的數(shù)據(jù)模型:
如圖所示,ViewModel嘗試加載數(shù)據(jù)時,總是會先進行網(wǎng)絡(luò)判斷,若網(wǎng)絡(luò)未連接,則展示本地緩存,否則請求網(wǎng)絡(luò),并且在網(wǎng)絡(luò)請求成功時,將數(shù)據(jù)保存本地。
乍得一看,這種方案似乎并沒有什么問題,實際上卻有兩個非常大的弊端:
2.1 業(yè)務(wù)并非這么簡單
首先,通過一個boolean類型的值就能代表網(wǎng)絡(luò)連接的狀態(tài)嗎?顯而易見,答案是否定的。
實際上,在某些業(yè)務(wù)場景下,服務(wù)器的連接狀態(tài)可以是更為復(fù)雜的,比如接收到了部分的數(shù)據(jù)包?比如某些情況下網(wǎng)絡(luò)請求錯誤,這時候是否需要重新展示本地緩存?
若涉及到網(wǎng)絡(luò)請求的重試則更復(fù)雜,成功展示網(wǎng)絡(luò)數(shù)據(jù),再次失敗展示緩存——業(yè)務(wù)越來越復(fù)雜,我們甚至會逐漸沉浸其中無法自拔,最終醒悟,這種數(shù)據(jù)的交互模型完全不夠用了 。
2.2 無用的本地緩存
另外一個很明顯的弊端則是,當網(wǎng)絡(luò)連接狀態(tài)良好的時候,用戶看到的數(shù)據(jù)總是服務(wù)器返回的數(shù)據(jù)。
這種情況下,請求的數(shù)據(jù)再次存入本地緩存似乎毫無意義,因為網(wǎng)絡(luò)環(huán)境的通暢,Database中的緩存從來未作為數(shù)據(jù)源被展示過。
3.使用單一數(shù)據(jù)源
使用 單一數(shù)據(jù)源 (single source of truth)的好處不言而喻,正如上文所闡述的,多個數(shù)據(jù)源 反而會將業(yè)務(wù)邏輯變得越來越復(fù)雜,因此,我們設(shè)計出這樣的模型:
ViewModel如果響應(yīng)Database中的數(shù)據(jù)變更,且Database作為唯一的數(shù)據(jù)來源?
其思路是:ViewModel只從Database中取得數(shù)據(jù),當Database中數(shù)據(jù)不夠時,則向Server請求網(wǎng)絡(luò)數(shù)據(jù),請求成功,數(shù)據(jù)存入Database,ViewModel觀察到Database中數(shù)據(jù)的變更,并更新到UI中。
這似乎無法滿足上文中的需求?讀者認真思考可知,其實是沒問題的,當網(wǎng)絡(luò)連接發(fā)生故障時,這時向服務(wù)端請求數(shù)據(jù)失敗,并不會更新Database,因此UI展示的正是期望的本地緩存。
ViewModel僅僅響應(yīng)Database中數(shù)據(jù)的變更,這種使用 單一數(shù)據(jù)源 的方式讓復(fù)雜的業(yè)務(wù)邏輯簡化了很多。
4.分頁列表的最佳實踐
現(xiàn)在我們理解了 單一數(shù)據(jù)源 的好處,該方案在分頁組件中也同樣適用,我們唯一需要實現(xiàn)的是,如何主動觸發(fā)服務(wù)端數(shù)據(jù)的請求?
這是當然的,因為Database中依賴網(wǎng)絡(luò)請求成功之后的數(shù)據(jù)存儲更新,否則列表所展示的永遠是Database中不變的數(shù)據(jù)——別忘了,ViewModel和Server之間并沒有任何關(guān)系。
針對Database中的數(shù)據(jù)更新,簡單的方式是 直接進行網(wǎng)絡(luò)請求,這種方式使用非常普遍,比如,列表需要下拉刷新,這時主動請求網(wǎng)絡(luò),網(wǎng)絡(luò)請求成功后將數(shù)據(jù)存入數(shù)據(jù)庫即可,這時ViewModel響應(yīng)到數(shù)據(jù)庫中的更新,并將最新的數(shù)據(jù)更新在UI上。
另外一種方式則和Paging分頁組件本身有關(guān),當列表滾動到指定位置,需要對下一頁數(shù)據(jù)進行加載時,如何向網(wǎng)絡(luò)拉取最新數(shù)據(jù)?
Paging為此提供了BoundaryCallback類用于配置分頁列表自動請求分頁數(shù)據(jù)的回調(diào)函數(shù),其作用是,當數(shù)據(jù)庫中最后一項數(shù)據(jù)被加載時,則會調(diào)用其onItemAtEndLoaded函數(shù):
class MyBoundaryCallback(val database : MyLocalCacheval apiService: ApiService) : PagedList.BoundaryCallback<User>() {
override fun onItemAtEndLoaded(itemAtEnd: User) {
// 請求網(wǎng)絡(luò)數(shù)據(jù),并更新到數(shù)據(jù)庫中
requestAndAppendData(apiService, database, itemAtEnd)
}
}
BoundaryCallback類為Paging通過Network+Database進行分頁加載的功能完成了最后一塊拼圖,現(xiàn)在,分頁列表所有數(shù)據(jù)都來源于本地緩存,并且復(fù)雜的業(yè)務(wù)實現(xiàn)起來也足夠靈活。
5.更多優(yōu)勢
通過Network+Database進行Paging分頁加載還有更多好處,比如更輕易管理分頁列表 額外的狀態(tài) 。
不僅僅是分頁列表,這種方案使得所有列表的 狀態(tài)管理 的更加容易,筆者為此撰寫了另外一篇文章去闡述它,篇幅所限,本文不進行展開,有興趣的讀者可以閱讀。
Android官方架構(gòu)組件Paging-Ex:列表狀態(tài)的響應(yīng)式管理
https://juejin.im/post/5ce6ba09e51d4555e372a562
六、總結(jié)
本文對Paging進行了系統(tǒng)性的概述,最后,Paging到底是一個什么樣的分頁庫?
首先,它支持Network、Database或者兩者,通過Paging,你可以輕松獲取分頁數(shù)據(jù),并直接更新在RecyclerView中。
其次,Paging使用了非常優(yōu)秀的 觀察者模式 ,其簡單的API的內(nèi)部封裝了復(fù)雜的分頁邏輯。
第三,Paging靈活的配置和強大的支持——不同DataSource的數(shù)據(jù)加載方式、不同的響應(yīng)式庫的支持(LiveData、RxJava)等等,Paging總是能夠勝任分頁數(shù)據(jù)加載的需求。
更多 & 參考
再次重申,強烈建議 讀者將本文作為學(xué)習Paging 閱讀優(yōu)先級最高的文章,所有其它的Paging中文博客閱讀優(yōu)先級都應(yīng)該靠后。
——是因為本文的篇幅較長嗎?(1w字的確...)不止如此,本文嘗試對Paging的整體結(jié)構(gòu)進行拆分,筆者認為,只要對整體結(jié)構(gòu)有足夠的理解,一切API的調(diào)用都輕而易舉。但如果直接上手寫代碼的話,反而容易造成 只見樹木,不見森林 之感,上手效率反而降低。
推薦閱讀
(點擊標題可跳轉(zhuǎn)閱讀)
App流暢度優(yōu)化:利用字節(jié)碼插樁實現(xiàn)一個快速排查高耗時方法的工具
談?wù)凙ndroid AOP技術(shù)方案
代理模式以及在Android中的使用
看完這篇 HTTPS,和面試官扯皮就沒問題了
覺得本文對你有幫助?請分享給更多人
wx號:gulinhai531
顧林海公眾號
不定期推出優(yōu)質(zhì)文
章,喜歡的朋友們
給我個好看。
好文章,我在看??
總結(jié)
以上是生活随笔為你收集整理的android实现箭头流程列表_反思|Android 列表分页组件Paging的设计与实现:系统概述...的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2022年12月全球热门移动游戏下载TO
- 下一篇: android 屏幕分辨率 屏幕密度,A