Flutter 中的 ListView 的一個容易忽略的知識點

郭巨集偉發表於2020-07-03

1. 前言

上一篇文章 Flutter渲染之通過demo瞭解Key的作用裡面通過兩個小 demo 講了 Key 的作用。其實在寫小 demo 的過程中也碰到一個問題,開始是沒有想清楚的,後來跟同事交流,去原始碼裡面翻了翻才找到一些原因,這裡再來寫一下。

2. 現象

在 demo2--交換兩個小部件 開始提到,如果兩個widget 是繼承的 StatelessWidget,結果是可成功交換的,完整程式碼如下。

class KeyDemo1Page extends StatefulWidget {
  @override
  _KeyDemo1PageState createState() => _KeyDemo1PageState();
}

class _KeyDemo1PageState extends State<KeyDemo1Page> {

  List<Widget> tiles = [
    StatelessColorfulTile(),
    StatelessColorfulTile(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.blue,
        centerTitle: true,
        title: Text("Key demo"),
      ),
      body: Column(children: tiles,),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.delete),
        onPressed: (){
          setState(() {
            tiles.insert(1, tiles.removeAt(0));
          });
        },
      ),
    );
  }
}

class StatelessColorfulTile extends StatelessWidget {
  Color myColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

  @override
  Widget build(BuildContext context) {
    print("build hahahahahha");
    return Container(
        color: myColor,
        child: Padding(padding: EdgeInsets.all(70.0))
    );
  }
}
複製程式碼

如果將 body 裡面的 Column 換為 ListView,tiles 賦值給 ListView 的 children,那麼就不行了。點選按鈕是沒有切換的。這就很奇怪了,同樣是容器啊,為啥一個可以一個不可以。為此我試了多種情況。

如果將點選按鈕交換 tiles 陣列元素改為刪除一個元素,介面也還是沒有反應; 如果將交換元素改為新建立一個陣列,將 tiles 元素插入到新陣列裡面,然後讓tiles 指向新陣列,這樣結果就是好的。

也就是說改變 tiles 陣列裡面元素的位置或者增減元素介面都不會有反應,只有改變 tiles 指向介面才可以切換。

將 ListView(children:tiles) 改為 ListView.builder 的形式是可以的,如下程式碼。

class _KeyDemo1PageState extends State<KeyDemo1Page> {
  ......

  Widget _itemForRow(BuildContext context, int index) {
    return tiles[index];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ......
      body: ListView.builder(
        itemCount: tiles.length,
        itemBuilder: _itemForRow,
      ),
      ......
    );
  }
}
複製程式碼

3. 原因

使用 ListView 和 ListView.builder 是有區別的,因此可以對比著去原始碼裡面看看。

ListView 的構造方法如下

istView({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    this.itemExtent,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
  }) : childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? children.length,
         dragStartBehavior: dragStartBehavior,
       );
複製程式碼

children 也賦值到 SliverChildListDelegate 裡面去了,因此也去它裡面看看,在 SliverChildListDelegate 裡面發現了一個重要的方法,如下

@override
  bool shouldRebuild(covariant SliverChildListDelegate oldDelegate) {
    return children != oldDelegate.children;
  }
複製程式碼

發現了,如果 ListView 的孩子陣列沒有改變的話 shouldRebuild 就會返回 false,也就不會執行 build 方法了,因此介面也不會有什麼變化。可以想象 ListView.builder 裡面也有這種方法,不過返回結果應該不一樣,去看看吧。

ListView.builder 的建構函式這樣的

ListView.builder({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    this.itemExtent,
    @required IndexedWidgetBuilder itemBuilder,
    int itemCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
  }) : assert(itemCount == null || itemCount >= 0),
       assert(semanticChildCount == null || semanticChildCount <= itemCount),
       childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? itemCount,
         dragStartBehavior: dragStartBehavior,
       );
複製程式碼

我們去 SliverChildBuilderDelegate 裡面看看,發現也有一個 shouldRebuild 方法,程式碼如下。

@override
  bool shouldRebuild(covariant SliverChildBuilderDelegate oldDelegate) => true;
複製程式碼

因此可以看出使用 ListView.builder 的話 shouldRebuild 預設返回 true,因此這種方法build 都會執行,所以介面是可以切換的。

4. 總結

執行 setState 的後,ListView 的 children 裡面的元素改變,包括移動和增刪,介面是不會改變的,只能是改變整個 children 指向。使用 ListView.builder 則會改變。

從開始發現問題到嘗試了各種現象發現一些基本規律,然後去原始碼裡面找原因,基本可以搞清楚問題所在,不過還是有一些技術細節需要更深入一層理解了。

相關文章