Widget 的 Key(二)


在〈Widget 的 Key(一)〉中談到了 key 的作用,現在回到其中範例還沒有設置 key 的情境,假設你的便利貼 App,Note 為了能儲存、修改便利貼訊息,改繼承了 StatefulWidget

import 'package:flutter/material.dart';

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

class MyNote extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My Note',
      home: MyHomePage(title: 'My Note Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({this.title});

  final String title;

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

// Note 現在繼承了 StatefulWidget
class Note extends StatefulWidget {
  Color color;
  double width;
  double height;

  Note({this.color, this.width, this.height});

  @override
  State<StatefulWidget> createState() => _NoteState(this);
}

class _NoteState extends State<Note> {
  Note note;

  _NoteState(this.note);

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

class _MyHomePageState extends State<MyHomePage> {
  final notes = [
    Note(
      color: Colors.red,
      width: 200,
      height: 200,
    ),
    Note(
      color: Colors.blue,
      width: 200,
      height: 200,
    )
  ];

  void _round_robin() {
    setState(() {
      notes.add(notes.removeAt(0));
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: notes,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _round_robin,
        tooltip: 'round robin',
        child: Icon(Icons.swap_vert),
      ),
    );
  }
}

結果你發現按鈕怎麼按,畫面就是不會動:

Widget 的 Key(二)

來看看是怎麼一回事,一開始 Widget 樹與 Element 樹是這樣的:

Widget 的 Key(二)

StatefulWidgetcreateElement 傳回的,是 Element 的子類 StatefulElement 實例,現在顏色資訊不在 Note 上,而是在 _NoteState,而 Note 對應的 Element 節點,才會參考到 _NoteState(六角形圖示),這也可以從 StatefulElement 的原始碼中看到:

class StatefulElement extends ComponentElement {
  /// Creates an element that uses the given widget as its configuration.
  StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
        super(widget) {
    _state._element = this;
    _state._widget = widget;
  }
  ...

在按下按鈕後,因為 Note 沒有設置 key,根據〈Widget 的 Key(一)〉的說明,Widget 樹與 Element 樹會變為:

Widget 的 Key(二)

Widget 樹中的 Note 節點是有調換了沒錯,然而由於沒有設置 keyElement 樹不會調整,只是對應的 Element 參考了新的 Widget,問題就在於,Element_NoteState 並沒有換,繪製時的顏色狀態,是來自 _NoteState,結果畫面還是看到藍色。

要解決這個問題,可以為 Note 加上 key

import 'package:flutter/material.dart';

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

...略

class Note extends StatefulWidget {
  Color color;
  double width;
  double height;

  // 設置 key
  Note({Key key, this.color, this.width, this.height}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _NoteState(this);
}

...略

class _MyHomePageState extends State<MyHomePage> {
  int _idx = 0;
  final notes = [
    Note(
      key: UniqueKey(),  // 設置 key
      color: Colors.red,
      width: 200,
      height: 200,
    ),
    Note(
      key: UniqueKey(),  // 設置 key
      color: Colors.blue,
      width: 200,
      height: 200,
    )
  ];

  ...略
}

Note 加上 key 之後,根據〈Widget 的 Key(一)〉的說明,會因為 canUpdate 傳回 falseElement 樹必須與 Widget 樹有對應的調整,這時的兩棵樹會是:

Widget 的 Key(二)

這時就可以根據 Element 各自的顏色狀態來繪製,因此可以看到兩個顏色交替。

調整某些 StatefulWidget 節點時,該 StatefulWidget 節點必須設置 key,而且一定要設置在該節點,否則會導致新的 Element 產生,而使得 State 物件重新產生,這就無法保留狀態了。

為了做個實驗,來修改一下 Note_NoteState,令顏色是隨機設置,並在 Note 外加個 Padding,例如:

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

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

class MyNote extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My Note',
      home: MyHomePage(title: 'My Note Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({this.title});

  final String title;

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

class Note extends StatefulWidget {
  double width;
  double height;

  // 設置 key
  Note({Key key, this.width, this.height}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _NoteState(this);
}

class _NoteState extends State<Note> {
  Note note;
  Color color;

  _NoteState(this.note);

  @override
  void initState() {
    super.initState();

    // 隨機顏色
    var rand = new Random();
    color = Color.fromARGB(255,
        rand.nextInt(255), rand.nextInt(255), rand.nextInt(255));
  }

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

class _MyHomePageState extends State<MyHomePage> {
  final notes = [
    Padding(  // 加上 Padding
      child: Note( 
        key: UniqueKey(),  
        width: 200,
        height: 200,
      ),
      padding: EdgeInsets.all(8.0),
    ),
    Padding(  // 加上 Padding
      child: Note(
        key: UniqueKey(),  
        width: 200,
        height: 200,
      ),
      padding: EdgeInsets.all(8.0),
    ),
  ];

  void _round_robin() {
    setState(() {
      notes.add(notes.removeAt(0));
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: notes,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _round_robin,
        tooltip: 'round robin',
        child: Icon(Icons.swap_vert),
      ),
    );
  }
}

注意,雖然 Note 設置了 key,但 Padding 沒有設置 key,如果執行程式的話,會發生顏色不斷隨機變化:

Widget 的 Key(二)

來看看為什麼,假設一開始的顏色等是如下:

Widget 的 Key(二)

按下按鈕後,Widget 樹變化了,現在要開始檢查 Element 樹,來看到檢查第一個 Padding 時:

Widget 的 Key(二)

PaddingNoteElementkey 對不起來,Flutter 只會檢查目前子樹,不會去看另一個 Padding 子樹,這時原本的 Element 會被丟棄,使用 NotecreateElement 建立一個新的 Element,而新的 State 也就隨之建立,結果又是另一個隨機色了。

解決的方式是,在 Padding 設置 key

...略
class _MyHomePageState extends State<MyHomePage> {
  final notes = [
    Padding(
      key: UniqueKey(),  // 設置 key
      child: Note(
        width: 200,
        height: 200,
      ),
      padding: EdgeInsets.all(8.0),
    ),
    Padding(
      key: UniqueKey(),  // 設置 key
      child: Note(
        width: 200,
        height: 200,
      ),
      padding: EdgeInsets.all(8.0),
    ),
  ];
  ...略
}

這麼一來,Widget 樹的 Padding 節點調整了,Element 樹的部份也會跟著調整,也就沒有了方才的問題,一開始的兩個顏色是隨機的,而後交替出現:

Widget 的 Key(二)