Flutter ListView 源碼分析

前言

不得不說,Flutter 繪製 UI 的速度和原生根本不是一個量級的,Flutter 要快的多了,好比經常使用的 ListView 控件,原生寫的話,好比 Android,若是不封裝的話,須要一個 AdapterViewHolder,再加個 xml 佈局文件,而 Flutter 可能就幾十行。緩存

對於越經常使用的控件,越要熟悉它的原理。Flutter 中的 ScrollView 家族,成員的劃分其實和 Android 仍是很是相似的,除了 ListViewGridView,還有 CustomScrollViewNestedScrollView。今天咱們要講主角就是 ListViewless

ListView

ListViewGridView 都繼承於 BoxScrollView,但這裏並非繪製和佈局的地方,Flutter 和原生不太同樣,以 Android 爲例,Android 上繪製和佈局的單位是 ViewViewGroup,Flutter 則要複雜一點,首先咱們用的最多的是各類 Widget,好比 ListView,但 Widget 能夠理解爲一個配置描述文件,好比如下代碼:ide

Container {
  width: 100,
  height: 100,
  color: Colors.white,
}
複製代碼

這裏描述了咱們須要一個寬高爲 100,顏色爲白色的容器,最後真正去繪製的是 RenderObject。而在 WidgetRenderObject 之間還有個 Element,它的職責是,將咱們配置的 Widget Tree 轉換成 Element Tree,Element 是對 Widget 的進一步抽象,Element 有兩個子類,一個是 RenderObjectElement,它持有 RenderObject,還有一個 ComponentElement ,用於組合多個 RenderObjectElement。這個是 Flutter UI 的核心,要理解好這三個類。佈局

回到咱們的主題上來,咱們前面說到 ListView 繼承於 BoxScrollView,而 BoxScrollView 又繼承於 ScrollViewScrollView 是一個 StatelessWidget,它依靠 Scrollable 實現滾動效果,而滾動容器中的 Widget,稱爲 slivers。sliver 用於消費滾動事件。ui

@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 抽象方法建立。this

@override                                                                         
List<Widget> buildSlivers(BuildContext context) {
  // buildChildLayout
  Widget sliver = buildChildLayout(context);                                      
  EdgeInsetsGeometry effectivePadding = padding;                                  
  // 省略 
  return <Widget>[ sliver ];                                                      
}                                                                                 
複製代碼

最後咱們的 ListView 就實現了 buildChildLayout()spa

@override                                        
Widget buildChildLayout(BuildContext context) {  
  if (itemExtent != null) { 
    // 若是子項是固定高度的
    return SliverFixedExtentList(                
      delegate: childrenDelegate,                
      itemExtent: itemExtent,                    
    );                                           
  }
  // 默認狀況
  return SliverList(delegate: childrenDelegate); 
}                                                
複製代碼

SliverList 是一個 RenderObjectWidget,上面咱們也說到了,最終繪製和佈局都是交與 RenderObject 去實現的。ListView 也不例外:code

@override                                                     
RenderSliverList createRenderObject(BuildContext context) {   
  final SliverMultiBoxAdaptorElement element = context;       
  return RenderSliverList(childManager: element);             
}                                                             
複製代碼

RenderSliverListListView 的核心實現,也是咱們本文的重點。orm

RenderSliverList

有過 Android 自定義控件經驗的同窗會知道,當咱們自定義一個控件時,通常會涉及這幾個步驟:measure 和 draw,若是是自定義 ViewGroup 還會涉及 layout 過程,Flutter 也不例外,但它將 measure 和 layout 合併到 layout,draw 稱爲 paint。雖然叫法不同,但做用是同樣的。系統會調用 performLayout() 會執行測量和佈局,RenderSliverList 主要涉及佈局操做,因此咱們主要看下這個方法便可。cdn

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 可以使用的偏移量,這裏包含了預佈局的區域。這裏咱們用一張很是粗糙的圖片來解釋下。

flutter_listview_1

C 區域表示咱們的屏幕,這裏咱們認爲是可見區域,實際狀況下,可能還要更小,由於 ListView 可能有些 paddingmagin 或者其餘佈局等。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 之間有空間,沒有填充。畫個簡單的圖形。

flutter_listview_2

這塊區域就是 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 的佈局流程後,能夠發現整個流程仍是比較清晰的。

  1. 須要佈局的區域包括可見區域和緩存區域
  2. 在佈局區域之外的進行回收
相關文章
相關標籤/搜索