Введение
Работая с виджетами во Flutter, вы, наверное, уже заметили, что у всех виджетов есть параметр Key
, или так называемый ключ. В этом параграфе мы рассмотрим, что такое ключи, когда, зачем и как их стоит использовать.
Что такое Key
Key
— это уникальный идентификатор виджета, который используется при перестроении дерева виджетов для того, чтобы отличать друг от друга одинаковые по типу виджеты.
Ключи необходимо использовать, когда у одного родителя (например, Column
или Row
) несколько дочерних виджетов одного типа и у этих виджетов есть состояние. Если мы поменяем такие виджеты местами или удалим один из них, фреймворк не сможет отличить один виджет от другого, так как у них одинаковый тип.
Давайте рассмотрим наглядный пример: у нас есть Column
, в котором находятся два StatefulWidget
виджета MyStatefulWidget
, и мы удаляем один из них.
Какой из виджетов мы удалили? В данном случае фреймворк будет ориентироваться на позицию виджетов внутри children
и удалит второй, но, если мы хотим удалить первый, нужно, чтобы фреймворк их мог различить. Именно для этого используются ключи.
Пример использования ключей
Давайте рассмотрим следующий пример. У нас есть виджет ColorBlock
— это StatefulWidget
, который представляет собой цветной Container
. При инициализации для Container
задаётся переданный извне цвет. По тапу на ColorBlock
цвет меняется по определённой логике, а именно — параметры цвета red
/green
/blue
инкрементируются:
1class ColorBlock extends StatefulWidget {
2 final Color color;
3
4 const ColorBlock({
5 required this.color,
6 Key? key,
7 }) : super(key: key);
8
9 @override
10 State<ColorBlock> createState() => _ColorBlockState();
11}
12
13class _ColorBlockState extends State<ColorBlock> {
14 late Color color;
15
16 @override
17 void initState() {
18 super.initState();
19 color = widget.color;
20 }
21
22 void incrementColor() {
23 setState(() {
24 color = Color.fromARGB(
25 color.alpha,
26 (color.red + 4) % 256,
27 (color.green + 4) % 256,
28 (color.blue + 4) % 256,
29 );
30 });
31 }
32
33 @override
34 Widget build(BuildContext context) {
35 return GestureDetector(
36 onTap: incrementColor,
37 child: Container(
38 color: color,
39 width: 100.0,
40 height: 100.0,
41 ),
42 );
43 }
44}
Также у нас есть виджет MyHomePage
— это StatefulWidget
, который представляет собой экран. На экране располагается список виджетов ColorBlock
, а также есть виджет FloatingActionButton
, по тапу на который наш список виджетов ColorBlock
разворачивается:
1class MyHomePage extends StatefulWidget {
2 const MyHomePage({Key? key, required this.title}) : super(key: key);
3
4 final String title;
5
6 @override
7 State<MyHomePage> createState() => _MyHomePageState();
8}
9
10class _MyHomePageState extends State<MyHomePage> {
11 var _colorBlocks = <Widget>[];
12
13 @override
14 void initState() {
15 super.initState();
16 final random = Random().nextInt(Colors.primaries.length - 2);
17 _colorBlocks = [
18 ColorBlock(
19 color: Colors.primaries[random],
20 ),
21 ColorBlock(
22 color: Colors.primaries[random + 1],
23 ),
24 ColorBlock(
25 color: Colors.primaries[random + 2],
26 ),
27 ];
28 }
29
30 @override
31 Widget build(BuildContext context) {
32 return Scaffold(
33 appBar: AppBar(
34 title: Text(widget.title),
35 ),
36 body: Column(children: _colorBlocks),
37 floatingActionButton: FloatingActionButton(
38 child: const Icon(Icons.swap_vert),
39 onPressed: () {
40 setState(() {
41 _colorBlocks = _colorBlocks.reversed.toList();
42 });
43 },
44 ),
45 );
46 }
47}
Запустив приложение, мы увидим следующее:
Когда мы нажимаем на виджет ColorBlock
, цвет ожидаемо меняется, а если нажать на виджет FloatingActionButton
, ничего не происходит, хотя мы меняем последовательность ColorBlock
и вызываем setState
. Это происходит потому, что фреймворк не может отличить виджеты ColorBlock
друг от друга и определить, что их последовательность поменялась.
Давайте воспользуемся ключами и поправим данный пример. При создании виджетов ColorBlock
будем передавать туда уникальный Key
. В классе Key
есть factory
-конструктор, который принимает String
и создаёт ключ.
1_colorBlocks = [
2 ColorBlock(
3 color: Colors.primaries[random],
4 key: Key(Colors.primaries[random].toString()),
5 ),
6 ColorBlock(
7 color: Colors.primaries[random + 1],
8 key: Key(Colors.primaries[random + 1].toString()),
9 ),
10 ColorBlock(
11 color: Colors.primaries[random + 2],
12 key: Key(Colors.primaries[random + 2].toString()),
13 ),
14];
Теперь, когда мы нажимаем на FloatingActionButton
, всё работает как ожидается:
Может возникнуть ощущение, что важно добавлять ключи к StatefulWidget
или к первому StatefulWidget
среди дочерних виджетов, но это не так. Давайте обернём наши ColorBlock
в Padding
. Вероятно, вы ожидаете, что к нашим ColorBlock
просто добавится Padding
, а в остальном поведение не изменится:
1_colorBlocks = [
2 Padding(
3 padding: const EdgeInsets.all(8.0),
4 child: ColorBlock(
5 color: Colors.primaries[random],
6 key: Key(Colors.primaries[random].toString()),
7 ),
8 ),
9 Padding(
10 padding: const EdgeInsets.all(8.0),
11 child: ColorBlock(
12 color: Colors.primaries[random + 1],
13 key: Key(Colors.primaries[random + 1].toString()),
14 ),
15 ),
16 Padding(
17 padding: const EdgeInsets.all(8.0),
18 child: ColorBlock(
19 color: Colors.primaries[random + 2],
20 key: Key(Colors.primaries[random + 2].toString()),
21 ),
22 ),
23];
Однако, когда мы нажимаем на виджет FloatingActionButton
, все наши ColorBlock
вместе с Padding
меняются местами, но состояние ColorBlock
теряется. Всё дело в том, что при перестановке Padding
фреймворк переиспользует те же самые Padding
, для него ничего не меняется, но дочерний виджет у каждого Padding
меняет ключ, и поэтому State
создаётся заново. Это легко исправить, указав ключ у самого Padding
:
1_colorBlocks = [
2 Padding(
3 key: Key(Colors.primaries[random].toString()),
4 padding: const EdgeInsets.all(8.0),
5 child: ColorBlock(
6 color: Colors.primaries[random],
7 ),
8 ),
9 Padding(
10 key: Key(Colors.primaries[random + 1].toString()),
11 padding: const EdgeInsets.all(8.0),
12 child: ColorBlock(
13 color: Colors.primaries[random + 1],
14 ),
15 ),
16 Padding(
17 key: Key(Colors.primaries[random + 2].toString()),
18 padding: const EdgeInsets.all(8.0),
19 child: ColorBlock(
20 color: Colors.primaries[random + 2],
21 ),
22 ),
23];
Типы ключей
Итак, мы разобрались, для чего нужны ключи, давайте теперь рассмотрим, какие они бывают. Есть множество ключей, и они делятся на две группы: LocalKey
и GlobalKey
, выглядит это следующим образом:
Key
— это абстрактный класс, общий предок для всех ключей, у него естьfactory
-конструктор, который создаётValueKey<String>
;LocalKey
— это абстрактный класс, наследуется отKey
, общий предок для всех локальных ключей. Особенность локальных ключей в том, что они должны быть уникальны для всех виджетов с одним родителем;ValueKey**<T>**
— локальный ключ, уникальность которого определяется по значению объектаT
, это значит, что для корректной работы ключа необходимо, чтобы были переопределеныhashCode
и оператор сравнения==
у классаT
;ObjectKey
— локальный ключ, уникальность которого определяется не по значению объекта, а по ссылке на него;UniqueKey
— уникальный локальный ключ, который равен только самому себе.
UniqueKey
может быть использован, когда нам нужно на каждое перестроение дерева виджетов пересоздавать поддерево, либо когда у нас нет какого-то конкретного value
, с которым мы можем ассоциировать виджеты.
В примере выше мы фактически использовали ValueKey
(именно он создаётся с помощью factory
-конструктора Key
), но могли использовать любой из локальных ключей, в том числе UniqueKey
. То, какой из ключей использовать, зависит от задачи и данных, которые у нас есть.
Давайте рассмотрим пример использования UniqueKey
, для этого изменим наш пример следующим образом:
- вместо
ColorBlock
напишемRandomColorBlock
, который будет случайным образом определять свой цвет вinitState
и больше не будет его менять; - в
initState
виджетаMyHomePage
будем создавать список изRandomColorBlock
; - по тапу на
FloatingActionButton
вместо разворота списка будем его обновлять и вызыватьsetState
.
Поскольку сама структура дерева виджетов у нас меняться не будет, передадим UniqueKey
в наши RandomColorBlock
при создании — благодаря этому фреймворк поймёт, что это другие RandomColorBlock
.
1class RandomColorBlock extends StatefulWidget {
2 const RandomColorBlock({Key? key}) : super(key: key);
3
4 @override
5 State<RandomColorBlock> createState() => _RandomColorBlockState();
6}
7
8class _RandomColorBlockState extends State<RandomColorBlock> {
9 late Color color;
10
11 @override
12 void initState() {
13 super.initState();
14 final random = Random();
15 color = Color.fromARGB(
16 255,
17 random.nextInt(256),
18 random.nextInt(256),
19 random.nextInt(256),
20 );
21 }
22
23 @override
24 Widget build(BuildContext context) {
25 return Container(
26 color: color,
27 width: 100.0,
28 height: 100.0,
29 );
30 }
31}
32
33class MyHomePage extends StatefulWidget {
34 const MyHomePage({Key? key, required this.title}) : super(key: key);
35
36 final String title;
37
38 @override
39 State<MyHomePage> createState() => _MyHomePageState();
40}
41
42class _MyHomePageState extends State<MyHomePage> {
43 var _colorBlocks = <Widget>[];
44
45 @override
46 void initState() {
47 super.initState();
48 _generateRandomColorBlocks();
49 }
50
51 void _generateRandomColorBlocks() {
52 setState(() {
53 _colorBlocks = List.generate(
54 3,
55 (_) => RandomColorBlock(key: UniqueKey()),
56 );
57 });
58 }
59
60 @override
61 Widget build(BuildContext context) {
62 return Scaffold(
63 appBar: AppBar(title: Text(widget.title)),
64 body: Column(children: _colorBlocks),
65 floatingActionButton: FloatingActionButton(
66 onPressed: _generateRandomColorBlocks,
67 child: const Icon(Icons.swap_vert),
68 ),
69 );
70 }
71}
GlobalKey — это абстрактный класс, наследуется от Key
, общий предок для всех глобальных ключей. Особенность глобальных ключей в том, что они должны быть уникальны для всего приложения.
LabeledGlobalKey — своего рода глобальный аналог UniqueKey
, уникален и равен сам себе. У ключа есть debug label
, который никак не влияет на его уникальность и используется только для целей дебага.
GlobalObjectKey — своего рода глобальный аналог ObjectKey
, равенство / уникальность определяются по инстансу Object
(то есть фактически по ссылке в памяти).
Глобальные ключи могут использоваться в двух целях:
- переместить виджет и его
State
из одной части дерева виджетов в совершенно другое место в дереве виджетов (иными словами, поменять родителя виджета), при этом при перемещении виджет с нашим ключом не должен покидать дерево виджетов и не должен быть использован в двух разных местах одновременно; - получить доступ к
State
/BuildContext
StatefulWidget
из любой точки приложения.
Используем GlobalKey
Представьте, что в примере со списком из ColorBlock
вёрстка в portrait- и landscape-ориентации сильно различается: в landscape добавляется дополнительный Padding
к Column
.
1@override
2Widget build(BuildContext context) {
3 return OrientationBuilder(builder: (context, orientation) {
4 return Scaffold(
5 appBar: AppBar(
6 title: Text(widget.title),
7 ),
8 body: orientation == Orientation.portrait
9 ? Column(
10 children: _colorBlocks,
11 )
12 : Padding(
13 padding: const EdgeInsets.all(8.0),
14 child: Column(
15 children: _colorBlocks,
16 ),
17 ),
18 floatingActionButton: FloatingActionButton(
19 child: const Icon(Icons.swap_vert),
20 onPressed: () {
21 setState(() {
22 _colorBlocks = _colorBlocks.reversed.toList();
23 });
24 },
25 ),
26 );
27 });
28}
В таком случае, поскольку при повороте экрана у виджета Scaffold
меняется виджет body
, у нас будет пересоздаваться всё поддерево body
. Чтобы решить эту проблему, мы можем воспользоваться глобальным ключом. Давайте добавим его к Column
и посмотрим, что произойдёт.
1class _MyHomePageState extends State<MyHomePage> {
2 final _columnKey = GlobalKey();
1@override
2Widget build(BuildContext context) {
3 return OrientationBuilder(builder: (context, orientation) {
4 return Scaffold(
5 appBar: AppBar(
6 title: Text(widget.title),
7 ),
8 body: orientation == Orientation.portrait
9 ? Column(
10 key: _columnKey,
11 children: _colorBlocks,
12 )
13 : Padding(
14 padding: const EdgeInsets.all(8.0),
15 child: Column(
16 key: _columnKey,
17 children: _colorBlocks,
18 ),
19 ),
20 floatingActionButton: FloatingActionButton(
21 child: const Icon(Icons.swap_vert),
22 onPressed: () {
23 setState(() {
24 _colorBlocks = _colorBlocks.reversed.toList();
25 });
26 },
27 ),
28 );
29 });
30}
Как видите, GlobalKey
позволяет поменять родителя Column
и смонтировать его в любом другом месте без потери состояния. Происходит это за один фрейм, и Column
не уходит из дерева виджетов.
Также необходимо следить за тем, чтобы в один фрейм один и тот же глобальный ключ не был использован более чем в одном виджете. Иначе мы получим следующую ошибку: Duplicate GlobalKey detected in widget tree
.
Очень важно понимать, что использование глобальных ключей для сохранения состояния и перемещения виджета не самая дешёвая операция, поэтому не рекомендуется без необходимости использовать для этого глобальные ключи. По возможности желательно выносить состояние из виджета в слой презентационной логики или в инхеритед-виджеты.
В примере выше мы могли вместо добавления виджета Padding
в landscape добавить его в обеих ориентациях и только менять значение параметра padding
. Так и стоит поступать, если есть такая возможность.
1@override
2Widget build(BuildContext context) {
3 return OrientationBuilder(builder: (context, orientation) {
4 return Scaffold(
5 appBar: AppBar(
6 title: Text(widget.title),
7 ),
8 body: Padding(
9 padding: orientation == Orientation.portrait
10 ? EdgeInsets.zero
11 : const EdgeInsets.all(8.0),
12 child: Column(
13 children: _colorBlocks,
14 ),
15 ),
16 floatingActionButton: FloatingActionButton(
17 child: const Icon(Icons.swap_vert),
18 onPressed: () {
19 setState(() {
20 _colorBlocks = _colorBlocks.reversed.toList();
21 });
22 },
23 ),
24 );
25 });
26}
Теперь давайте рассмотрим пример с получением доступа к State
. На нашем экране сейчас есть три ColorBlock
и FloatingActionButton
, который их разворачивает. Давайте воспользуемся GlobalKey
и поменяем логику следующим образом: будем по тапу на FloatingActionButton
инкрементировать все ColorBlock
, то есть вызывать incrementColor
у каждого ColorBlock
. Для этого нужно внести следующие изменения:
- в каждый
ColorBlock
передатьGlobalKey<_ColorBlockState>
; - по тапу на
FloatingActionButton
пробегаться по всем ключам, получать доступ кState
черезkey.currentState
и вызыватьincrementColor
.
1class _MyHomePageState extends State<MyHomePage> {
2 final _colorBlockKeys = <GlobalKey<_ColorBlockState>>[];
3}
1@override
2void initState() {
3 super.initState();
4 final random = Random().nextInt(Colors.primaries.length - 2);
5 _colorBlocks = [
6 ColorBlock(
7 key: GlobalKey<_ColorBlockState>(),
8 color: Colors.primaries[random],
9 ),
10 ColorBlock(
11 key: GlobalKey<_ColorBlockState>(),
12 color: Colors.primaries[random + 1],
13 ),
14 ColorBlock(
15 key: GlobalKey<_ColorBlockState>(),
16 color: Colors.primaries[random + 2],
17 ),
18 ];
19 for (final colorBlock in _colorBlocks) {
20 final key = colorBlock.key;
21 if (key is GlobalKey<_ColorBlockState>) {
22 _colorBlockKeys.add(key);
23 }
24 }
25}
1floatingActionButton: FloatingActionButton(
2 child: const Icon(Icons.swap_vert),
3 onPressed: () {
4 for (final key in _colorBlockKeys) {
5 key.currentState?.incrementColor();
6 }
7 },
8),
Таким образом GlobalKey
позволяет получить доступ к State
из любой точки приложения.
Этим также не стоит злоупотреблять — управлять состоянием виджетов лучше с помощью отдельных классов с бизнес-логикой / презентационной логикой. Подробнее о State Management и различных подходах к управлению состояниями мы расскажем в следующих параграфах.
Тем не менее бывают кейсы, когда глобальные ключи удобны. Например, для навигации. Об этом мы расскажем подробнее в параграфе.
Итак, в этом параграфе мы рассмотрели, что такое ключи, какие они бывают, когда, зачем и, главное, как их использовать.
Чтобы закрепить новые знания, советуем посмотреть видео по теме от команды Flutter.