前言
接著上一章Flutter Sliver一生之敵 (ScrollView),我們這章將沿著ListView/GridView => SliverList/SliverGrid => RenderSliverList/RenderSliverGrid的感情線,梳理列表計算的最終一公里程式碼,舉一反N。
歡迎加入Flutter Candies QQ群: 181398081。
- Flutter Sliver一生之敵 (ScrollView)
- Flutter Sliver一生之敵 (ExtendedList)
- Flutter Sliver你要的瀑布流小姐姐
- Flutter Sliver一生之敵 (LoadingMoreList)
Sliver的佈局輸入和輸出
在講解佈局程式碼之前,先要了解下Sliver佈局的輸入和輸出
SliverConstraints
Sliver佈局的輸入,就是Viewport告訴我們的約束。
class SliverConstraints extends Constraints {
/// Creates sliver constraints with the given information.
///
/// All of the argument must not be null.
const SliverConstraints({
//滾動的方向
@required this.axisDirection,
//這個是給center使用的,center之前的sliver是顛倒的
@required this.growthDirection,
//使用者手勢的方向
@required this.userScrollDirection,
//滾動的偏移量,注意這裡是針對這個Sliver的,而且非整個Slivers的總滾動偏移量
@required this.scrollOffset,
//前面Slivers的總的大小
@required this.precedingScrollExtent,
//為pinned和floating設計的,如果前一個Sliver繪製大小為100,但是佈局大小隻有50,那麼這個Sliver的overlap為50.
@required this.overlap,
//還有多少內容可以繪製,參考viewport以及cache。比如多Slivers的時候,前一個佔了100,那麼後面能繪製的區域就要減掉前面繪製的區域大小,得到剩餘的繪製區域大小
@required this.remainingPaintExtent,
//縱軸的大小
@required this.crossAxisExtent,
//縱軸的方向,這裡會影響GridView同一行元素的擺放順序,是0~x,還是x~0
@required this.crossAxisDirection,
//viewport中還有多少內容可以繪製
@required this.viewportMainAxisExtent,
//剩餘的快取區域大小
@required this.remainingCacheExtent,
//相對於scrollOffset快取區域大小
@required this.cacheOrigin,
})
複製程式碼
SliverGeometry
Sliver佈局的輸出,將會反饋給Viewport。
@immutable
class SliverGeometry extends Diagnosticable {
/// Creates an object that describes the amount of space occupied by a sliver.
///
/// If the [layoutExtent] argument is null, [layoutExtent] defaults to the
/// [paintExtent]. If the [hitTestExtent] argument is null, [hitTestExtent]
/// defaults to the [paintExtent]. If [visible] is null, [visible] defaults to
/// whether [paintExtent] is greater than zero.
///
/// The other arguments must not be null.
const SliverGeometry({
//預估的Sliver能夠滾動大小
this.scrollExtent = 0.0,
//對後一個的overlap屬性有影響,它小於[SliverConstraints.remainingPaintExtent],為Sliver在viewport範圍(包含cache)內第一個元素到最後一個元素的大小
this.paintExtent = 0.0,
//相對Sliver位置的繪製起點
this.paintOrigin = 0.0,
//這個sliver在viewport的第一個顯示位置到下一個sliver的第一個顯示位置的大小
double layoutExtent,
//最大能繪製的總大小,這個引數是用於[SliverConstraints.remainingPaintExtent] 是無窮大的,就是使用在shrink-wrapping viewport中
this.maxPaintExtent = 0.0,
//如果sliver被pinned在邊界的時候,這個大小為Sliver的自身的高度。其他情況為0
this.maxScrollObstructionExtent = 0.0,
//點選有效區域的大小,預設為paintExtent
double hitTestExtent,
//可見,paintExtent為0不可見。
bool visible,
//是否需要做clip,免得chidren溢位
this.hasVisualOverflow = false,
//viewport layout sliver的時候,如果sliver出現了一些問題,那麼這個值將不等於0,通過這個值來修正整個滾動的ScrollOffset
this.scrollOffsetCorrection,
//該Sliver使用了多少[SliverConstraints.remainingCacheExtent],針對多Slivers的情況
double cacheExtent,
})
複製程式碼
大概講解了這些引數的意義,可能還是不太明白,在後面的原始碼中使用中還會根據場景進行講解。
BoxScrollView
Widget | Extends |
---|---|
ListView/GridView | BoxScrollView => ScrollView |
ListView 和 GirdView 都繼承與BoxScrollView,我們先看看BoxScrollView跟ScrollView有什麼區別。
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry padding;
@override
List<Widget> buildSlivers(BuildContext context) {
/// 這個方法被ListView/GirdView 實現
Widget sliver = buildChildLayout(context);
EdgeInsetsGeometry effectivePadding = padding;
if (padding == null) {
final MediaQueryData mediaQuery = MediaQuery.of(context, nullOk: true);
if (mediaQuery != null) {
// Automatically pad sliver with padding from MediaQuery.
final EdgeInsets mediaQueryHorizontalPadding =
mediaQuery.padding.copyWith(top: 0.0, bottom: 0.0);
final EdgeInsets mediaQueryVerticalPadding =
mediaQuery.padding.copyWith(left: 0.0, right: 0.0);
// Consume the main axis padding with SliverPadding.
effectivePadding = scrollDirection == Axis.vertical
? mediaQueryVerticalPadding
: mediaQueryHorizontalPadding;
// Leave behind the cross axis padding.
sliver = MediaQuery(
data: mediaQuery.copyWith(
padding: scrollDirection == Axis.vertical
? mediaQueryHorizontalPadding
: mediaQueryVerticalPadding,
),
child: sliver,
);
}
}
if (effectivePadding != null)
sliver = SliverPadding(padding: effectivePadding, sliver: sliver);
return <Widget>[ sliver ];
}
/// Subclasses should override this method to build the layout model.
@protected
/// 這個方法被ListView/GirdView 實現
Widget buildChildLayout(BuildContext context);
複製程式碼
可以看出來,只是多包了一層SliverPadding,最後返回的[ sliver ]也說明,其實ListView和GridView 跟CustomScrollView相比,前者是單個Sliver,後者可為多個Slivers.
ListView
在BoxScrollView的buildSlivers方法中呼叫了buildChildLayout,下面是在ListView中的實現。可以看到根據itemExtent來分別返回了SliverList和SliverFixedExtentList 2種Sliver。
@override
Widget buildChildLayout(BuildContext context) {
if (itemExtent != null) {
return SliverFixedExtentList(
delegate: childrenDelegate,
itemExtent: itemExtent,
);
}
return SliverList(delegate: childrenDelegate);
}
複製程式碼
SliverList
class SliverList extends SliverMultiBoxAdaptorWidget {
/// Creates a sliver that places box children in a linear array.
const SliverList({
Key key,
@required SliverChildDelegate delegate,
}) : super(key: key, delegate: delegate);
@override
RenderSliverList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context;
return RenderSliverList(childManager: element);
}
}
複製程式碼
RenderSliverList
Sliver佈局
RenderSliverList中的performLayout (github.com/flutter/flu…)方法是用於佈局children,在講解程式碼之前我們先看一下單個Sliver的children佈局的情況。
圖中綠色的為我們能看到的部分,黃色是快取區域,灰色為應該回收掉的部分。
//指示開始
childManager.didStartLayout();
//指示是否可以新增新的child
childManager.setDidUnderflow(false);
//constraints就是viewport給我們的佈局限制,也就是佈局輸入
//滾動位置包含cache,佈局區域開始位置
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;
//獲取到child的限制,如果是垂直滾動的列表,高度應該是無限大double.infinity
final BoxConstraints childConstraints = constraints.asBoxConstraints();
//從第一個child開始向後需要回收的孩子個數,圖中灰色部分
int leadingGarbage = 0;
//從最後一個child開始向前需要回收的孩子個數,圖中灰色部分
int trailingGarbage = 0;
//是否滾動到最後
bool reachedEnd = false;
//如果列表裡面沒有一個child,我們將嘗試加入一個,如果加入失敗,那麼整個Sliver無內容
if (firstChild == null) {
if (!addInitialChild()) {
// There are no children.
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
}
複製程式碼
- 向前計算的情況,(垂直滾動的列表)是列表想前滾動。由於灰色部分的child會被移除,所以當我們向前滾動的時候,我們需要根據現在的滾動位置來檢視是否需要在前面插入child。
// Find the last child that is at or before the scrollOffset.
RenderBox earliestUsefulChild = firstChild;
//當第一個child的layoutOffset小於我們的滾動位置的時候,說明前面是空的,如果在第一個child的簽名插入一個新的child來填充
for (double earliestScrollOffset =
childScrollOffset(earliestUsefulChild);
earliestScrollOffset > scrollOffset;
earliestScrollOffset = childScrollOffset(earliestUsefulChild)) {
// We have to add children before the earliestUsefulChild.
// 這裡就是在插入新的child
earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
//處理當前面已經沒有child的時候
if (earliestUsefulChild == null) {
final SliverMultiBoxAdaptorParentData childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
//已經到0.0的位置了,所以不需要再向前找了,break
if (scrollOffset == 0.0) {
// insertAndLayoutLeadingChild only lays out the children before
// firstChild. In this case, nothing has been laid out. We have
// to lay out firstChild manually.
firstChild.layout(childConstraints, parentUsesSize: true);
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.
// 這裡就是我們上一章講的,出現出錯了。將scrollOffsetCorrection設定為不為0,傳遞給viewport,這樣它會整體重新移除掉這個差值,重新進行layout佈局。
geometry = SliverGeometry(
scrollOffsetCorrection: -scrollOffset,
);
return;
}
}
/// 滾動的位置減掉firstChild的大小,用來繼續計算是否還需要插入更多child來補足前面。
final double firstChildScrollOffset = earliestScrollOffset - paintExtentOf(firstChild);
// firstChildScrollOffset may contain double precision error
// 同樣的道理,如果發現最終減掉之後,數值小於0.0(precisionErrorTolerance這是一個接近0.0的極小數)的話,肯定是不對的,所以又告訴viewport移除掉差值,重新佈局
if (firstChildScrollOffset < -precisionErrorTolerance) {
// The first child doesn't fit within the viewport (underflow) and
// there may be additional children above it. Find the real first child
// and then correct the scroll position so that there's room for all and
// so that the trailing edge of the original firstChild appears where it
// was before the scroll offset correction.
// TODO(hansmuller): do this work incrementally, instead of all at once,
// i.e. find a way to avoid visiting ALL of the children whose offset
// is < 0 before returning for the scroll correction.
double correction = 0.0;
while (earliestUsefulChild != null) {
assert(firstChild == earliestUsefulChild);
correction += paintExtentOf(firstChild);
earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
}
geometry = SliverGeometry(
scrollOffsetCorrection: correction - earliestScrollOffset,
);
final SliverMultiBoxAdaptorParentData childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
return;
}
// ok,這裡就是正常的情況
final SliverMultiBoxAdaptorParentData childParentData = earliestUsefulChild.parentData as SliverMultiBoxAdaptorParentData;
// 設定child繪製的開始點
childParentData.layoutOffset = firstChildScrollOffset;
assert(earliestUsefulChild == firstChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
}
複製程式碼
- advance 方法(github.com/flutter/flu…)
向後移動child,如果沒有了返回false
bool inLayoutRange = true;
RenderBox child = earliestUsefulChild;
int index = indexOf(child);
double endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
bool advance() { // returns true if we advanced, false if we have no more children
// This function is used in two different places below, to avoid code duplication.
assert(child != null);
if (child == trailingChildWithLayout)
inLayoutRange = false;
child = childAfter(child);
///不在render tree裡面
if (child == null)
inLayoutRange = false;
index += 1;
if (!inLayoutRange) {
if (child == null || indexOf(child) != index) {
// We are missing a child. Insert it (and lay it out) if possible.
//不在樹裡面,嘗試新增進去
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 as SliverMultiBoxAdaptorParentData;
//設定繪製位置
childParentData.layoutOffset = endScrollOffset;
assert(childParentData.index == index);
//設定endScrollOffset為child的繪製結束位置
endScrollOffset = childScrollOffset(child) + paintExtentOf(child);
return true;
}
複製程式碼
當向後滾動的時候,第一個child也許不是離scrollOffset最近的,所以我們需要向後找,找到這個最近的。
// Find the first child that ends after the scroll offset.
while (endScrollOffset < scrollOffset) {
//如果是小於,說明需要被回收,這裡+1記錄一下。
leadingGarbage += 1;
if (!advance()) {
assert(leadingGarbage == childCount);
assert(child == null);
//找到最後都沒有滿足的話,將以最後一個child為準
// 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;
}
}
複製程式碼
- 向後處理child直到佈局區域的結束位置。
// Now find the first child that ends after our end.
// 直到佈局區域的結束位置
while (endScrollOffset < targetEndScrollOffset) {
if (!advance()) {
reachedEnd = true;
break;
}
}
// Finally count up all the remaining children and label them as garbage.
//到上面位置是需要佈局的最後一個child,所以在它之後的child就是需要被回收的
if (child != null) {
child = childAfter(child);
while (child != null) {
trailingGarbage += 1;
child = childAfter(child);
}
}
複製程式碼
// At this point everything should be good to go, we just have to clean up
// the garbage and report the geometry.
// 使用之前計算出來的回收引數
collectGarbage(leadingGarbage, trailingGarbage);
@protected
void collectGarbage(int leadingGarbage, int trailingGarbage) {
assert(_debugAssertChildListLocked());
assert(childCount >= leadingGarbage + trailingGarbage);
invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
//從第一個向後刪除
while (leadingGarbage > 0) {
_destroyOrCacheChild(firstChild);
leadingGarbage -= 1;
}
//從最後一個向前刪除
while (trailingGarbage > 0) {
_destroyOrCacheChild(lastChild);
trailingGarbage -= 1;
}
// Ask the child manager to remove the children that are no longer being
// kept alive. (This should cause _keepAliveBucket to change, so we have
// to prepare our list ahead of time.)
_keepAliveBucket.values.where((RenderBox child) {
final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
return !childParentData.keepAlive;
}).toList().forEach(_childManager.removeChild);
assert(_keepAliveBucket.values.where((RenderBox child) {
final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
return !childParentData.keepAlive;
}).isEmpty);
});
}
void _destroyOrCacheChild(RenderBox child) {
final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
//如果child被標記為快取的話,從tree中移除並且放入快取中
if (childParentData.keepAlive) {
assert(!childParentData._keptAlive);
remove(child);
_keepAliveBucket[childParentData.index] = child;
child.parentData = childParentData;
super.adoptChild(child);
childParentData._keptAlive = true;
} else {
assert(child.parent == this);
//直接移除
_childManager.removeChild(child);
assert(child.parent == null);
}
}
複製程式碼
assert(debugAssertChildListIsNonEmptyAndContiguous());
double estimatedMaxScrollOffset;
//以及到底了,直接使用最後一個child的繪製結束位置
if (reachedEnd) {
estimatedMaxScrollOffset = endScrollOffset;
} else {
// 計算出估計最大值
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: indexOf(firstChild),
lastIndex: indexOf(lastChild),
leadingScrollOffset: childScrollOffset(firstChild),
trailingScrollOffset: endScrollOffset,
);
assert(estimatedMaxScrollOffset >= endScrollOffset - childScrollOffset(firstChild));
}
//根據remainingPaintExtent算出當前消耗了的繪製區域大小
final double paintExtent = calculatePaintOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
//根據remainingCacheExtent算出當前消耗了的快取繪製區域大小
final double cacheExtent = calculateCacheOffset(
constraints,
from: childScrollOffset(firstChild),
to: endScrollOffset,
);
//佈局區域結束位置
final double targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent;
//將輸出反饋給Viewport,viewport根據sliver的輸出,如果這個sliver已經沒有內容了,再佈局下一個
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
//是否需要clip
hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint || constraints.scrollOffset > 0.0,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
// 2者相等說明已經這個sliver的底部了
if (estimatedMaxScrollOffset == endScrollOffset)
childManager.setDidUnderflow(true);
//通知完成layout
//這裡會通過[SliverChildDelegate.didFinishLayout] 將第一個index和最後一個index傳遞出去,可以用追蹤
childManager.didFinishLayout();
複製程式碼
static double _extrapolateMaxScrollOffset(
int firstIndex,
int lastIndex,
double leadingScrollOffset,
double trailingScrollOffset,
int childCount,
) {
if (lastIndex == childCount - 1)
return trailingScrollOffset;
final int reifiedCount = lastIndex - firstIndex + 1;
//算出平均值
final double averageExtent = (trailingScrollOffset - leadingScrollOffset) / reifiedCount;
//加上剩餘估計值
final int remainingCount = childCount - lastIndex - 1;
return trailingScrollOffset + averageExtent * remainingCount;
}
複製程式碼
Sliver繪製
@override
void paint(PaintingContext context, Offset offset) {
if (firstChild == null)
return;
// offset is to the top-left corner, regardless of our axis direction.
// originOffset gives us the delta from the real origin to the origin in the axis direction.
Offset mainAxisUnit, crossAxisUnit, originOffset;
bool addExtent;
// 根據滾動的方向,來獲取主軸和橫軸的係數
switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
case AxisDirection.up:
mainAxisUnit = const Offset(0.0, -1.0);
crossAxisUnit = const Offset(1.0, 0.0);
originOffset = offset + Offset(0.0, geometry.paintExtent);
addExtent = true;
break;
case AxisDirection.right:
mainAxisUnit = const Offset(1.0, 0.0);
crossAxisUnit = const Offset(0.0, 1.0);
originOffset = offset;
addExtent = false;
break;
case AxisDirection.down:
mainAxisUnit = const Offset(0.0, 1.0);
crossAxisUnit = const Offset(1.0, 0.0);
originOffset = offset;
addExtent = false;
break;
case AxisDirection.left:
mainAxisUnit = const Offset(-1.0, 0.0);
crossAxisUnit = const Offset(0.0, 1.0);
originOffset = offset + Offset(geometry.paintExtent, 0.0);
addExtent = true;
break;
}
assert(mainAxisUnit != null);
assert(addExtent != null);
RenderBox child = firstChild;
while (child != null) {
//獲取child主軸的位置,為child的layoutOffset減去滾動位移scrollOffset
final double mainAxisDelta = childMainAxisPosition(child);
//獲取child橫軸的位置,ListView為0.0, GridView為計算出來的crossAxisOffset
final double crossAxisDelta = childCrossAxisPosition(child);
Offset childOffset = Offset(
originOffset.dx + mainAxisUnit.dx * mainAxisDelta + crossAxisUnit.dx * crossAxisDelta,
originOffset.dy + mainAxisUnit.dy * mainAxisDelta + crossAxisUnit.dy * crossAxisDelta,
);
if (addExtent)
childOffset += mainAxisUnit * paintExtentOf(child);
// If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child))
// does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden.
// 這裡可以看到因為有cache的原因,有一些child其實是不需要繪製在我們可以看到的可視區域的
if (mainAxisDelta < constraints.remainingPaintExtent && mainAxisDelta + paintExtentOf(child) > 0)
context.paintChild(child, childOffset);
child = childAfter(child);
}
}
複製程式碼
RenderSliverFixedExtentList
當ListView的itemExtent不為null的時候,使用的是RenderSliverFixedExtentList。這個我們也只簡單講一下,由於知道了child主軸的高度,再各種計算當中就更加簡單。我們可以根據scrollOffset和viewport直接算出來第一個child和最後一個child。
GridView
RenderSliverGrid
最後是我們的GridView,因為GridView的設計為child的主軸大小和橫軸大小/橫軸child個數相等(當然還跟childAspectRatio(預設為1.0)寬高比例有關係),所以說其實child主軸的大小也是已知的,而橫軸的繪製位置也很好定.基本上的計算原理也跟ListView差不多了。
舉一反三
講了一堆原始碼,不知道有多少人能看到這裡。我們通過對原始碼分析,知道了sliver列表的一些計算繪製知識。接下來我們將對官方的Sliver 列表做一些擴充套件,來滿足羞羞的效果。
圖片列表記憶體優化
經常聽到有小夥伴說圖片列表滾動幾下就閃退,這種情況在ios上面特別明顯,而在安卓上面記憶體增長的很快,其原因是Flutter預設為圖片做了記憶體快取。就是說你如果滾動列表載入了300張圖片,那麼記憶體裡面就會有300張圖片的記憶體快取,官方快取上限為1000.
列表記憶體測試
首先,我們來看看不做任何處理的情況下,圖片列表的記憶體。我在這裡做了一個圖片列表,常見的9宮格的圖片列表,增量載入child的總個數為300個,也就是說載入完畢之後可能有(1~9)*300=(300~2700)個圖片記憶體快取,當然因為官方快取為1000,最終圖片記憶體快取應該在300到1000之間(如果總的圖片大小沒有超過官方的限制)。
記憶體檢測工具
- 首先,執行
flutter packages pub global activate devtools
啟用 dart devtools - 啟用成功之後,執行
flutter --no-color packages pub global run devtools --machine --port=0
將上圖中的 127.0.0.1:9540 地址輸入到瀏覽器中。
- 接下來我們需要執行
flutter run --profile
執行起來我們的測試應用 執行完畢之後,會有一個地址,我們將這個地址copy到devtools中的Connect - 點選Connect之後,在上部切換到Memory,我們就可以看到應用的實時記憶體變化監控了
不做任何處理的測試
- 安卓,我開啟列表,一直向下拉,直到載入完畢300條,記憶體變化為下圖,可以看到記憶體起飛爆炸
- ios,我做了同樣的步驟,可惜,它最終沒有堅持到最後,600m左右閃退(跟ios應用記憶體限制有關)
上面例子很明顯看到多圖片列表對記憶體的巨大消耗,我們前面瞭解了Flutter中列表繪製整個流程,那麼我們有沒有辦法來改進一下記憶體呢? 答案是我們可以嘗試在列表children回收的時候,我們主動去清除掉那個child中包含圖片的記憶體快取。這樣記憶體中只有我們列表中少量的圖片記憶體,另一方面由於我們圖片做了硬碟快取,即使我們清除了記憶體快取,圖片重新載入的時候也不會再次下載,對於使用者來說無感知的。
圖片記憶體優化
我們前面提到過官方的collectGarbage方法,這個方法呼叫的時候將去清除掉不需要的children。那麼我們可以在這個時刻將被清除children的indexes獲取到並且通知使用者。
關鍵程式碼如下。由於我不想重寫更多的Sliver底層的類,所以我這裡是通過ExtendedListDelegate中的回撥將indexes傳遞出來。
void callCollectGarbage({
CollectGarbage collectGarbage,
int leadingGarbage,
int trailingGarbage,
int firstIndex,
int targetLastIndex,
}) {
if (collectGarbage == null) return;
List<int> garbages = [];
firstIndex ??= indexOf(firstChild);
targetLastIndex ??= indexOf(lastChild);
for (var i = leadingGarbage; i > 0; i--) {
garbages.add(firstIndex - i);
}
for (var i = 0; i < trailingGarbage; i++) {
garbages.add(targetLastIndex + i);
}
if (garbages.length != 0) {
//call collectGarbage
collectGarbage.call(garbages);
}
}
複製程式碼
當通知chilren被清除的時候,通過ImageProvider.evict方法將圖片快取從記憶體中移除掉。
SliverListConfig<TuChongItem>(
collectGarbage: (List<int> indexes) {
///collectGarbage
indexes.forEach((index) {
final item = listSourceRepository[index];
if (item.hasImage) {
item.images.forEach((image) {
final provider = ExtendedNetworkImageProvider(
image.imageUrl,
);
provider.evict();
});
}
});
},
複製程式碼
經過優化之後執行同樣的步驟,安卓記憶體變化為下
ios也差不多,表現為下
不夠極限?
從上面測試中,我們可以看到經過優化,圖片列表的記憶體得到了大大的優化,基本滿足我們的需求。但是我們做的還不夠極限,因為對於列表圖片來說,通常我們對它的圖片質量其實不是那麼高的(我又想起來了列表圖片一張8m的那個大哥)
- 使用官方的ResizeImage,它是官方最近新加的,用於減少圖片記憶體快取。你可以通過設定width/height來減少圖片,其實就是官方給你做了壓縮。用法如下
當然這種用法的前提是你已經提前知道了圖片的大小,這樣你可以對圖片進行等比壓縮。比如下面程式碼我對寬高進行了5倍縮小。注意的是,這樣做了之後,圖片的質量將會下降,如果太小了,就會糊掉。請根據自己的情況進行設定。另外一個問題是,列表圖片和點選圖片進行預覽的圖片,因為不是同一個ImageProvider了(預覽圖片一般都希望是高清的),所以會重複下載,請根據自己的情況進行取捨。
ImageProvider createResizeImage() {
return ResizeImage(ExtendedNetworkImageProvider(imageUrl),
width: width ~/ 5, height: height ~/ 5);
}
複製程式碼
- 在繼承ExtendedNetworkImageProvider(當然extended的其他provider也通過這樣方法來壓縮圖片), override instantiateImageCodec方法,這裡對圖片進行壓縮。 程式碼位置
///override this method, so that you can handle raw image data,
///for example, compress
Future<ui.Codec> instantiateImageCodec(
Uint8List data, DecoderCallback decode) async {
_rawImageData = data;
return await decode(data);
}
複製程式碼
- 在做了這些優化之後,我們再次進行測試,下面試記憶體變化情況,記憶體消耗再次被降低。
支援我的PR
如果方案對你有用,請支援一下我對collectGarbage的PR.
add collectGarbage method for SliverChildDelegate to track which children can be garbage collected
這樣可以讓更多人解決掉圖片列表記憶體的問題。當然你也可以直接使用 ExtendedList WaterfallFlow 和 LoadingMoreList 它們都支援這個api。整個完整的解決方案我已經提交到了ExtendedImage的demo當中,方便檢視整個流程。
列表曝光追蹤
簡單的說,就是我們怎麼方便地知道在可視區域中的children呢?從列表的計算繪製過程中,其實我們是能夠輕易獲取到可視區域中children的indexes的。我這裡提供了ViewportBuilder回撥來獲取可視區域中第一個index和最後一個index。 程式碼位置
同樣是通過ExtendedListDelegate,在viewportBuilder中回撥。
使用演示
ExtendedListView.builder(
extendedListDelegate: ExtendedListDelegate(
viewportBuilder: (int firstIndex, int lastIndex) {
print("viewport : [$firstIndex,$lastIndex]");
}),
複製程式碼
特殊化最後一個child的佈局
我們在入門Flutter的時候,做增量載入列表的時候,看到的例子就是把最後一個child作為loadmore/no more。ListView如果滿螢幕的時候沒有什麼問題,但是下面情況需要解決。
- ListView未滿屏的時候,最後一個child展示 ‘沒有更多’。 通常是希望‘沒有更多’ 是放在最下面進行顯示,但是因為它是最後一個child,它會緊挨著倒數第2個。
- GridView 最後一個child作為loadmore/no more的時候。產品不希望它們當作普通的GridView元素來進行佈局
為了解決這個問題,我設計了lastChildLayoutTypeBuilder。通過使用者告訴的最後一個child的型別,來佈局最後一個child,下面以RenderSliverList為例子。
if (reachedEnd) {
///zmt
final layoutType = extendedListDelegate?.lastChildLayoutTypeBuilder
?.call(indexOf(lastChild)) ??
LastChildLayoutType.none;
// 最後一個child的大小
final size = paintExtentOf(lastChild);
// 最後一個child 繪製的結束位置
final trailingLayoutOffset = childScrollOffset(lastChild) + size;
//如果最後一個child繪製的結束位置小於了剩餘繪製大小,那麼我們將最後一個child的位置改為constraints.remainingPaintExtent - size
if (layoutType == LastChildLayoutType.foot &&
trailingLayoutOffset < constraints.remainingPaintExtent) {
final SliverMultiBoxAdaptorParentData childParentData =
lastChild.parentData;
childParentData.layoutOffset = constraints.remainingPaintExtent - size;
endScrollOffset = constraints.remainingPaintExtent;
}
estimatedMaxScrollOffset = endScrollOffset;
}
複製程式碼
最後我們看看怎麼使用。
enum LastChildLayoutType {
/// 普通的
none,
/// 將最後一個元素繪製在最大主軸Item之後,並且使用橫軸大小最為layout size
/// 主要使用在[ExtendedGridView] and [WaterfallFlow]中,最後一個元素作為loadmore/no more元素的時候。
fullCrossAxisExtend,
/// 將最後一個child繪製在trailing of viewport,並且使用橫軸大小最為layout size
/// 這種常用於最後一個元素作為loadmore/no more元素,並且列表元素沒有充滿整個viewport的時候
/// 如果列表元素充滿viewport,那麼效果跟fullCrossAxisExtend一樣
foot,
}
ExtendedListView.builder(
extendedListDelegate: ExtendedListDelegate(
// 列表的總長度應該是 length + 1
lastChildLayoutTypeBuilder: (index) => index == length
? LastChildLayoutType.foot
: LastChildLayoutType.none,
),
複製程式碼
簡單的聊天列表
我們在做一個聊天列表的時候,因為佈局是從上向下的,我們第一反應肯定是將 ListView的reverse設定為true,當有新的會話會被插入0的位置,這樣設定是最簡單,但是當會話沒有充滿viewport的時候,因為佈局被翻轉,所以佈局會像下面這樣。
trailing
-----------------
| |
| |
| item2 |
| item1 |
| item0 |
-----------------
leading
複製程式碼
為了解決這個問題,你可以設定 closeToTrailing 為true, 佈局將變成如下 該屬性同時支援[ExtendedGridView],[ExtendedList],[WaterfallFlow]。 當然如果reverse如果不為ture,你設定這個屬性依然會生效,沒滿viewport的時候佈局會緊靠trailing。
trailing
-----------------
| item2 |
| item1 |
| item0 |
| |
| |
-----------------
leading
複製程式碼
那是如何是現實的呢?為此我增加了2個擴充套件方法
如果最後一個child的繪製結束位置沒有剩餘繪製區域大(也就是children未填充滿viewport),那麼我們給每一個child的繪製起點增加constraints.remainingPaintExtent - endScrollOffset的距離,那麼現象就會是全部children是緊靠trailing佈局的。這個方法為整體計算佈局之後呼叫。
/// handle closeToTrailing at end
double handleCloseToTrailingEnd(
bool closeToTrailing, double endScrollOffset) {
if (closeToTrailing && endScrollOffset < constraints.remainingPaintExtent) {
RenderBox child = firstChild;
final distance = constraints.remainingPaintExtent - endScrollOffset;
while (child != null) {
final SliverMultiBoxAdaptorParentData childParentData =
child.parentData;
childParentData.layoutOffset += distance;
child = childAfter(child);
}
return constraints.remainingPaintExtent;
}
return endScrollOffset;
}
複製程式碼
因為我們給每個child的繪製起點增加了constraints.remainingPaintExtent - endScrollOffset的距離。再下一次performLayout的時候,我們應該先移除掉這部分的距離。當第一個child的index為0 並且layoutOffset不為0,我們需要將全部的children的layoutOffset做移除。
/// handle closeToTrailing at begin
void handleCloseToTrailingBegin(bool closeToTrailing) {
if (closeToTrailing) {
RenderBox child = firstChild;
SliverMultiBoxAdaptorParentData childParentData = child.parentData;
// 全部移除掉前一次performLayout增加的距離
if (childParentData.index == 0 && childParentData.layoutOffset != 0) {
var distance = childParentData.layoutOffset;
while (child != null) {
childParentData = child.parentData;
childParentData.layoutOffset -= distance;
child = childAfter(child);
}
}
}
}
複製程式碼
最後我們看看怎麼使用。
ExtendedListView.builder(
reverse: true,
extendedListDelegate: ExtendedListDelegate(closeToTrailing: true),
複製程式碼
結語
這一章我們通過對sliver 列表的原始碼進行分析,舉一反四,解決了實際開發中的一些問題。下一章我們將創造自己的瀑布流佈局,你也能有建立任意sliver佈局列表的能力。
歡迎加入Flutter Candies,一起生產可愛的Flutter小糖果( QQ群:181398081)
最最後放上Flutter Candies全家桶,真香。