Flutter | 深入淺出Key

Vadaski發表於2019-04-02

前言

在開發 Flutter 的過程中你可能會發現,一些小部件的建構函式中都有一個可選的引數——Key。剛接觸的同學或許會對這個概念感到很迷茫,感到不知所措。

在這篇文章中我們會深入淺出的介紹什麼是 Key,以及應該使用 key 的具體場景。

什麼是Key

在 Flutter 中我們經常與狀態打交道。我們知道 Widget 可以有 Stateful 和 Stateless 兩種。Key 能夠幫助開發者在 Widget tree 中儲存狀態,在一般的情況下,我們並不需要使用 Key。那麼,究竟什麼時候應該使用 Key呢。

我們來看看下面這個例子。

class StatelessContainer extends StatelessWidget {
  final Color color = RandomColor().randomColor();
  
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: color,
    );
  }
}
複製程式碼

這是一個很簡單的 Stateless Widget,顯示在介面上的就是一個 100 * 100 的有顏色的 Container。 RandomColor 能夠為這個 Widget 初始化一個隨機顏色。

我們現在將這個Widget展示到介面上。

class Screen extends StatefulWidget {
  @override
  _ScreenState createState() => _ScreenState();
}

class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    StatelessContainer(),
    StatelessContainer(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: widgets,
        ),
      ),
      floatingActionButton: FloatingActionButton(
          onPressed: switchWidget,
        child: Icon(Icons.undo),
      ),
    );
  }

  switchWidget(){
    widgets.insert(0, widgets.removeAt(1));
    setState(() {});
  }
}
複製程式碼

這裡在螢幕中心展示了兩個 StatelessContainer 小部件,當我們點選 floatingActionButton 時,將會執行 switchWidget 並交換它們的順序。

Flutter | 深入淺出Key
看上去並沒有什麼問題,交換操作被正確執行了。現在我們做一點小小的改動,將這個 StatelessContainer 升級為 StatefulContainer。

class StatefulContainer extends StatefulWidget {
  StatefulContainer({Key key}) : super(key: key);
  @override
  _StatefulContainerState createState() => _StatefulContainerState();
}

class _StatefulContainerState extends State<StatefulContainer> {
  final Color color = RandomColor().randomColor();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: color,
    );
  }
}
複製程式碼

在 StatefulContainer 中,我們將定義 Color 和 build 方法都放進了 State 中。

現在我們還是使用剛才一樣的佈局,只不過把 StatelessContainer 替換成 StatefulContainer,看看會發生什麼。

Flutter | 深入淺出Key

這時,無論我們怎樣點選,都再也沒有辦法交換這兩個Container的順序了,而 switchWidget 確實是被執行了的。

為了解決這個問題,我們在兩個 Widget 構造的時候給它傳入一個 UniqueKey。

class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    StatefulContainer(key: UniqueKey(),),
    StatefulContainer(key: UniqueKey(),),
  ];
  ···
複製程式碼

然後這兩個 Widget 又可以正常被交換順序了。

看到這裡大家肯定心中會有疑問,為什麼 Stateful Widget 無法正常交換順序,加上了 Key 之後就可以了,在這之中到底發生了什麼? 為了弄明白這個問題,我們將涉及 Widget 的 diff 更新機制。

Widget 更新機制

在之前的文章中,我們介紹了 WidgetElement 的關係。若你還對 Element 的概念感到很模糊的話,請先閱讀 Flutter | 深入理解BuildContext

下面來來看Widget的原始碼。

@immutable
abstract class Widget extends DiagnosticableTree {
  const Widget({ this.key });
  final Key key;
  ···
  static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }
}
複製程式碼

我們知道 Widget 只是一個配置且無法修改,而 Element 才是真正被使用的物件,並可以修改。

當新的 Widget 到來時將會呼叫 canUpdate 方法,來確定這個 Element是否需要更新。

canUpdate 對兩個(新老) Widget 的 runtimeType 和 key 進行比較,從而判斷出當前的 Element 是否需要更新。若 canUpdate 方法返回 true 說明不需要替換 Element,直接更新 Widget 就可以了。

StatelessContainer 比較過程

在 StatelessContainer 中,我們並沒有傳入 key ,所以只比較它們的 runtimeType。這裡 runtimeType 一致,canUpdate 方法返回 true,兩個 Widget 被交換了位置,StatelessElement 呼叫新持有 Widget 的 build 方法重新構建,在螢幕上兩個 Widget 便被正確的交換了順序。

