Flutter ListView 原始碼分析

奮鬥的Leo發表於2019-06-17

前言

不得不說,Flutter 繪製 UI 的速度和原生根本不是一個量級的,Flutter 要快的多了,比如常用的 ListView 控制元件,原生寫的話,比如 Android,如果不封裝的話,需要一個 AdapterViewHolder,再加個 xml 佈局檔案,而 Flutter 可能就幾十行。

對於越常用的控制元件,越要熟悉它的原理。Flutter 中的 ScrollView 家族,成員的劃分其實和 Android 還是非常類似的,除了 ListViewGridView,還有 CustomScrollViewNestedScrollView。今天我們要講主角就是 ListView

ListView

ListViewGridView 都繼承於 BoxScrollView,但這裡並不是繪製和佈局的地方,Flutter 和原生不太一樣,以 Android 為例,Android 上繪製和佈局的單位是 ViewViewGroup,Flutter 則要複雜一點,首先我們用的最多的是各種 Widget,比如 ListView,但 Widget 可以理解為一個配置描述檔案,比如以下程式碼:

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 用於消費滾動事件。

@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);             
}                                                             
複製程式碼

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

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

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. 在佈局區域以外的進行回收

相關文章