Android官方架構組件Paging-Ex:爲分頁列表添加Header和Footer

本文已受權「玉剛說」微信公衆號獨家發佈android

概述

PagingGoogle在2018年I/O大會上推出的適用於Android原生開發的分頁庫,若是您還不是很瞭解這個 官方欽定 的分頁架構組件,歡迎參考筆者的這篇文章:git

Android官方架構組件Paging:分頁庫的設計美學github

筆者在實際項目中已經使用Paging半年有餘,和市面上其它熱門的分頁庫相比,Paging最大的亮點在於其 將列表分頁加載的邏輯做爲回調函數封裝入 DataSource,開發者在配置完成後,無需控制分頁的加載,列表會 自動加載 下一頁數據並展現。編程

本文將闡述:爲使用了Paging的列表添加HeaderFooter的整個過程、這個過程當中遇到的一些阻礙、以及本身是如何解決這些阻礙的——若是您想直接瀏覽最終的解決方案,請直接翻閱本文的 最終的解決方案 小節。微信

初始思路

RecyclerView列表添加HeaderFooter並非一個很麻煩的事,最簡單粗暴的方式是將RecyclerViewHeader共同放入同一個ScrollView的子View中,但它無異於對RecyclerView自身的複用機制視而不見,所以這種解決方案並不是首選。架構

更適用的解決方式是經過實現 多類型列表(MultiType),除了列表自己的Item類型以外,HeaderFooter也被視做一種Item,關於這種方式的實現網上已有不少文章講解,本文不贅述。app

在正式開始本文內容以前,咱們先來看看最終的實現效果,咱們爲一個Student的分頁列表添加了一個HeaderFooter框架

實現這種效果,筆者最初的思路也是經過 多類型列表 實現HeaderFooter,可是很快咱們就遇到了第一個問題,那就是 咱們並無直接持有數據源ide

1.數據源問題

對於常規的多類型列表而言,咱們能夠輕易的持有List<ItemData>,從數據的控制而言,我更傾向於用一個表明Header或者Footer的佔位符插入到數據列表的頂部或者底部,這樣對於RecyclerView的渲染而言,它是這樣的:函數

正如我所標註的,List<ItemData>中一個ItemData對應了一個ItemView——我認爲爲一個Header或者Footer單首創建對應一個Model類型是徹底值得的,它極大加強了代碼的可讀性,並且對於複雜的Header而言,表明狀態的Model類也更容易讓開發者對其進行渲染。

這種實現方式簡單、易讀而不失優雅,可是在Paging中,這種思路一開始就被堵死了。

咱們先看PagedListAdapter類的聲明:

// T泛型表明數據源的類型,即本文中的 Student
public abstract class PagedListAdapter<T, VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {
    // ...
}
複製代碼

所以,咱們須要這樣實現:

// 這裏咱們只能指定Student類型
class SimpleAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {
  // ...
}
複製代碼

有同窗提出,咱們能夠將這裏的Student指定爲某個接口(好比定義一個ItemData接口),而後讓StudentHeader對應的Model都去實現這個接口,而後這樣:

class SimpleAdapter : PagedListAdapter<ItemData, RecyclerView.ViewHolder>(diffCallback) {
  // ...
}
複製代碼

看起來確實可行,可是咱們忽略了一個問題,那就是本小節要闡述的:

咱們並無直接持有數據源

回到初衷,咱們知道,Paging最大的亮點在於 自動分頁加載,這是觀察者模式的體現,配置完成後,咱們並不關心 數據是如何被分頁、什麼時候被加載、如何被渲染 的,所以咱們也不須要直接持有List<Student>(實際上也持有不了),更無從談起手動爲其添加HeaderItemFooterItem了。

以本文爲例,實際上全部邏輯都交給了ViewModel

class CommonViewModel(app: Application) : AndroidViewModel(app) {

    private val dao = StudentDb.get(app).studentDao()

    fun getRefreshLiveData(): LiveData<PagedList<Student>> =
            LivePagedListBuilder(dao.getAllStudent(), PagedList.Config.Builder()
                    .setPageSize(15)                         //配置分頁加載的數量
                    .setInitialLoadSizeHint(40)              //初始化加載的數量
                    .build()).build()
}
複製代碼

