說說Flutter中最熟悉的陌生人 —— Key

在這裏插入圖片描述

Key在Flutter的源碼中可以說是無處不在,但是我們日常中確不怎麼使用它。有點像是“最熟悉的陌生人”,那麼今天就來說說這個“陌生人”,揭開它神祕的面紗。

概念

KeyWidgetElementSemanticsNode的標識符。 只有當新的WidgetKey與當前ElementWidgetKey相同時,它纔會被用來更新現有的ElementKey在具有相同父級的Element之間必須是唯一的。

以上定義是源碼中關於Key的解釋。通俗的說就是Widget的標識,幫助實現Element的複用。關於它的說明源碼中也提供了YouTube的視頻鏈接:When to Use Keys。如果你無法訪問,可以看Google 官方在優酷上傳的

When to Use Keys

例子

視頻中的例子很簡單且具有代表性,所以本文將採用它來介紹今天的內容。

首先上代碼:

import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  List<Widget> widgets;

  @override
  void initState() {
    super.initState();
    widgets = [
      StatelessColorfulTile(),
      StatelessColorfulTile()
    ];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Row(
        children: widgets,
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.refresh),
        onPressed: _swapTile,
      ),
    );
  }

  _swapTile() {
    setState(() {
      widgets.insert(1, widgets.removeAt(0));
    });
  }
}

class StatelessColorfulTile extends StatelessWidget {

  final Color _color = Utils.randomColor();

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 150,
      width: 150,
      color: _color,
    );
  }
}

class Utils {
  static Color randomColor() {
    var red = Random.secure().nextInt(255);
    var greed = Random.secure().nextInt(255);
    var blue = Random.secure().nextInt(255);
    return Color.fromARGB(255, red, greed, blue);
  }
}

代碼可以直接複製到DartPad中運行查看效果。 或者點擊這裏直接運行

效果很簡單,就是兩個彩色方塊,點擊右下角的按鈕後交換兩個方塊的位置。這裏我就不放具體的效果圖了。實際效果也和我們預期的一樣,兩個方塊成功交換位置。

發現問題

上面的方塊是StatelessWidget,那我們把它換成StatefulWidget呢?。

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

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

class StatefulColorfulTileState extends State<StatefulColorfulTile> {
  final Color _color = Utils.randomColor();

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 150,
      width: 150,
      color: _color,
    );
  }
}

再次執行代碼,發現方塊沒有“交換”。這是爲什麼?

在這裏插入圖片描述

分析問題

首先要知道Flutter中有三棵樹,分別是Widget TreeElement TreeRenderObject Tree

  • Widget: Element配置信息。與Element的關係可以是一對多,一份配置可以創造多個Element實例。
  • Element:Widget 的實例化,內部持有WidgetRenderObject
  • RenderObject:負責渲染繪製

簡單的比擬一下,Widget有點像是產品經理,規劃產品整理需求。Element則是UI小姐姐,根據原型整理出最終設計圖。RenderObject就是我們程序員,負責具體的落地實現。

代碼中可以確定一點,兩個方塊的Widget肯定是交換了。既然Widget沒有問題,那就看看Element

但是爲什麼StatelessWidget可以成功,換成StatefulWidget就失效了?

點擊按鈕調用setState方法,依次執行:

標記自身元素dirty爲true
添加至_dirtyElements
_element.markNeedsBuild()
owner.scheduleBuildFor()
drawFrame()
buildScope()
_dirtyElements[index].rebuild()
performRebuild()
updateChild()

我們重點看一下ElementupdateChild方法:

  @protected
  Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
  	// 如果'newWidget'爲null,而'child'不爲null,那麼我們刪除'child',返回null。
    if (newWidget == null) {
      if (child != null)
        deactivateChild(child);
      return null;
    }
    if (child != null) {
      // 兩個widget相同,位置不同更新位置,返回child。這裏比較的是hashCode
      if (child.widget == newWidget) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        return child;
      }
      // 我們的交換例子處理在這裏
      if (Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        child.update(newWidget);
        return child;
      }
      deactivateChild(child);
    }
    // 如果無法更新複用,那麼創建一個新的Element並返回。
    return inflateWidget(newWidget, newSlot);
  }

WidgetcanUpdate方法:

  static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }

這裏出現了我們今天的主角Key,不過我們先放在一邊。canUpdate方法的作用是判斷newWidget是否可以替代oldWidget作爲Element的配置。 一開始也提到了,Element會持有Widget。

