前言
不得不說,Flutter 繪製 UI 的速度和原生根本不是一個量級的,Flutter 要快的多了,比如常用的 ListView
控制元件,原生寫的話,比如 Android,如果不封裝的話,需要一個 Adapter
、ViewHolder
,再加個 xml 佈局檔案,而 Flutter 可能就幾十行。
對於越常用的控制元件,越要熟悉它的原理。Flutter 中的 ScrollView
家族,成員的劃分其實和 Android 還是非常類似的,除了 ListView
、GridView
,還有 CustomScrollView
和 NestedScrollView
。今天我們要講主角就是 ListView
。
ListView
ListView
和 GridView
都繼承於 BoxScrollView
,但這裡並不是繪製和佈局的地方,Flutter 和原生不太一樣,以 Android 為例,Android 上繪製和佈局的單位是 View
和 ViewGroup
,Flutter 則要複雜一點,首先我們用的最多的是各種 Widget
,比如 ListView
,但 Widget
可以理解為一個配置描述檔案,比如以下程式碼:
Container {
width: 100,
height: 100,
color: Colors.white,
}
複製程式碼
這裡描述了我們需要一個寬高為 100,顏色為白色的容器,最後真正去繪製的是 RenderObject
。而在 Widget
和 RenderObject
之間還有個 Element
,它的職責是,將我們配置的 Widget Tree 轉換成 Element Tree,Element
是對 Widget
的進一步抽象,Element
有兩個子類,一個是 RenderObjectElement
,它持有 RenderObject
,還有一個 ComponentElement
,用於組合多個 RenderObjectElement
。這個是 Flutter UI 的核心,要理解好這三個類。
回到我們的主題上來,我們前面說到 ListView
繼承於 BoxScrollView
,而 BoxScrollView
又繼承於 ScrollView
,ScrollView
是一個 StatelessWidget
,它依靠 Scrollable
實現滾動效果,而滾動容器中的 Widget
,稱為 slivers。sliver 用於消費滾動事件。
@override
Widget build(BuildContext context) {
// slivers
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);
// 省略
return primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}
複製程式碼
BoxScrollView
實現了 buildSlivers()
,它只有一個 sliver,也就是滾動容器中,只有一個消費者。這裡又是通過呼叫 buildChildLayout
抽象方法建立。
@override
List<Widget> buildSlivers(BuildContext context) {
// buildChildLayout
Widget sliver = buildChildLayout(context);
EdgeInsetsGeometry effectivePadding = padding;
// 省略
return <Widget>[ sliver ];
}
複製程式碼
最後我們的 ListView
就實現了 buildChildLayout()
:
@override
Widget buildChildLayout(BuildContext context) {
if (itemExtent != null) {
// 如果子項是固定高度的
return SliverFixedExtentList(
delegate: childrenDelegate,
itemExtent: itemExtent,
);
}
// 預設情況
return SliverList(delegate: childrenDelegate);
}
複製程式碼
SliverList
是一個 RenderObjectWidget
,上面我們也說到了,最終繪製和佈局都是交與 RenderObject
去實現的。ListView
也不例外:
@override
RenderSliverList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context;
return RenderSliverList(childManager: element);
}
複製程式碼
RenderSliverList
是 ListView
的核心實現,也是我們本文的重點。
RenderSliverList
有過 Android 自定義控制元件經驗的同學會知道,當我們自定義一個控制元件時,一般會涉及這幾個步驟:measure 和 draw,如果是自定義 ViewGroup
還會涉及 layout 過程,Flutter 也不例外,但它將 measure 和 layout 合併到 layout,draw 稱為 paint。雖然叫法不一樣,但作用是一樣的。系統會呼叫 performLayout()
會執行測量和佈局,RenderSliverList
主要涉及佈局操作,所以我們主要看下這個方法即可。
performLayout()
程式碼比較長,所以我們會省略一些非核心程式碼。
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetEndScrollOffset = scrollOffset + remainingExtent;
final BoxConstraints childConstraints = constraints.asBoxConstraints();
複製程式碼
scrollOffset
表示已滾動的偏移量,cacheOrigin
表示預佈局的相對位置。為了更好的視覺效果,ListView
會在可見範圍內增加預佈局的區域,這裡表示下一次滾動即將展示的區域,稱為 cacheExtent
。這個值可以配置,預設為 250。
// viewport.dart
double get cacheExtent => _cacheExtent;
double _cacheExtent;
set cacheExtent(double value) {
value = value ?? RenderAbstractViewport.defaultCacheExtent;
assert(value != null);
if (value == _cacheExtent)
return;
_cacheExtent = value;
markNeedsLayout();
}
static const double defaultCacheExtent = 250.0;
複製程式碼
remainingCacheExtent
是當前該 sliver 可使用的偏移量,這裡包含了預佈局的區域。這裡我們用一張非常粗糙的圖片來解釋下。
C 區域表示我們的螢幕,這裡我們認為是可見區域,實際情況下,可能還要更小,因為 ListView
可能有些 padding
、magin
或者其他佈局等。B 區域有兩個分別表示頭部的預佈局和底部的預佈局區域,它的值就是我們設定的 cacheExtent
,A 區域回收區域。
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
複製程式碼
這裡的 constraints.scrollOffset
就是 A + B,即可不見區域。constraints.cacheOrigin
在這裡,如果使用預設值,它等於 -250,意思就是說 B 的區域高度有 250,所以它完全不可見時,它的相對位置 y 值就是 -250,這裡算出的 scrollOffset
其實就是開始佈局的起始位置,如果 cacheExtent = 0
,那麼它會從 C 的頂部開始佈局,即 constraints.scrollOffset
否則就是 constraints.scrollOffset + constraints.cacheOrigin
。
if (firstChild == null) {
// 如果沒有 children
if (!addInitialChild()) {
// There are no children.
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
}
// 至少存在一個 children
// leading 頭部,trailing 尾部
RenderBox leadingChildWithLayout, trailingChildWithLayout;
// Find the last child that is at or before the scrollOffset.
RenderBox earliestUsefulChild = firstChild;
for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild);
earliestScrollOffset > scrollOffset;
earliestScrollOffset = childScrollOffset(earliestUsefulChild)) {
// 在頭部插入新的 children
earliestUsefulChild =
insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
if (earliestUsefulChild == null) {
final SliverMultiBoxAdaptorParentData childParentData = firstChild
.parentData;
childParentData.layoutOffset = 0.0;
if (scrollOffset == 0.0) {
earliestUsefulChild = firstChild;
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
break;
} else {
// We ran out of children before reaching the scroll offset.
// We must inform our parent that this sliver cannot fulfill
// its contract and that we need a scroll offset correction.
geometry = SliverGeometry(
scrollOffsetCorrection: -scrollOffset,
);
return;
}
}
final double firstChildScrollOffset = earliestScrollOffset -
paintExtentOf(firstChild);
if (firstChildScrollOffset < -precisionErrorTolerance) {
// 雙精度錯誤
}
final SliverMultiBoxAdaptorParentData childParentData = earliestUsefulChild
.parentData;
// 更新 parentData
childParentData.layoutOffset = firstChildScrollOffset;
assert(earliestUsefulChild == firstChild);
// 更新頭尾
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
}
複製程式碼
上面的程式碼是處理以下這種情況,即 earliestScrollOffset > scrollOffset
,即頭部的 children 和 scrollOffset
之間有空間,沒有填充。畫個簡單的圖形。
這塊區域就是 needLayout。當從下向上滾動時候,就是這裡在進行佈局。
bool inLayoutRange = true;
RenderBox child = earliestUsefulChild;
int index = indexOf(child);
// endScrollOffset 表示當前已經佈局 children 的偏移量
double endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
bool advance() {
assert(child != null);
if (child == trailingChildWithLayout)
inLayoutRange = false;
child = childAfter(child);
if (child == null)
inLayoutRange = false;
index += 1;
if (!inLayoutRange) {
if (child == null || indexOf(child) != index) {
// 需要佈局新的 children,在尾部插入一個新的
child = insertAndLayoutChild(childConstraints,
after: trailingChildWithLayout,
parentUsesSize: true,
);
if (child == null) {
// We have run out of children.
return false;
}
} else {
// Lay out the child.
child.layout(childConstraints, parentUsesSize: true);
}
trailingChildWithLayout = child;
}
assert(child != null);
final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
childParentData.layoutOffset = endScrollOffset;
assert(childParentData.index == index);
// 更新 endScrollOffset,用當前 child 的偏移量 + child 所需要的範圍
endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
return true;
}
複製程式碼
// Find the first child that ends after the scroll offset.
while (endScrollOffset < scrollOffset) {
// 記錄需要回收的專案
leadingGarbage += 1;
if (!advance()) {
assert(leadingGarbage == childCount);
assert(child == null);
// we want to make sure we keep the last child around so we know the end scroll offset
collectGarbage(leadingGarbage - 1, 0);
assert(firstChild == lastChild);
final double extent = childScrollOffset(lastChild) +
paintExtentOf(lastChild);
geometry = SliverGeometry(
scrollExtent: extent,
paintExtent: 0.0,
maxPaintExtent: extent,
);
return;
}
}
複製程式碼
不在可見檢視,不在快取區域的,記錄頭部需要回收的。
// Now find the first child that ends after our end.
while (endScrollOffset < targetEndScrollOffset) {
if (!advance()) {
reachedEnd = true;
break;
}
}
複製程式碼
從上往下滾動時,呼叫 advance()
不斷在底部插入新的 child。
// Finally count up all the remaining children and label them as garbage.
if (child != null) {
child = childAfter(child);
while (child != null) {
trailingGarbage += 1;
child = childAfter(child);
}
}
// 回收
collectGarbage(leadingGarbage, trailingGarbage);
複製程式碼
記錄尾部需要回收的,全部一起回收。上圖中用 nedd grabage 標記的區域。
double estimatedMaxScrollOffset;
if (reachedEnd) {
// 沒有 child 需要佈局了
estimatedMaxScrollOffset = endScrollOffset;
} else {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: indexOf(firstChild),
lastIndex: indexOf(lastChild),
leadingScrollOffset: childScrollOffset(firstChild),
trailingScrollOffset: endScrollOffset,
);
assert(estimatedMaxScrollOffset >=
endScrollOffset - childScrollOffset(firstChild));
}
final double paintExtent = calculatePaintOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
final double targetEndScrollOffsetForPaint = constraints.scrollOffset +
constraints.remainingPaintExtent;
// 反饋佈局消費請求
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint ||
constraints.scrollOffset > 0.0,
);
// 佈局結束
childManager.didFinishLayout();
複製程式碼
總結
在分析完 ListView
的佈局流程後,可以發現整個流程還是比較清晰的。
- 需要佈局的區域包括可見區域和快取區域
- 在佈局區域以外的進行回收