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

在這裏插入圖片描述

Key在Flutter的源碼中能夠說是無處不在,可是咱們平常中確不怎麼使用它。有點像是「最熟悉的陌生人」,那麼今天就來講說這個「陌生人」,揭開它神祕的面紗。html

概念

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

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

例子

視頻中的例子很簡單且具備表明性,因此本文將採用它來介紹今天的內容。程序員

首先上代碼:github

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中運行查看效果。 或者點擊這裏直接運行算法

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

發現問題

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

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,
    );
  }
}
複製代碼

再次執行代碼,發現方塊沒有「交換」。這是爲何?dom

在這裏插入圖片描述

分析問題

首先要知道Flutter中有三棵樹,分別是==Widget Tree==、==Element Tree== 和 ==RenderObject Tree==。ide

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

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

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

可是爲何StatelessWidget能夠成功,換成StatefulWidget就失效了?

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

graph TB
A["_element.markNeedsBuild()"] -- 標記自身元素dirty爲true --> B["owner.scheduleBuildFor()"]
B --添加至_dirtyElements--> D["drawFrame()"] 
D --> E["buildScope()"]
E --> F["_dirtyElements[index].rebuild()"]
F --> G["performRebuild()"]
G --> H["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

咱們下個月見~~

相關文章
相關標籤/搜索