該方法判斷的依據就是runtimeTypekey是否相等。在我們上面的例子中,不管是StatelessWidget還是StatefulWidget的方塊,顯然canUpdate都會返回true。因此執行child.update(newWidget)方法,就是將持有的Widget更新了。

不知道這裏大家有沒有注意到,這裏並沒有更新state。我們看一下StatefulWidget源碼:

abstract class StatefulWidget extends Widget {

  const StatefulWidget({ Key key }) : super(key: key);
  
  @override
  StatefulElement createElement() => StatefulElement(this);

  @protected
  State createState();
}

StatefulWidget中創建的是StatefulElement,它是Element的子類。

class StatefulElement extends ComponentElement {

  StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
        super(widget) {
    _state._element = this;  
    _state._widget = widget;
  }

  @override
  Widget build() => state.build(this);

  State<StatefulWidget> get state => _state;
  State<StatefulWidget> _state;
  ...
}

通過調用StatefulWidgetcreateElement方法,最終執行createState創建出state並持有。也就是說StatefulElement才持有state。

所以我們上面兩個StatefulWidget的方塊的交換,實際只是交換了“身體”,而“靈魂”沒有交換。所以不管你怎麼點擊按鈕都是沒有變化的。

解決問題

找到了原因,那麼怎麼解決它?那就是設置一個不同的Key

  @override
  void initState() {
    super.initState();
    widgets = [
      StatefulColorfulTile(key: const Key("1")),
      StatefulColorfulTile(key: const Key("2"))
    ];
  }

但是這裏要注意的是,這裏不是說添加key以後,在canUpdate方法返回false,最後執行inflateWidget(newWidget, newSlot)方法創建新的Element。(很多相關文章對於此處的說明都有誤區。。。好吧我承認我一開始也被誤導了。。。)

  @protected
  Element inflateWidget(Widget newWidget, dynamic newSlot) {
    final Key key = newWidget.key;
    if (key is GlobalKey) {
      final Element newChild = _retakeInactiveElement(key, newWidget);
      if (newChild != null) {
        newChild._activateWithParent(this, newSlot);
        final Element updatedChild = updateChild(newChild, newWidget, newSlot);
        assert(newChild == updatedChild);
        return updatedChild;
      }
    }
    // 這裏就調用到了createElement,重新創建了Element
    final Element newChild = newWidget.createElement();
    newChild.mount(this, newSlot);
    return newChild;
  }

如果如此,那麼執行createElement方法勢必會重新創建state,那麼方塊的顏色也就隨機變了。當然此種情況並不是不存在,比如我們給現有的方塊外包一層PaddingSingleChildRenderObjectElement):

  @override
  void initState() {
    super.initState();
    widgets = [
      Padding(
        padding: const EdgeInsets.all(8.0),
        child: StatefulColorfulTile(key: Key("1"),)
      ),
      Padding(
        padding: const EdgeInsets.all(8.0),
        child: StatefulColorfulTile(key: Key("2"),)
      ),
    ];
  }

這種情況下,交換後比較外層Padding不變,接着比較內層StatefulColorfulTile,因爲key不相同導致顏色隨機改變。因爲兩個方塊位於不同子樹,兩者在逐層對比中用到的就是canUpdate方法返回false來更改。

