==================================================================
在最近發布的 Flutter 3.43.0-0.1.pre 這個 Beta 版本裡,官方在 Framework 層面對 ScrollView / Viewport / ShrinkWrappingViewport 做了一個比較有意思的修改:
ScrollCacheExtent,廢棄 cacheExtent + cacheExtentStyleRenderShrinkWrappingViewport 在無約束下 cacheExtent 可能變成 NaN 的問題這次修改涉及 rendering 層核心程式碼,屬於 Viewport 底層重構,暫時看來修改的影響是正向的,應該不至於引起類似之前《Flutter 3.41 iOS 鍵盤負優化:一個程式碼潔癖引發的負優化》的問題。
根據 #181092 的修改內容,這次修改範圍主要涉及:
rendering/viewport.dart
widgets/scroll_view.dart
widgets/page_view.dart
widgets/list_view.dart
widgets/grid_view.dart
對應原始碼的影響有:
RenderViewportBase
RenderViewport
RenderShrinkWrappingViewport
Viewport
ShrinkWrappingViewport
ScrollView
ListView
PageView
所以,雖然看起來只是個小 feature 和一個 bug fix,但其實這個調整並不是 Widget 層的小改動,而是 Viewport 渲染路徑修改。
所以才會需要挑出來聊一聊。
首先是 ScrollCacheExtent,在之前的實作裡,Viewport 的 cache 主要由這兩個欄位控制:
double cacheExtent
CacheExtentStyle cacheExtentStyle
相關邏輯為:
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
calculatedCacheExtent = cacheExtent;
case CacheExtentStyle.viewport:
calculatedCacheExtent = mainAxisExtent * cacheExtent;
}
涉及的關鍵變數是:
mainAxisExtent = viewport size
而問題就出現在這裡,因為 ShrinkWrappingViewport 的特殊性,當 ScrollView 設定 shrinkWrap = true 的時候,ScrollView.buildViewport 就會建立 ShrinkWrappingViewport:
ScrollView.buildViewport
-> ShrinkWrappingViewport
-> RenderShrinkWrappingViewport
而 ShrinkWrappingViewport 的特點就是 viewport size 由子節點決定,而不是由父約束,這就意味著 mainAxisExtent 可能不是一個有限的值,也就是類似以下場景:
SingleChildScrollView
-> ListView(shrinkWrap: true)
或
Column
-> ListView(shrinkWrap: true)
這些情況下父佈局在主軸方向是 unbounded,所以 ShrinkWrappingViewport 會得到 constraints.maxExtent = infinity 的情況,也就是最終:
mainAxisExtent = infinity
這乍看沒什麼問題,但舊的 cacheExtent 邏輯沒有考慮到這種情況,因為在舊邏輯裡:
viewport cache mode = cacheExtentStyle.viewport
也就是
calculatedCacheExtent = mainAxisExtent * cacheExtent
如果這時候 mainAxisExtent = infinity,那就會 infinity * 0.5 = infinity,以致於在後續佈局計算裡 paintExtent / layoutOffset / scrollOffset 都可能出現 infinity - infinity,也就是結果為 NaN。例如:
SingleChildScrollView(
child: ListView.builder(
shrinkWrap: true,
cacheExtent: 0.5,
cacheExtentStyle: CacheExtentStyle.viewport,
itemBuilder: ...
),
)
而在新的 API 下,cacheExtent 和 cacheExtentStyle 現在合併成 ScrollCacheExtent,並且內部做了適配,所以這種情況現在不會再報錯:
SingleChildScrollView(
child: ListView.builder(
shrinkWrap: true,
scrollCacheExtent: ScrollCacheExtent.viewport(0.5),
),
)
所以這裡的 ScrollCacheExtent 不是簡單地把兩個參數合成一個,而是在內部做了重構,首先是在 viewport.dart 內部提供了:
ScrollCacheExtent.pixels()
ScrollCacheExtent.viewport()
對應內部實作了新的 Viewport 計算邏輯:
_calculateCacheOffset(mainAxisExtent)
_calculatedCacheExtent =
_scrollCacheExtent._calculateCacheOffset(mainAxisExtent)
這個情況下 cache 統一由一個位置計算,並且避免 style + value 分離。其中「NaN 修復」的關鍵在於 RenderShrinkWrappingViewport,對應的核心修改為:
if (!mainAxisExtent.isFinite)
cacheExtent = 0
因為對於 infinite viewport 來說,實際上已經會建立所有子項(already builds all children),所以根本不需要 cache,而這個修改也會影響 PageView / ListView / GridView / CustomScrollView 等常用控制項。
所以這也是一個相對昂貴的效能配置選項。
所以這個 ScrollCacheExtent 的修改,本質上是:
ShrinkWrappingViewport 在無約束下 cacheExtent 計算出現 NaN 的問題ScrollView / Viewport / RenderViewport 的快取邏輯雖然邏輯改動看起來好像不多,但涉及的檔案和地方還是蠻多的,從長遠來看,這個修改還是比較有意義的,至少之前經常遇到的 NaN 問題終於不用自己處理了。