Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

卻把清梅嗅發表於2019-04-07

爭取打造 Android Jetpack 講解的最好的部落格系列

Android Jetpack 實戰篇

概述

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

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

筆者在實際專案中已經使用Paging半年有餘,和市面上其它熱門的分頁庫相比,Paging最大的亮點在於其 將列表分頁載入的邏輯作為回撥函式封裝入 DataSource,開發者在配置完成後,無需控制分頁的載入,列表會 自動載入 下一頁資料並展示。

本文將闡述:為使用了Paging的列表新增HeaderFooter的整個過程、這個過程中遇到的一些阻礙、以及自己是如何解決這些阻礙的——如果您想直接瀏覽最終的解決方案,請直接翻閱本文的 最終的解決方案 小節。

初始思路

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

更適用的解決方式是通過實現 多型別列表(MultiType),除了列表本身的Item型別之外,HeaderFooter也被視作一種Item,關於這種方式的實現網上已有很多文章講解,本文不贅述。

在正式開始本文內容之前,我們先來看看最終的實現效果,我們為一個Student的分頁列表新增了一個HeaderFooter

Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

實現這種效果,筆者最初的思路也是通過 多型別列表 實現HeaderFooter,但是很快我們就遇到了第一個問題,那就是 我們並沒有直接持有資料來源

1.資料來源問題

對於常規的多型別列表而言,我們可以輕易的持有List<ItemData>,從資料的控制而言,我更傾向於用一個代表Header或者Footer的佔位符插入到資料列表的頂部或者底部,這樣對於RecyclerView的渲染而言,它是這樣的:

Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

正如我所標註的,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個位置上

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

Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

程式碼寫完後,直覺告訴我似乎沒有什麼問題,讓我們來看看實際的執行效果:

Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

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
    }
}
複製程式碼

現在我們成功實現了上文中我們的思路,一圖勝千言:

Android官方架構元件Paging-Ex:為分頁列表新增Header和Footer

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中的實現方式,如果您有更好的解決方案,歡迎提出,期待與您的共同探討。

參考&感謝


關於我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github

如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章