StatefulContainer 比較過程

而在 StatefulContainer 的例子中,我們將 color 的定義放在了 State 中,Widget 並不儲存 State,真正 hold State 的引用的是 Stateful Element。

當我們沒有給 Widget 任何 key 的時候,將會只比較這兩個 Widget 的 runtimeType 。由於兩個 Widget 的屬性和方法都相同,canUpdate 方法將會返回 true,於是更新 StatefulWidget 的位置,這兩個 Element 將不會交換位置。但是原有 Element 只會從它持有的 state 例項中 build 新的 widget。因為 element 沒變,它持有的 state 也沒變。所以顏色不會交換。這裡變換 StatefulWidget 的位置是沒有作用的。

而我們給 Widget 一個 key 之後,canUpdate 方法將會比較兩個 Widget 的 runtimeType 以及 key。並返回 false。(這裡 runtimeType 相同,key 不同)

此時 RenderObjectElement 會用新 Widget 的 key 在老 Element 列表裡面查詢,找到匹配的則會更新 Element 的位置並更新對應 renderObject 的位置,對於這個例子來講就是交換了 Element 的位置並交換了對應 renderObject 的位置。都交換了,那麼顏色自然也就交換了。

這裡感謝ad6623對之前錯誤描述的指出。

比較範圍

為了提升效能 Flutter 的比較演算法(diff)是有範圍的,它並不是對第一個 StatefulWidget 進行比較,而是對某一個層級的 Widget 進行比較。

···
class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(key: UniqueKey(),),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(key: UniqueKey(),),
    ),
  ];
···
複製程式碼

在這個例子中,我們將兩個帶 key 的 StatefulContainer 包裹上 Padding 元件,然後點選交換按鈕,會發生下面這件奇妙的事情。

Flutter | 深入淺出Key

兩個 Widget 的 Element 並不是交換順序,而是被重新建立了。

在 Flutter 的比較過程中它下到 Row 這個層級,發現它是一個 MultiChildRenderObjectWidget(多子部件的 Widget)。然後它會對所有 children 層逐個進行掃描。

在Column這一層級,padding 部分的 runtimeType 並沒有改變,且不存在 Key。然後再比較下一個層級。由於內部的 StatefulContainer 存在 key,且現在的層級在 padding 內部,該層級沒有多子 Widget。runtimeType 返回 flase,Flutter 的將會認為這個 Element 需要被替換。然後重新生成一個新的 Element 物件裝載到 Element 樹上替換掉之前的 Element。第二個 Widget 同理。

所以為了解決這個問題,我們需要將 key 放到 Row 的 children 這一層級。

···
class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(),
    ),
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(),
    ),
  ];
···
複製程式碼

現在我們又可以愉快的玩耍了(交換 Widget 順序)了。

擴充套件內容

slot 能夠描述子級在其父級列表中的位置。多子部件 Widget 例如 Row,Column 都為它的子級提供了一系列 slot。

在呼叫 Element.updateChild 的時候有一個細節,若新老 Widget 的例項相同,注意這裡是例項相同而不是型別相同, slot 不同的時候,Flutter 所做的僅僅是更新 slot,也就給他換個位置。因 為 Widget 是不可變的,例項相同意味著顯示的配置相同,所以要做的僅僅是挪個地方而已。

abstract class Element extends DiagnosticableTree implements BuildContext {
···
  dynamic get slot => _slot;
  dynamic _slot;
···
 @protected
  Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
    ···
    if (child != null) {
      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);
        assert(child.widget == newWidget);
        assert(() {
          child.owner._debugElementWasRebuilt(child);
          return true;
        }());
        return child;
      }
      deactivateChild(child);
      assert(child._parent == null);
    }
    return inflateWidget(newWidget, newSlot);
  }
複製程式碼

更新機制表

新WIDGET不為空 新 Widget不為空
child為空 返回null。 返回新的 Element
child不為空 移除舊的widget,返回null. 若舊的child Element 可以更新(canUpdate)則更新並將其返回,否則返回一個新的 Element.

Key 的種類

Key

@immutable
abstract class Key {
  const factory Key(String value) = ValueKey<String>;

  @protected
  const Key.empty();
}
複製程式碼

預設建立 Key 將會通過工廠方法根據傳入的 value 建立一個 ValueKey。

Key 派生出兩種不同用途的 Key:LocalKey 和 GlobalKey。

Localkey

