1. 程式人生 > 其它 >【從零開始擼一個App】RecyclerView的使用

【從零開始擼一個App】RecyclerView的使用

技術標籤:android

目標

前段時間打造了一款簡單易用功能全面的圖片上傳元件,現在就來將上傳的圖片以圖片集的形式展現到App上。出於使用者體驗考慮,載入新圖片採用[無限]滾動模式,Android平臺上我們優選RecyclerView元件。

顯示圖片,用的自然是ImageView,然而它並不支援直接載入網路圖片,需要先通過其它網路元件(如HttpURLConnectionokhttp3等)將圖片獲取到本地,得到BitMap資料,然後通過setImageBitmap()載入。
ImageView也有setImageURI(Uri uri)方法,這裡uri的命名容易讓人產生錯覺,其實只能是本地檔案路徑。

所幸,一些開源元件封裝了繁瑣的網路操作和快取策略,提供了易用的API。這裡我選擇了Glide

實現

載入更多

項佈局

有兩個,一個用於列表中各個圖片顯示,一個顯示載入更多/已全部載入放置在列表最末提示使用者。

<!--圖片-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/thumbnail_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop"/>
</LinearLayout>
<!--loadmore-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center">

    <TextView
        android:id="@+id/tv_load_more"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="正在載入更多" />
</LinearLayout>

RecyclerView.Adapter

RecyclerView的設計模式網上資料很多,此處不再贅述。先實現RecyclerView.Adapter

class ThumbnailListAdapter(
    private val thumbnails: List<Thumbnail>,
    private val totalCount: Long,
    private val context: Context
) :
    RecyclerView.Adapter<ThumbnailListAdapter.ThumbnailViewHolder>() {

    // 呼叫若干次
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThumbnailViewHolder {
        // viewType就是通過getItemViewType得到的
        val itemView = LayoutInflater.from(context).inflate(viewType, parent, false)
        return ThumbnailViewHolder(itemView)
    }

    // 搞分頁/瀑布載入的同學不要把這個和資料庫的總數量搞混,這裡的itemCount表示現在記憶體中資料量
    // 我們可以[從後端]獲取新資料新增到資料集,以實現loadmore功能
    override fun getItemCount(): Int {
        return if (thumbnails.isNotEmpty())
            thumbnails.size + 1 // +1 是因為除了thumbnails資料集之外,還有個寫死的loadmore項
        else
            0
    }

    // R.layout.xxx 是Int型別,可以直接返回
    override fun getItemViewType(position: Int): Int {
        return if (position < thumbnails.size)
            R.layout.list_thumbnail_image // 正常圖片顯示
        else
            R.layout.list_loadmore_footer // 末尾loadmore
    }

    // 有螢幕外item進入螢幕時就會呼叫
    override fun onBindViewHolder(holder: ThumbnailViewHolder, position: Int) {
        if (position < thumbnails.size) {
            Glide.with(context)
                .load(thumbnails[position].uri)
                .into(holder.itemView.thumbnail_view)
        } else {
            if (thumbnails.size >= totalCount)
                holder.itemView.tv_load_more.text = "全部載入完畢"
        }
    }
    
    // 必須這麼繼承一下
    class ThumbnailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}

滾動監聽

為RecyclerView新增滾動監聽,在合適的時候載入新資料到資料集中。

recyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
        // 已經在載入則跳過
        if (!_thumbnailsLoading) {
            // 找到最後可見項的索引
            val lastPos = layoutManager.findLastVisibleItemPosition()
            val sum = adapter.itemCount
            // 當快接近末尾項時(這裡差額10,表示再顯示10個item就沒資料了)獲取新資料
            if (newState == RecyclerView.SCROLL_STATE_IDLE && sum - lastPos <= 10) {
                vm.thumbnails.addAll(vm.getMoreAlbumCovers()) // 載入新資料到資料集中
                _thumbnailsLoading = true
            }
        }
    }
})

不要將上面預載入資料和Glide的預載入圖片混淆起來,拿到資料,和通過資料中的uri獲取圖片並下載,這是兩個步驟。Glide專門針對RecyclerView提供了預載入方案,是為了減少滑動時圖片還未從網路請求導致的等待載入情況,目前只支援LinearLayoutManager或其子類佈局

佈局

StaggeredGridLayoutManager

按列瀑布流顯示圖片。簡單地將RecyclerView的layoutManager設為StaggeredGridLayoutManager例項即可,注意StaggeredGridLayoutManager目前還是beta版。

val sgLayoutManager =
    StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)
recyclerview.layoutManager = sgLayoutManager

使用StaggeredGridLayoutManager會發現上下滑動過程中,經常發生圖片塊重排。根據網上說法,這是因為複用的ViewHolder和該ViewHolder要載入的圖片,它們的尺寸不一致導致。比如某個ViewHolder之前載入的圖片高度為60,之後被回收,但是尺寸資訊仍然保留著,後來被一張高度80的圖片複用,由於StaggeredGridLayoutManager是根據ViewHolder的尺寸排序佈局,尺寸的變化導致發生多次排序。解決方法是在ViewHolder繫結資料時(在RecyclerView.Adapter.onBindViewHolder()中),就事先設定好本次佈局的最終尺寸,如下:

