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

唯鹿發表於2020-03-09

在這裡插入圖片描述

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

概念

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

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

例子

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

首先上程式碼:

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 Tree==、==Element Tree== 和 ==RenderObject Tree==。

  • 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

我們下個月見~~

相關文章