Flutter渲染之通過demo瞭解Key的作用

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

1. 前言

之前看很多類的建構函式裡面有一個可選引數 key,平時寫程式碼過程中也沒怎麼用就忽視了,後來遇到一些問題才發現這個小小的 key 可是有大作用,因此決定好好研究下。看了一些文章都是分析原始碼,寫得不明不白,晦澀難懂,因此決定通過兩個實際的小 demo 來講述。看本文之前最好先看看 《Flutter渲染之Widget、Element 和 RenderObject》,這個是基礎。

2. demo1--刪除列表第一個cell

需求

一個列表裡面有多行cell,點選按鈕,每次刪除第一個cell。

實現過程

這個需求可以說是非常簡單,不多說,先搞一個表和按鈕出來,根據以前的程式設計經驗,將資料來源裡面第一個元素刪掉,然後 reloadData 即可,小菜一碟。

class KeyDemo2Page extends StatefulWidget {
  @override
  _KeyDemo2PageState createState() => _KeyDemo2PageState();
}

class _KeyDemo2PageState extends State<KeyDemo2Page> {
  final List<String> _names = ["1", "2", "3", "4", "5", "6", "7"];

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

class KeyItemLessWidget extends StatelessWidget {
  final String name;
  final randColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));
  KeyItemLessWidget(this.name);

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(name, style: TextStyle(color: Colors.white, fontSize: 50),),
      height: 80,
      color: randColor,
    );
  }
}
複製程式碼

我們的cell使用的是 StatelessWidget,裡面隨機建立了一個顏色,出來的介面如下所示。

Flutter渲染之通過demo瞭解Key的作用
image.png

介面確實如我們想象的一樣,點選按鈕才發現,結果完全不是我們想象的那個樣子,每次點選按鈕,cell 的個數確實減少了,但是 cell 的顏色都跟以前不一樣了。

如果看過我之前寫的那篇文章《Flutter渲染之Widget、Element 和 RenderObject》的話就很好理解了。點選按鈕的時候會執行 setState, 然後會重新執行 build 方法,包括每個 cell 都會重新 build。而每個cell生成隨機顏色的程式碼就在 build 裡面,因此這裡每次點選按鈕都會重新改變 cell 顏色。

因此,為了解決每次點選按鈕cell 顏色都改變的問題,需要將 cell 由 StatelessWidget 改為 StatefullWidget。將顏色儲存在 State 裡面,這樣應該就不會改變了。cell 程式碼如下。

class KeyItemLessWidget extends StatefulWidget {
  final String name;

  KeyItemLessWidget(this.name);

  @override
  _KeyItemLessWidgetState createState() => _KeyItemLessWidgetState();
}

class _KeyItemLessWidgetState extends State<KeyItemLessWidget> {
  final randColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(widget.name, style: TextStyle(color: Colors.white, fontSize: 50),),
      height: 80,
      color: randColor,
    );
  }
}
複製程式碼

結果跟我們預想的一致,cell 的顏色確實保持住了,cell 個數也減一了。但是這裡又有問題了,點選按鈕我們希望是刪除第一個cell,也就是第一個資料,但是這裡實際每次卻是從頭部開始刪除數字,但是介面上刪除的顏色卻是從尾部刪除的。這就很奇怪了,還是不滿足我們的需求。

通過前面那篇文章瞭解了 Element 的作用以後也可以理解這個現象。cell 建立以後都會建立相應的 Element,Element 裡面有對 Widget 和 State 的引用,顏色儲存在 State 裡面。每次點選按鈕執行 setState,cell 重新建立,通過 canUpdate 方法判斷 Element 是否要重建還是直接替換 Widget,canUpdate 方法如下

  /// Whether the `newWidget` can be used to update an [Element] that currently
  /// has the `oldWidget` as its configuration.
  ///
  /// An element that uses a given widget as its configuration can be updated to
  /// use another widget as its configuration if, and only if, the two widgets
  /// have [runtimeType] and [key] properties that are [operator==].
  ///
  /// If the widgets have no key (their key is null), then they are considered a
  /// match if they have the same type, even if their children are completely
  /// different.
  static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }
複製程式碼