而本例是方塊的外層是RowMultiChildRenderObjectElement),是對比兩個List,存在不同。關鍵在於update時調用的RenderObjectElement.updateChildren方法。

  @protected
  List<Element> updateChildren(List<Element> oldChildren, List<Widget> newWidgets, { Set<Element> forgottenChildren }) {
  	...
    int newChildrenTop = 0;
    int oldChildrenTop = 0;
    int newChildrenBottom = newWidgets.length - 1;
    int oldChildrenBottom = oldChildren.length - 1;

    final List<Element> newChildren = oldChildren.length == newWidgets.length ?
        oldChildren : List<Element>(newWidgets.length);

    Element previousChild;

    // 從前往後依次對比,相同的更新Element,記錄位置,直到不相等時跳出循環。
    while ((oldChildrenTop <= oldChildrenBottom) && 
    	(newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
      final Widget newWidget = newWidgets[newChildrenTop];
      // 注意這裏的canUpdate,本例中在沒有添加key時返回true。
      // 因此直接執行updateChild,本循環結束返回newChildren。後面因條件不滿足都在不執行。
      // 一旦添加key,這裏返回false,不同之處就此開始。
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

    // 從後往前依次對比,記錄位置,直到不相等時跳出循環。
    while ((oldChildrenTop <= oldChildrenBottom) && 
    	(newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
      final Widget newWidget = newWidgets[newChildrenBottom];
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      oldChildrenBottom -= 1;
      newChildrenBottom -= 1;
    }
	// 至此,就可以得到新舊List中不同Weiget的範圍。
    final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
    Map<Key, Element> oldKeyedChildren;
    // 如果存在中間範圍,掃描舊children,獲取所有的key與Element保存至oldKeyedChildren。
    if (haveOldChildren) {
      oldKeyedChildren = <Key, Element>{};
      while (oldChildrenTop <= oldChildrenBottom) {
        final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
        if (oldChild != null) {
          if (oldChild.widget.key != null)
            oldKeyedChildren[oldChild.widget.key] = oldChild;
          else
          	// 沒有key就移除對應的Element
            deactivateChild(oldChild);
        }
        oldChildrenTop += 1;
      }
    }
	// 更新中間不同的部分
    while (newChildrenTop <= newChildrenBottom) {
      Element oldChild;
      final Widget newWidget = newWidgets[newChildrenTop];
      if (haveOldChildren) {
        final Key key = newWidget.key;
        if (key != null) {
          // key不爲null,通過key獲取對應的舊Element
          oldChild = oldKeyedChildren[key];
          if (oldChild != null) {
            if (Widget.canUpdate(oldChild.widget, newWidget)) {
              oldKeyedChildren.remove(key);
            } else {
              oldChild = null;
            }
          }
        }
      }
      // 本例中這裏的oldChild.widget與newWidget hashCode相同,在updateChild中成功被複用。
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
    }
    
    // 重置
    newChildrenBottom = newWidgets.length - 1;
    oldChildrenBottom = oldChildren.length - 1;

    // 將後面相同的Element更新後添加到newChildren,至此形成新的完整的children。
    while ((oldChildrenTop <= oldChildrenBottom) && 
    	(newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = oldChildren[oldChildrenTop];
      final Widget newWidget = newWidgets[newChildrenTop];
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

    // 清除舊列表中多餘的Element
    if (haveOldChildren && oldKeyedChildren.isNotEmpty) {
      for (Element oldChild in oldKeyedChildren.values) {
        if (forgottenChildren == null || !forgottenChildren.contains(oldChild))
          deactivateChild(oldChild);
      }
    }

    return newChildren;
  }

這個方法有點複雜,詳細的執行流程我在代碼中添加了註釋。看完這個diff算法,只能說一句:妙啊!!

到此也就解釋了我們一開始提出的問題。不知道你對這不起眼的key是不是有了更深的認識。通過上面的例子可以總結以下三點:

  • 一般情況下不設置key也會默認複用Element

  • 對於更改同一父級下Widget(尤其是runtimeType不同的Widget)的順序或是增刪,使用key可以更好的複用Element提升性能

  • StatefulWidget使用key,可以在發生變化時保持state。不至於發生本例中“身體交換”的bug。

Key的種類

上面例子中我們用到了Key,其實它還有許多種類。
在這裏插入圖片描述

1.LocalKey

LocalKey 繼承自 Key,在同一父級的Element之間必須是唯一的。(當然了,你要是寫成不唯一也行,不過後果自負哈。。。)

我們基本不直接使用LocalKey ,而是使用的它的子類:

ValueKey

我們上面使用到的Key,其實就是ValueKey<String>。它主要是使用特定類型的值來做標識的,像是“值引用”,比如int、String等類型。我們看它源碼中的 ==操作符方法:

class ValueKey<T> extends LocalKey {
  const ValueKey(this.value);
  
  final T value;

  @override
  bool operator ==(dynamic other) {
    if (other.runtimeType != runtimeType)
      return false;
    final ValueKey<T> typedOther = other;
    return value == typedOther.value; // <---
  }
  ...
}

ObjectKey

有“值引用”,就有“對象引用”。主要還是==操作符方法:

class ObjectKey extends LocalKey {
  const ObjectKey(this.value);

  final Object value;

  @override
  bool operator ==(dynamic other) {
    if (other.runtimeType != runtimeType)
      return false;
    final ObjectKey typedOther = other;
    return identical(value, typedOther.value); // <---
  }
  ...
}

UniqueKey

會生成一個獨一無二的key值。

class UniqueKey extends LocalKey {
  UniqueKey();

  @override
  String toString() => '[#${shortHash(this)}]';
}

String shortHash(Object object) {
  return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0');
}

PageStorageKey

用於保存和還原比Widget生命週期更長的值。比如用於保存滾動的偏移量。每次滾動完成時,PageStorage會保存其滾動偏移量。 這樣在重新創建Widget時可以恢復之前的滾動位置。類似的,在ExpansionTile中用於保存展開與閉合的狀態。

具體的實現原理也很簡單,看看PageStorage的源碼就清楚了,這裏就不展開了。

2.GlobalKey

介紹

GlobalKey 也繼承自 Key,在整個應用程序中必須是唯一的。GlobalKey源碼有點長,我就不全部貼過來了。

@optionalTypeArgs
abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
  factory GlobalKey({ String debugLabel }) => LabeledGlobalKey<T>(debugLabel);

  const GlobalKey.constructor() : super.empty();

  static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};
  // 在`Element的 `mount`中註冊GlobalKey。
  void _register(Element element) {
    _registry[this] = element;
  }
  // 在`Element的 `unmount`中註銷GlobalKey。
  void _unregister(Element element) {
    if (_registry[this] == element)
      _registry.remove(this);
  }

  Element get _currentElement => _registry[this];

  BuildContext get currentContext => _currentElement;
  
  Widget get currentWidget => _currentElement?.widget;

  T get currentState {
    final Element element = _currentElement;
    if (element is StatefulElement) {
      final StatefulElement statefulElement = element;
      final State state = statefulElement.state;
      if (state is T)
        return state;
    }
    return null;
  }
  ...
}

它的內部存在一個Map<GlobalKey, Element>的靜態Map,通過調用_register_unregister方法來添加和刪除Element。同時它的內部還持有當前的ElementWidget甚至State。可以看到 GlobalKey是非常昂貴的,沒有特別的複用需求,不建議使用它

怎麼複用呢?GlobalKey在上面inflateWidget的源碼中出現過一次。當發現key是GlobalKey時,使用_retakeInactiveElement方法複用Element


  Element _retakeInactiveElement(GlobalKey key, Widget newWidget) {
    final Element element = key._currentElement;
    if (element == null)
      return null;
    if (!Widget.canUpdate(element.widget, newWidget))
      return null;
    final Element parent = element._parent;
    if (parent != null) {
      parent.forgetChild(element);
      parent.deactivateChild(element);
    }
    owner._inactiveElements.remove(element);
    return element;
  }

如果獲取到了Element,那麼就從舊的節點上移除並返回。否則將在inflateWidget重新創建新的Element

使用

  • 首先就是上面提到的使用相同的GlobalKey來實現複用。

  • 利用GlobalKey持有的BuildContext。比如常見的使用就是獲取Widget的寬高信息,通過BuildContext可以在其中獲取RenderObjectSize,從而拿到寬高信息。這裏就不貼代碼了,有需要可以看此處示例

  • 利用GlobalKey持有的State,實現在外部調用StatefulWidget內部方法。比如常用GlobalKey<NavigatorState>來實現無Context跳轉頁面,在點擊推送信息跳轉指定頁面就需要用到。

先創建一個GlobalKey<NavigatorState>

  static GlobalKey<NavigatorState> navigatorKey = new GlobalKey();

添加至MaterialApp:

  MaterialApp(
   navigatorKey: navigatorKey,
   ...
  );

然後就是調用push方法:

  navigatorKey.currentState.push(MaterialPageRoute(
    builder: (BuildContext context) => MyPage(),
  ));

通過GlobalKey持有的State,就可以調用其中的方法、獲取數據。

LabeledGlobalKey

它是一個帶有標籤的GlobalKey。 該標籤僅用於調試,不用於比較。

GlobalObjectKey

同上ObjectKey。區別在於它是GlobalKey

思考題

最後來個思考題:對於可選參數key,我搜索了一下Flutter的源碼。發現只有Dismissible這個滑動刪除組件要求必須傳入key。結合今天的內容,想想是爲什麼?如果傳入相同的key,會發生什麼?


本篇是“說說”系列第三篇,前兩篇鏈接奉上:

PS:此係列都是自己的學習記錄與總結,盡力做到“通俗易懂”和“看着一篇就夠了”。不過也不現實,學習之路沒有捷徑。

寫着寫着,就寫的有點多了。本想着拆成兩篇,想想算了。畢竟我是一名月更選手,哈哈~~

如果本文對你有所幫助或啓發的話,還請不吝點贊收藏支持一波。同時也多多支持我的Flutter開源項目flutter_deer

我們下個月見~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章