LocalKey 直接繼承至 Key,它應用於擁有相同父 Element 的小部件進行比較的情況,也就是上述例子中,有一個多子 Widget 中需要對它的子 widget 進行移動處理,這時候你應該使用Localkey。

Localkey 派生出了許多子類 key:

  • ValueKey : ValueKey('String')
  • ObjectKey : ObjectKey(Object)
  • UniqueKey : UniqueKey()

Valuekey 又派生出了 PageStorageKey : PageStorageKey('value')

GlobalKey

@optionalTypeArgs
abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
···
static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};
static final Set<Element> _debugIllFatedElements = HashSet<Element>();
static final Map<GlobalKey, Element> _debugReservations = <GlobalKey, Element>{};
···
BuildContext get currentContext ···
Widget get currentWidget ···
T get currentState ···
複製程式碼

GlobalKey 使用了一個靜態常量 Map 來儲存它對應的 Element。

你可以通過 GlobalKey 找到持有該GlobalKey的 WidgetStateElement

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

什麼時候需要使用 Key

ValueKey

如果您有一個 Todo List 應用程式,它將會記錄你需要完成的事情。我們假設每個 Todo 事情都各不相同,而你想要對每個 Todo 進行滑動刪除操作。

這時候就需要使用 ValueKey!

return TodoItem(
    key: ValueKey(todo.task),
    todo: todo,
    onDismissed: (direction){
        _removeTodo(context, todo);
    },
);
複製程式碼

ObjectKey

如果你有一個生日應用,它可以記錄某個人的生日,並用列表顯示出來,同樣的還是需要有一個滑動刪除操作。

我們知道人名可能會重複,這時候你無法保證給 Key 的值每次都會不同。但是,當人名和生日組合起來的 Object 將具有唯一性。

這時候你需要使用 ObjectKey!

UniqueKey

如果組合的 Object 都無法滿足唯一性的時候,你想要確保每一個 Key 都具有唯一性。那麼,你可以使用 UniqueKey。它將會通過該物件生成一個具有唯一性的 hash 碼。

不過這樣做,每次 Widget 被構建時都會去重新生成一個新的 UniqueKey,失去了一致性。也就是說你的小部件還是會改變。(還不如不用?)

PageStorageKey

當你有一個滑動列表,你通過某一個 Item 跳轉到了一個新的頁面,當你返回之前的列表頁面時,你發現滑動的距離回到了頂部。這時候,給 Sliver 一個 PageStorageKey!它將能夠保持 Sliver 的滾動狀態。

GlobalKey

GlobalKey 能夠跨 Widget 訪問狀態。 在這裡我們有一個 Switcher 小部件,它可以通過 changeState 改變它的狀態。

class SwitcherScreenState extends State<SwitcherScreen> {
  bool isActive = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Switch.adaptive(
            value: isActive,
            onChanged: (bool currentStatus) {
              isActive = currentStatus;
              setState(() {});
            }),
      ),
    );
  }

  changeState() {
    isActive = !isActive;
    setState(() {});
  }
}
複製程式碼

但是我們想要在外部改變該狀態,這時候就需要使用 GlobalKey。

class _ScreenState extends State<Screen> {
  final GlobalKey<SwitcherScreenState> key = GlobalKey<SwitcherScreenState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SwitcherScreen(
        key: key,
      ),
      floatingActionButton: FloatingActionButton(onPressed: () {
        key.currentState.changeState();
      }),
    );
  }
}
複製程式碼

這裡我們通過定義了一個 GlobalKey 並傳遞給 SwitcherScreen。然後我們便可以通過這個 key 拿到它所繫結的 SwitcherState 並在外部呼叫 changeState 改變狀態了。

Flutter | 深入淺出Key

參考資料

寫在最後

這篇文章的靈感來自於 何時使用金鑰 - Flutter小部件 101 第四集, 強烈建議大家觀看這個系列視訊,你會對 Flutter 如何構建檢視更加清晰。也希望這篇文章對你有所幫助!

在這個視訊最後介紹 GlobalKey 時,提到了 Globalkey 能夠用於在不同小部件之間同步狀態,以及儲存狀態的功能,但我並沒有找到實現辦法,如果有使用過這兩個功能的小夥伴麻煩在這篇文章下面留言告訴我一下,謝謝!?

文章若有不對之處還請各位高手指出,歡迎在下方評論區以及我的郵箱1652219550a@gmail.com留言,我會在24小時內與您聯絡!

相關文章