通過原始碼可以看出是通過比對型別和 key 來判斷的。我們這裡沒有 key,型別相同,通過註釋可以知道這種情況返回 true。因此只需要用新 widget 替代 Element 裡面的老 Widget 即可。但是 Element 裡面的 State 還在,我們之前的顏色還儲存在裡面,新建立的 Widget 還是使用的之前的 State,執行的也是這個 State 裡面的 build 方法, 因此頭部的顏色並沒有刪除,尾部那個 Element 發現其他 Element 都有 Widget 更新,自己是多餘的,因此 Flutter 因此會將其 unmount。因此我們看到的現象就是數字是刪除的頭部,但是顏色卻是刪除的尾部。

為了解決因為 Element 複用導致的刪除尾部顏色問題,我們這裡需要讓 canUpdate 返回 false,這樣每次 Element 都會重新建立。因此就要使用 Key 了,程式碼如下。

class KeyDemo2Page extends StatefulWidget {
  @override
  _KeyDemo2PageState createState() => _KeyDemo2PageState();
}

class _KeyDemo2PageState extends State<KeyDemo2Page> {
  final List<String> _names = ["1", "2", "3", "4", "5", "6", "7"];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.blue,
        centerTitle: true,
        title: Text("Key demo"),
      ),
      body: ListView(
        children: _names.map((item) {
          return KeyItemLessWidget(item, key: Key(item),);
        }).toList(),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.delete),
        onPressed: (){
          setState(() {
            _names.removeAt(0);
          });
        },
      ),
    );
  }
}

class KeyItemLessWidget extends StatefulWidget {
  final String name;

  KeyItemLessWidget(this.name, {Key key}): super(key: key);

  @override
  _KeyItemLessWidgetState createState() => _KeyItemLessWidgetState();
}

class _KeyItemLessWidgetState extends State<KeyItemLessWidget> {
  final randColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(widget.name, style: TextStyle(color: Colors.white, fontSize: 50),),
      height: 80,
      color: randColor,
    );
  }
}
複製程式碼

這裡注意幾點

  1. 在 KeyItemLessWidget 加了構造方法
KeyItemLessWidget(this.name, {Key key}): super(key: key);
複製程式碼
  1. 在建立物件的時候傳入 key
return KeyItemLessWidget(item, key: Key(item),);
複製程式碼

現在為止才是實現了最終的需求,跟原生開發的思路非常不同,過程值得好好理解,最後關鍵還是靠 key 出馬才解決問題。

3. demo2--交換兩個小部件

需求

點選按鈕,交換兩個 widget 的位置。

實現過程

實現思路,交換資料來源資料位置,執行 setState 即可,程式碼如下

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

class _KeyDemo1PageState extends State<KeyDemo1Page> {

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

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

  @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) {
    return Container(
        color: myColor,
        child: Padding(padding: EdgeInsets.all(70.0))
    );
  }
}
複製程式碼

介面如下

Flutter渲染之通過demo瞭解Key的作用

這裡widget 我們使用的是 StatelessWidget,結果跟我們想要的一樣,點選按鈕兩個widget 成功交換位置。這裡我們做一點改變,將方塊改為 StatefulWidget 再來看看。程式碼如下。

class StatelessColorfulTile extends StatefulWidget {
  @override
  _StatelessColorfulTileState createState() => _StatelessColorfulTileState();
}

class _StatelessColorfulTileState extends State<StatelessColorfulTile> {
  Color myColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

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

結果就是點選按鈕毫無反應。這是怎麼回事?

還是那個道理,StatefulWidget 的 State 在 Element 裡面,雖然 Widget 確實換了了位置,但是 Element 通過canUpdate 方法判斷返回 true,因此 Element 只是將自己的 Element 引用進行了替換而已。重新執行之前 State 的build 方法,顏色也還是以前的顏色,因此看上去就是沒有任何變化。

為了實現切換的目的,這時候又要使用 Key 了,完整程式碼如下。

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

class _KeyDemo1PageState extends State<KeyDemo1Page> {
  List<StatelessColorfulTile> tiles = [
    StatelessColorfulTile(key: UniqueKey(),),
    StatelessColorfulTile(key: UniqueKey(),),
  ];

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

class StatelessColorfulTile extends StatefulWidget {
  StatelessColorfulTile({Key key}): super(key: key);