override fun onBindViewHolder(holder: ThumbnailViewHolder, position: Int) {
    val layoutParams =
        holder.itemView.thumbnail_view.layoutParams as LinearLayout.LayoutParams
    //手動設定ViewHolder高度
    layoutParams.height = thumbnails[position].height

    Glide.with(context).load(thumbnails[position].uri)
        .into(holder.itemView.thumbnail_view)
}

當由下滑回到最頂部時,經常會出現頂部(第一行)的圖片相互重排。仔細觀察,這是因為第一行初次佈局時是按順序排列而非按空缺插入,往回滑時則是按空缺(哪裡最空最先排哪裡),這導致順序可能與初次排序不一致。不過還好,最終仍會按照圖片尺寸各自歸位。而且這種情況只會出現在第一次由下滑回到頂部時。

GreedoLayoutManager

StaggeredGridLayoutManager一共有3k多行程式碼,又是beta版。程式碼潔癖的我把目光投向了GreedoLayoutManager,它是500px開源的一個LayoutManager,能在保持圖片寬高比例的前提下將多張圖片拼接到一行顯示,原理很簡單,看下面動圖:

替換LayoutManager也相當簡單,重新設定下RecyclerView的layoutManager即可。

val layoutManager =
    GreedoLayoutManager(adapter).also { it.setMaxRowHeight(resources.displayMetrics.heightPixels / 3) }
recyclerview.layoutManager = layoutManager

GreedoLayoutManager在佈局之前需要知道item的寬高比例,只要讓Adapter實現SizeCalculatorDelegate介面即可

override fun aspectRatioForIndex(index: Int): Double {
    val thumbnail = thumbnails[index]
    return thumbnail.width / thumbnail.height.toDouble()
}

執行介面顯示:

可以看到每張圖片都比預期大很多,只能看到一小部分。經研究發現,上面定義的圖片展示項的佈局(LinearLayout內嵌ImageView),最終呈現後,LinearLayout的尺寸是每個網格的尺寸,而內嵌的ImageView則超出了LinearLayout,似乎其最終尺寸是MeasuredSize——我們在onCreateViewHolder時使用了LayoutInflater.from(context).inflate(viewType, parent, false),這裡的parent是RecyclerView,而在佈局xml中寬高都設定為match_parent,因此其中ImageView的MeasuredSize同RecyclerView的寬高——然而ImageView最終尺寸應該同樣適配網格尺寸才對。

以width為例:

期望:ImageView.width == LinearLayout.width == 網格.width
實際:ImageView.width == ImageView.measuredWith == RecyclerView.width

我們看到每個框格其實是ImageView被擷取的左上角那部分。

經過一番搜尋,網上各種對getWidthgetMeasuredWidth區別的闡述,並沒有解決我的困惑,直到這篇從原始碼的角度分析,getWidth() 與 getMeasuredWidth() 的不同之處讓我知道,其實Android系統並沒有對width下定義,自定義佈局時可隨意設定子項大小,是否超出螢幕也沒有限制。在我們這個場景下,估計GreedoLayoutMananger在處理了最外層控制元件(這裡是LinearLayout)的width後,並沒有遞迴處理內部控制元件的width,從而導致了這個bug。

既然如此,那麼就不要外圍的LinearLayout,直接使用ImageView,反倒省了一點開銷。

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThumbnailViewHolder {
    return if (viewType == 0) {
        val imageView = ImageView(parent.context).apply {
            scaleType = ImageView.ScaleType.CENTER_CROP
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        }
        ThumbnailViewHolder(imageView)
    } else {
        val itemView = LayoutInflater.from(context).inflate(viewType, parent, false)
        ThumbnailViewHolder(itemView)
    }
}

當然也有ViewHolder重用導致的顯示問題,圖片只顯示一部分,且是按ViewHolder重用前的寬高比例顯示,如下:

懶得深究,使用Glide官方文件建議的waitForLayout()並沒有用,override(width, height)提前告知圖片尺寸解決。

Glide.with(context)
    .load(thumbnails[position].uri)
    .override(thumbnails[position].width, thumbnails[position].height)
    .into(holder.itemView as ImageView)
//                .waitForLayout() //並沒有用

下拉重新整理

使用SwipeRefreshLayout,easy,按過不表。最後成品如下

其它

一般常用detachAndScrapView,RecyclerView會自動幫我們處理後續重用View[Holder]的邏輯。然而在某些場景下(如只是重排當前顯示的Views而不是移除),我們可以使用更輕量級的detachView(detach之後view就不在介面上顯示了),不過要記得在下次佈局之前手動呼叫attachView(位置的話,detach之前在哪,attach後就在哪)或removeDetachedView/recycleView
注意detach之後,RecyclerView.getChildCount()就相應減少。

真正把 view layout到介面上的是RecyclerView的layoutDecorated方法。