能夠看到,咱們並未直接持有List<Student>,所以list.add(headerItem)這種 持有並修改數據源 的方案几乎不可行(較真而言,實際上是可行的,可是成本太高,本文不深刻討論)。

2.嘗試直接實現列表

接下來我針對直接實現多類型列表進行嘗試,咱們先不討論如何實現Footer,僅以Header而言,咱們進行以下的實現:

class HeaderSimpleAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {

    // 1.根據position爲item分配類型
    // 若是position = 1,視爲Header
    // 若是position != 1,視爲普通的Student
    override fun getItemViewType(position: Int): Int {
        return when (position == 0) {
            true -> ITEM_TYPE_HEADER
            false -> super.getItemViewType(position)
        }
    }

    // 2.根據不一樣的viewType生成對應ViewHolder
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_TYPE_HEADER -> HeaderViewHolder(parent)
            else -> StudentViewHolder(parent)
        }
    }

    // 3.根據holder類型,進行對應的渲染
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is HeaderViewHolder -> holder.renderHeader()
            is StudentViewHolder -> holder.renderStudent(getStudentItem(position))
        }
    }

    // 4.這裏咱們根據StudentItem的position,
    // 獲取position-1位置的學生
    private fun getStudentItem(position: Int): Student? {
        return getItem(position - 1)
    }

    // 5.由於有Header,item數量要多一個
    override fun getItemCount(): Int {
        return super.getItemCount() + 1
    }

    // 省略其餘代碼...
    // 省略ViewHolder代碼
}    
複製代碼

代碼和註釋已經將個人我的思想展現的很清楚了,咱們固定一個Header在多類型列表的最上方,這也致使咱們須要重寫getItemCount()方法,而且在對Item進行渲染的onBindViewHolder()方法中,對Sutdent的獲取進行額外的處理——由於多了一個Header,致使產生了數據源和列表的錯位差—— 第n個數據被獲取時,咱們應該將其渲染在列表的第n+1個位置上

我簡單繪製了一張圖來描述這個過程,也許更加直觀易懂:

代碼寫完後,直覺告訴我彷佛沒有什麼問題,讓咱們來看看實際的運行效果:

Gif也許展現並不那麼清晰,簡單總結下,問題有兩個:

  • 1.在咱們進行下拉刷新時,由於Header更應該是一個靜態獨立的組件,但實際上它也是列表的一部分,所以白光一閃,除了Student列表,Header做爲Item也進行了刷新,這與咱們的預期不符;
  • 2.下拉刷新以後,列表 並未展現在最頂部,而是滑動到了一個奇怪的位置。

致使這兩個問題的根本緣由仍然是Paging計算列表的position時出現的問題:

對於問題1,Paging對於列表的刷新理解爲 全部Item的刷新,所以一樣做爲ItemHeader也沒法避免被刷新;

問題2依然也是這個問題致使的,在Paging獲取到第一頁數據時(假設第一頁數據只有10條),Paging會命令更新position in 0..9Item,而實際上由於Header的關係,咱們是指望它可以更新第position in 1..10Item,最終致使了刷新以及對新數據的展現出現了問題。

3.向Google和Github尋求答案

正如標題而言,我嘗試求助於GoogleGithub,最終找到了這個連接:

PagingWithNetworkSample - PagedList RecyclerView scroll bug

若是您簡單研究過PagedListAdapter的源碼的話,您應該瞭解,PagedListAdapter內部定義了一個AsyncPagedListDiffer,用於對列表數據的加載和展現,PagedListAdapter更像是一個空殼,全部分頁相關的邏輯實際都 委託 給了AsyncPagedListDiffer:

public abstract class PagedListAdapter<T, VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {

         final AsyncPagedListDiffer<T> mDiffer;

         public void submitList(@Nullable PagedList<T> pagedList) {
             mDiffer.submitList(pagedList);
         }

         protected T getItem(int position) {
             return mDiffer.getItem(position);
         }

         public int getItemCount() {
             return mDiffer.getItemCount();
         }       

