iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Mobile Development

Android 性能戰爭:從 Profiler 開始的 30 天優化實錄系列 第 19

# Day 19:【流暢度戰爭】RecyclerView 優化:不只是 ViewHolder

  • 分享至 

  • xImage
  •  

各位戰士,歡迎來到第十九天的戰場。如果說 UI 流暢度戰爭有一處「凡爾登絞肉機」般的核心戰場,那無疑就是 RecyclerView。幾乎每個應用程式的核心介面都由列表構成,而每一次滾動,都是對我們性能優化技巧的嚴峻考驗。

每個新兵都知道 RecyclerView 優化的第一條軍規:使用 ViewHolder 模式。這當然是基礎中的基礎,是我們必須守住的底線。但是,要打贏這場戰爭,光靠這條底線是遠遠不夠的。當列表變得複雜,內容頻繁更新時,僅僅使用 ViewHolder 就像只拿著步槍去對抗坦克。

今天,我們的任務就是為你的軍火庫添置三款強大的戰術武器,它們將幫助你在最嚴苛的滾動戰役中取得勝利。


武器一:setHasFixedSize(true) —— 預判敵情,避免內耗

這是什麼?
這是一個你應該在 RecyclerView 上設定的、極其簡單卻又極其重要的布林值。setHasFixedSize(true) 是在告訴 RecyclerView 一個明確的資訊:「我的整體大小(寬度和高度)不會因為列表項目的增減而改變。」

對於絕大多數佔滿螢幕的 RecyclerView 來說,這個條件都是成立的。

為何重要?
預設情況下,每當你呼叫 adapter.notify...() 系列方法更新資料時,RecyclerView 都會假設它的邊界可能會因為內容變化而改變,因此它會觸發一次 requestLayout(),重新進行完整的「測量 (Measure)」和「佈局 (Layout)」流程。這是一個非常昂貴的操作,尤其是在資料頻繁變動時。

當你設定了 setHasFixedSize(true),就等於給 RecyclerView 吃了一顆定心丸。它知道了自己的尺寸是固定的,所以在收到資料變更通知時,它可以跳過重新測量佈局的步驟,只專注於重繪需要更新的 Item。

如何使用?
在初始化 RecyclerView 後,加上這一行即可。

val recyclerView = findViewById<RecyclerView>(R.id.my_recycler_view)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = myAdapter

// 就是這麼簡單!
recyclerView.setHasFixedSize(true)

武器二:RecycledViewPool —— 跨戰區共享彈藥

這是什麼?

RecyclerView 的核心是 View 的回收與複用。預設情況下,每一個 RecyclerView 實例都有一個自己私有的「回收池」(RecycledViewPool),用來存放那些滾出螢幕的 ViewHolder。

問題場景

想像一個常見的 UI:使用 ViewPager2 實現多個分頁,每個分頁裡都有一個 RecyclerView;或者在一個垂直的 RecyclerView 裡,巢狀了多個水平的 RecyclerView。在這種情況下,每個 RecyclerView 都各自為政,維護自己的回收池。當你從第一頁滑到第二頁時,第一頁回收的 ViewHolder 只能靜靜地躺在自己的池子裡,而第二頁的 RecyclerView 卻可能因為池子是空的,而被迫不斷建立新的 View,造成卡頓。

解決方案

建立一個共享的 RecycledViewPool,並把它設定給所有需要協同作戰的RecyclerView

// 建立一個共享的回收池
val sharedViewPool = RecyclerView.RecycledViewPool()

// 假設 viewPager2 中有多個 Fragment,每個 Fragment 裡的 RecyclerView 都使用這個共享池
// Fragment A
recyclerViewA.setRecycledViewPool(sharedViewPool)

// Fragment B
recyclerViewB.setRecycledViewPool(sharedViewPool)

// 對於巢狀 RecyclerView 同樣適用
// verticalRecyclerViewAdapter onBindViewHolder...
// horizontalRecyclerView.setRecycledViewPool(sharedViewPool)

如此一來,不同列表之間就可以互相支援。從 A 列表滾出的 View,可以被 B 列表無縫地取用(只要它們的 itemViewType 相同),大大提高了 ViewHolder 的複用效率,減少了 onCreateViewHolder 的呼叫次數。

武器三:DiffUtil & ListAdapter —— 精準打擊,取代地毯式轟炸

問題場景

當列表資料需要更新時,你最先想到的可能是 adapter.notifyDataSetChanged()。這是一個毀滅性的命令,相當於對整個戰場進行「地毯式轟炸」。它告訴 RecyclerView:「所有東西都變了,放棄所有快取,全部重新佈局和重繪!」 這不僅性能極低,還會失去所有優雅的 Item 動畫。

解決方案

使用 DiffUtil 進行「精準打擊」。DiffUtil 是一個工具類,它能比較新舊兩個資料列表的差異,並計算出最小的更新操作集(哪些是新增、哪些是刪除、哪些是移動、哪些是內容改變)。

而比 DiffUtil 更進階的武器,是官方推薦的 ListAdapterListAdapter 是一個特殊的 Adapter,它內建了 DiffUtil 的功能,並在背景執行緒為你處理好了一切。

如何使用?

  1. 建立一個 DiffUtil.ItemCallback
class MyItemDiffCallback : DiffUtil.ItemCallback<MyItem>() {
    // 判斷兩個 Item 是否是同一個(通常比較 ID)
    override fun areItemsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem.id == newItem.id
    }

    // 判斷同一個 Item 的內容是否發生了變化
    override fun areContentsTheSame(oldItem: MyItem, newItem: MyItem): Boolean {
        return oldItem == newItem // 如果 MyItem 是 data class,可以直接比較
    }
}
  1. 讓你的 Adapter 繼承自 ListAdapter
class MyAdapter : ListAdapter<MyItem, MyViewHolder>(MyItemDiffCallback()) {
    // ... 正常實現 onCreateViewHolder 和 onBindViewHolder ...
}
  1. 更新資料時,告別 notifyDataSetChanged(),改用 submitList()
// 在你的 Activity 或 ViewModel 中
val newList = // ... 從網路或資料庫獲取了新資料 ...
myAdapter.submitList(newList)

就這樣!ListAdapter 會自動在背景執行緒用 DiffUtil 比較新舊列表的差異,然後把計算出的精準更新操作(如 notifyItemInserted, notifyItemChanged)切換到主執行緒來執行。你將同時獲得極致的性能和免費的 Item 動畫。

今日總結

今天,我們為 RecyclerView 這場核心戰役裝備了三款超越 ViewHolder 模式的進階武器:

  • setHasFixedSize(true): 透過預判佈局不變來避免不必要的系統內耗。

  • RecycledViewPool: 透過共享回收池來提高多列表場景下的 ViewHolder 複用率。

  • ListAdapterDiffUtil: 透過精準的資料比對與更新,實現最高效、最流暢的列表刷新。

掌握了這些技巧,你就擁有了解決絕大多數 RecyclerView 卡頓問題的能力。

傳統 View 體系的戰役即將告一段落。明天,戰爭將進入一個全新的維度:Jetpack Compose。我們將探討在這個聲明式 UI 的世界裡,性能戰爭的規則又發生了怎樣的改變,以及如何應對最核心的挑戰——Recomposition (重組)。

我們明天見!


上一篇
# Day 18:【流暢度戰爭】佈局優化的勝利:ConstraintLayout vs LinearLayout
下一篇
# Day 20:【流暢度戰爭】Compose 中的 Recomposition 戰爭
系列文
Android 性能戰爭:從 Profiler 開始的 30 天優化實錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言