  @override
  _StatelessColorfulTileState createState() => _StatelessColorfulTileState();
}

class _StatelessColorfulTileState extends State<StatelessColorfulTile> {
  Color myColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

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

這裡使用了 UniqueKey(),作用後面分析。結果成功切換,實現我們提出的需求。

4. Key 的分類

Flutter渲染之通過demo瞭解Key的作用

key 的整合關係如上圖,key 本身是一個抽象類。key 的定義如下

abstract class Key {
  /// Construct a [ValueKey<String>] with the given [String].
  ///
  /// This is the simplest way to create keys.
  const factory Key(String value) = ValueKey<String>;

  /// Default constructor, used by subclasses.
  ///
  /// Useful so that subclasses can call us, because the [new Key] factory
  /// constructor shadows the implicit constructor.
  @protected
  const Key.empty();
}
複製程式碼

它有一個工廠構造器,建立出來一個 ValueKey,Key 直接子類主要有: LocalKey 和 GlobalKey。

4.1 LocakKey

它應用於具有相同父 Element 的 Widget 進行比較,也是 diff 演算法的核心所在。他有三個子類:ValueKey、ObjectKey、UniqueKey。

• ValueKey:當我們以特定的值作為 key 時使用,比如一個字串、數字等; • ObjectKey:如果兩個學生,他們的名字一樣沒使用 name 作為他們的 key 就不合適了,我們可以建立出一個學生物件,使用物件來作為 key。 • UniqueKey:我們要確保 key 的唯一性,可以使用 UniqueKey。如果希望強制重新整理,每次 Element 都重新建立,那麼可以使用 UniqueKey。

4.2 GlobalKey

GlobalKey 使用了一個靜態常量 Map 來儲存它對於的 Element,可以通過 GlobalKey 找到持有該 GlobalKey 的 Widget、State 和 Element。如下圖自動提示資訊。

Flutter渲染之通過demo瞭解Key的作用

比如上面 demo1 裡面,我們可以使用 GlobalKey,在點選按鈕的時候,列印出每個 cell 的name 屬性。完整程式碼如下

class KeyDemo2Page extends StatefulWidget {
  @override
  _KeyDemo2PageState createState() => _KeyDemo2PageState();
}

class _KeyDemo2PageState extends State<KeyDemo2Page> {
  final List<String> _names = ["1", "2", "3", "4", "5", "6", "7"];
  final GlobalKey<_KeyItemLessWidgetState> globalKeyTest = GlobalKey();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.blue,
        centerTitle: true,
        title: Text("Key demo"),
      ),
      body: KeyItemLessWidget("哈囉出行", key: globalKeyTest,),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.delete),
        onPressed: (){
          print(globalKeyTest.currentState.widget.name);
        },
      ),
    );
  }
}

class KeyItemLessWidget extends StatefulWidget {
  final String name;
  KeyItemLessWidget(this.name, {Key key}): super(key: key);

  @override
  _KeyItemLessWidgetState createState() => _KeyItemLessWidgetState();
}

class _KeyItemLessWidgetState extends State<KeyItemLessWidget> {
  final randColor = Color.fromARGB(255, Random().nextInt(256), Random().nextInt(256), Random().nextInt(256));

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(widget.name, style: TextStyle(color: Colors.white, fontSize: 50),),
      height: 80,
      color: randColor,
    );
  }
}
複製程式碼

注意:GlobalKey 是非常昂貴的,需要謹慎使用。

5. 總結

本文通過兩個簡單的 demo 引入了key,可以瞭解 key 的重要作用,需要深入瞭解 key 還需要看之前那篇文章 《Flutter渲染之Widget、Element 和 RenderObject》,首先知道 Widget、Element 和 RenderObject 的聯絡,這個是基礎。體會到了 Key 的作用以後然後從程式碼層面對 Key 進行分類,便於系統掌握,最後還是一個小 demo 瞭解 GlobalKey 的作用。總的來說本文沒有講太多原始碼相關內容,儘量通過實際需求按理來一步一步講解,很容易明白。

demo2--交換兩個小部件 裡面有個問題另外寫了一篇文章可以看看 Flutter 中的 ListView 的一個容易忽略的知識點

相關文章