         public PagedList<T> getCurrentList() {
             return mDiffer.getCurrentList();
         }
}          
複製代碼

雖然Paging中數據的獲取和展現咱們是沒法控制的,但咱們能夠嘗試 瞞過 PagedListAdapter,即便Paging獲得了position in 0..9List<Data>,可是咱們讓PagedListAdapter去更新position in 1..10的item不就能夠了嘛?

所以在上方的Issue連接中,onlymash 同窗提出了一個解決方案:

重寫PagedListAdapter中被AsyncPagedListDiffer代理的全部方法,而後實例化一個新的AsyncPagedListDiffer,並讓這個新的differ代理這些方法。

篇幅所限,咱們只展現部分核心代碼:

class PostAdapter: PagedListAdapter<Any, RecyclerView.ViewHolder>() {

    private val adapterCallback = AdapterListUpdateCallback(this)

    // 當第n個數據被獲取,更新第n+1個position
    private val listUpdateCallback = object : ListUpdateCallback {
        override fun onChanged(position: Int, count: Int, payload: Any?) {
            adapterCallback.onChanged(position + 1, count, payload)
        }

        override fun onMoved(fromPosition: Int, toPosition: Int) {
            adapterCallback.onMoved(fromPosition + 1, toPosition + 1)
        }

        override fun onInserted(position: Int, count: Int) {
            adapterCallback.onInserted(position + 1, count)
        }

        override fun onRemoved(position: Int, count: Int) {
            adapterCallback.onRemoved(position + 1, count)
        }
    }

    // 新建一個differ
    private val differ = AsyncPagedListDiffer<Any>(listUpdateCallback,
        AsyncDifferConfig.Builder<Any>(POST_COMPARATOR).build())

    // 將全部方法重寫,並委託給新的differ去處理
    override fun getItem(position: Int): Any? {
        return differ.getItem(position - 1)
    }

    // 將全部方法重寫,並委託給新的differ去處理
    override fun submitList(pagedList: PagedList<Any>?) {
        differ.submitList(pagedList)
    }

    // 將全部方法重寫,並委託給新的differ去處理
    override fun getCurrentList(): PagedList<Any>? {
        return differ.currentList
    }
}
複製代碼

如今咱們成功實現了上文中咱們的思路,一圖勝千言:

4.另一種實現方式

上一小節的實現方案是徹底可行的,但我我的認爲美中不足的是,這種方案 對既有的Adapter中代碼改動過大

我新建了一個AdapterListUpdateCallback、一個ListUpdateCallback以及一個新的AsyncPagedListDiffer,並重寫了太多的PagedListAdapter的方法——我添加了數十行分頁相關的代碼,但這些代碼和正常的列表展現並無直接的關係。

固然,我能夠將這些邏輯都抽出來放在一個新的類裏面,但我仍是感受我 好像是模仿並重寫了一個新的PagedListAdapter類同樣,那麼是否還有其它的思路呢?

最終我找到了這篇文章:

Android RecyclerView + Paging Library 添加頭部刷新會自動滾動的問題分析及解決

這篇文章中的做者經過細緻分析Paging的源碼,得出了一個更簡單實現Header的方案,有興趣的同窗能夠點進去查看,這裏簡單闡述其原理:

經過查看源碼,以添加分頁爲例,Paging對拿到最新的數據後,對列表的更新實際是調用了RecyclerView.AdapternotifyItemRangeInserted()方法,而咱們能夠經過重寫Adapter.registerAdapterDataObserver()方法,對數據更新的邏輯進行調整

// 1.新建一個 AdapterDataObserverProxy 類繼承 RecyclerView.AdapterDataObserver
class AdapterDataObserverProxy extends RecyclerView.AdapterDataObserver {
    RecyclerView.AdapterDataObserver adapterDataObserver;
    int headerCount;
    public ArticleDataObserver(RecyclerView.AdapterDataObserver adapterDataObserver, int headerCount) {
        this.adapterDataObserver = adapterDataObserver;
        this.headerCount = headerCount;
    }
    @Override
    public void onChanged() {
        adapterDataObserver.onChanged();
    }
    @Override
    public void onItemRangeChanged(int positionStart, int itemCount) {
        adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount);
    }
    @Override
    public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
        adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload);
    }

    // 當第n個數據被獲取,更新第n+1個position
    @Override
    public void onItemRangeInserted(int positionStart, int itemCount) {
        adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount);
    }
    @Override
    public void onItemRangeRemoved(int positionStart, int itemCount) {
        adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount);
    }
    @Override
    public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
        super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount);
    }
}

// 2.對於Adapter而言,僅需重寫registerAdapterDataObserver()方法
// 而後用 AdapterDataObserverProxy 去作代理便可
class PostAdapter extends PagedListAdapter {

    @Override
    public void registerAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
        super.registerAdapterDataObserver(new AdapterDataObserverProxy(observer, getHeaderCount()));
    }
}
複製代碼

咱們將額外的邏輯抽了出來做爲一個新的類,思路和上一小節的十分類似,一樣咱們也獲得了預期的結果。

通過對源碼的追蹤,從性能上來說,這兩種實現方式並無什麼不一樣,惟一的區別就是,前者是針對PagedListAdapter進行了重寫,將Item更新的代碼交給了AsyncPagedListDiffer;而這種方式中,AsyncPagedListDiffer內部對Item更新的邏輯,最終仍然是交給了RecyclerView.AdapternotifyItemRangeInserted()方法去執行的——二者本質上並沒有區別

5.最終的解決方案

雖然上文只闡述了Paging library如何實現Header,實際上對於Footer而言也是同樣,由於Footer也能夠被視爲另一種的Item;同時,由於Footer在列表底部,並不會影響position的更新,所以它更簡單。

下面是完整的Adapter示例:

class HeaderProxyAdapter : PagedListAdapter<Student, RecyclerView.ViewHolder>(diffCallback) {

    override fun getItemViewType(position: Int): Int {
        return when (position) {
            0 -> ITEM_TYPE_HEADER
            itemCount - 1 -> ITEM_TYPE_FOOTER
            else -> super.getItemViewType(position)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_TYPE_HEADER -> HeaderViewHolder(parent)
            ITEM_TYPE_FOOTER -> FooterViewHolder(parent)
            else -> StudentViewHolder(parent)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is HeaderViewHolder -> holder.bindsHeader()
            is FooterViewHolder -> holder.bindsFooter()
            is StudentViewHolder -> holder.bindTo(getStudentItem(position))
        }
    }

    private fun getStudentItem(position: Int): Student? {
        return getItem(position - 1)
    }

    override fun getItemCount(): Int {
        return super.getItemCount() + 2
    }

    override fun registerAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) {
        super.registerAdapterDataObserver(AdapterDataObserverProxy(observer, 1))
    }

    companion object {
        private val diffCallback = object : DiffUtil.ItemCallback<Student>() {
            override fun areItemsTheSame(oldItem: Student, newItem: Student): Boolean =
                    oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: Student, newItem: Student): Boolean =
                    oldItem == newItem
        }

        private const val ITEM_TYPE_HEADER = 99
        private const val ITEM_TYPE_FOOTER = 100
    }
}
複製代碼

若是你想查看運行完整的demo,這裏是本文sample的地址:

github.com/qingmei2/Sa…

6.更多優化點?

文末最終的方案是否有更多優化的空間呢?固然,在實際的項目中,對其進行簡單的封裝是更有意義的(好比Builder模式、封裝一個HeaderFooter甚至二者都有的裝飾器類、或者其它...)。

本文旨在描述Paging使用過程當中 遇到的問題解決問題的過程,所以項目級別的封裝和實現細節不做爲本文的主要內容;關於HeaderFooterPaging中的實現方式,若是您有更好的解決方案,期待與您的共同探討。

參考&感謝


系列文章

爭取打造 Android Jetpack 講解的最好的博客系列

Android Jetpack 實戰篇


關於我

Hello,我是卻把清梅嗅,若是您以爲文章對您有價值,歡迎 ❤️,也歡迎關注個人我的博客或者Github

若是您以爲文章還差了那麼點東西,也請經過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章
相關標籤/搜索