Введение

Работая с виджетами во Flutter, вы, наверное, уже заметили, что у всех виджетов есть параметр Key, или так называемый ключ. В этом параграфе мы рассмотрим, что такое ключи, когда, зачем и как их стоит использовать.

Что такое Key

Key — это уникальный идентификатор виджета, который используется при перестроении дерева виджетов для того, чтобы отличать друг от друга одинаковые по типу виджеты.

Ключи необходимо использовать, когда у одного родителя (например, Column или Row) несколько дочерних виджетов одного типа и у этих виджетов есть состояние. Если мы поменяем такие виджеты местами или удалим один из них, фреймворк не сможет отличить один виджет от другого, так как у них одинаковый тип.

Давайте рассмотрим наглядный пример: у нас есть Column, в котором находятся два StatefulWidget виджета MyStatefulWidget, и мы удаляем один из них.

fluttern

Какой из виджетов мы удалили? В данном случае фреймворк будет ориентироваться на позицию виджетов внутри children и удалит второй, но, если мы хотим удалить первый, нужно, чтобы фреймворк их мог различить. Именно для этого используются ключи.

fluttern

Пример использования ключей

Давайте рассмотрим следующий пример. У нас есть виджет ColorBlock — это StatefulWidget, который представляет собой цветной Container. При инициализации для Container задаётся переданный извне цвет. По тапу на ColorBlock цвет меняется по определённой логике, а именно — параметры цвета red/green /blue инкрементируются:

class ColorBlock extends StatefulWidget {
  final Color color;

  const ColorBlock({
    required this.color,
    Key? key,
  }) : super(key: key);

  @override
  State<ColorBlock> createState() => _ColorBlockState();
}

class _ColorBlockState extends State<ColorBlock> {
  late Color color;

  @override
  void initState() {
    super.initState();
    color = widget.color;
  }

  void incrementColor() {
    setState(() {
      color = Color.fromARGB(
        color.alpha,
        (color.red + 4) % 256,
        (color.green + 4) % 256,
        (color.blue + 4) % 256,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: incrementColor,
      child: Container(
        color: color,
        width: 100.0,
        height: 100.0,
      ),
    );
  }
}

Также у нас есть виджет MyHomePage — это StatefulWidget, который представляет собой экран. На экране располагается список виджетов ColorBlock, а также есть виджет FloatingActionButton, по тапу на который наш список виджетов ColorBlock разворачивается:

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _colorBlocks = <Widget>[];

  @override
  void initState() {
    super.initState();
    final random = Random().nextInt(Colors.primaries.length - 2);
    _colorBlocks = [
      ColorBlock(
        color: Colors.primaries[random],
      ),
      ColorBlock(
        color: Colors.primaries[random + 1],
      ),
      ColorBlock(
        color: Colors.primaries[random + 2],
      ),
    ];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(children: _colorBlocks),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.swap_vert),
        onPressed: () {
          setState(() {
            _colorBlocks = _colorBlocks.reversed.toList();
          });
        },
      ),
    );
  }
}

Запустив приложение, мы увидим следующее:

Когда мы нажимаем на виджет ColorBlock, цвет ожидаемо меняется, а если нажать на виджет FloatingActionButton, ничего не происходит, хотя мы меняем последовательность ColorBlock и вызываем setState. Это происходит потому, что фреймворк не может отличить виджеты ColorBlock друг от друга и определить, что их последовательность поменялась.

Давайте воспользуемся ключами и поправим данный пример. При создании виджетов ColorBlock будем передавать туда уникальный Key. В классе Key есть factory-конструктор, который принимает String и создаёт ключ.

_colorBlocks = [
  ColorBlock(
    color: Colors.primaries[random],
    key: Key(Colors.primaries[random].toString()),
  ),
  ColorBlock(
    color: Colors.primaries[random + 1],
    key: Key(Colors.primaries[random + 1].toString()),
  ),
  ColorBlock(
    color: Colors.primaries[random + 2],
    key: Key(Colors.primaries[random + 2].toString()),
  ),
];

Теперь, когда мы нажимаем на FloatingActionButton, всё работает как ожидается:

Может возникнуть ощущение, что важно добавлять ключи к StatefulWidget или к первому StatefulWidget среди дочерних виджетов, но это не так. Давайте обернём наши ColorBlock в Padding. Вероятно, вы ожидаете, что к нашим ColorBlock просто добавится Padding, а в остальном поведение не изменится:

_colorBlocks = [
  Padding(
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random],
      key: Key(Colors.primaries[random].toString()),
    ),
  ),
  Padding(
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random + 1],
      key: Key(Colors.primaries[random + 1].toString()),
    ),
  ),
  Padding(
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random + 2],
      key: Key(Colors.primaries[random + 2].toString()),
    ),
  ),
];

Однако, когда мы нажимаем на виджет FloatingActionButton, все наши ColorBlock вместе с Padding меняются местами, но состояние ColorBlock теряется. Всё дело в том, что при перестановке Padding фреймворк переиспользует те же самые Padding, для него ничего не меняется, но дочерний виджет у каждого Padding меняет ключ, и поэтому State создаётся заново. Это легко исправить, указав ключ у самого Padding:

_colorBlocks = [
  Padding(
    key: Key(Colors.primaries[random].toString()),
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random],
    ),
  ),
  Padding(
    key: Key(Colors.primaries[random + 1].toString()),
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random + 1],
    ),
  ),
  Padding(
    key: Key(Colors.primaries[random + 2].toString()),
    padding: const EdgeInsets.all(8.0),
    child: ColorBlock(
      color: Colors.primaries[random + 2],
    ),
  ),
];

Типы ключей

Итак, мы разобрались, для чего нужны ключи, давайте теперь рассмотрим, какие они бывают. Есть множество ключей, и они делятся на две группы: LocalKey и GlobalKey, выглядит это следующим образом:

fluttern

  • 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.

class RandomColorBlock extends StatefulWidget {
  const RandomColorBlock({Key? key}) : super(key: key);

  @override
  State<RandomColorBlock> createState() => _RandomColorBlockState();
}

class _RandomColorBlockState extends State<RandomColorBlock> {
  late Color color;

  @override
  void initState() {
    super.initState();
    final random = Random();
    color = Color.fromARGB(
      255,
      random.nextInt(256),
      random.nextInt(256),
      random.nextInt(256),
    );
  }

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _colorBlocks = <Widget>[];

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

  void _generateRandomColorBlocks() {
    setState(() {
      _colorBlocks = List.generate(
        3,
        (_) => RandomColorBlock(key: UniqueKey()),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Column(children: _colorBlocks),
      floatingActionButton: FloatingActionButton(
        onPressed: _generateRandomColorBlocks,
        child: const Icon(Icons.swap_vert),
      ),
    );
  }
}

GlobalKey — это абстрактный класс, наследуется от Key, общий предок для всех глобальных ключей. Особенность глобальных ключей в том, что они должны быть уникальны для всего приложения.

LabeledGlobalKey — своего рода глобальный аналог UniqueKey, уникален и равен сам себе. У ключа есть debug label, который никак не влияет на его уникальность и используется только для целей дебага.

GlobalObjectKey — своего рода глобальный аналог ObjectKey, равенство / уникальность определяются по инстансу Object (то есть фактически по ссылке в памяти).

Глобальные ключи могут использоваться в двух целях:

  • переместить виджет и его State из одной части дерева виджетов в совершенно другое место в дереве виджетов (иными словами, поменять родителя виджета), при этом при перемещении виджет с нашим ключом не должен покидать дерево виджетов и не должен быть использован в двух разных местах одновременно;
  • получить доступ к State/BuildContext StatefulWidget из любой точки приложения.

Используем GlobalKey

Представьте, что в примере со списком из ColorBlock вёрстка в portrait- и landscape-ориентации сильно различается: в landscape добавляется дополнительный Padding к Column.

@override
Widget build(BuildContext context) {
  return OrientationBuilder(builder: (context, orientation) {
    return Scaffold(
     appBar: AppBar(
        title: Text(widget.title),
      ),
      body: orientation == Orientation.portrait
          ? Column(
              children: _colorBlocks,
            )
          : Padding(
              padding: const EdgeInsets.all(8.0),
              child: Column(
                children: _colorBlocks,
              ),
            ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.swap_vert),
        onPressed: () {
          setState(() {
            _colorBlocks = _colorBlocks.reversed.toList();
          });
        },
      ),
    );
  });
}

В таком случае, поскольку при повороте экрана у виджета Scaffold меняется виджет body, у нас будет пересоздаваться всё поддерево body. Чтобы решить эту проблему, мы можем воспользоваться глобальным ключом. Давайте добавим его к Column и посмотрим, что произойдёт.

class _MyHomePageState extends State<MyHomePage> {
  final _columnKey = GlobalKey();
@override
Widget build(BuildContext context) {
  return OrientationBuilder(builder: (context, orientation) {
    return Scaffold(
     appBar: AppBar(
        title: Text(widget.title),
      ),
      body: orientation == Orientation.portrait
          ? Column(
              key: _columnKey,
              children: _colorBlocks,
            )
          : Padding(
              padding: const EdgeInsets.all(8.0),
              child: Column(
                key: _columnKey,
                children: _colorBlocks,
              ),
            ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.swap_vert),
        onPressed: () {
          setState(() {
            _colorBlocks = _colorBlocks.reversed.toList();
          });
        },
      ),
    );
  });
}

Как видите, GlobalKey позволяет поменять родителя Column и смонтировать его в любом другом месте без потери состояния. Происходит это за один фрейм, и Column не уходит из дерева виджетов.

Также необходимо следить за тем, чтобы в один фрейм один и тот же глобальный ключ не был использован более чем в одном виджете. Иначе мы получим следующую ошибку: Duplicate GlobalKey detected in widget tree.

Очень важно понимать, что использование глобальных ключей для сохранения состояния и перемещения виджета не самая дешёвая операция, поэтому не рекомендуется без необходимости использовать для этого глобальные ключи. По возможности желательно выносить состояние из виджета в слой презентационной логики или в инхеритед-виджеты.

В примере выше мы могли вместо добавления виджета Padding в landscape добавить его в обеих ориентациях и только менять значение параметра padding. Так и стоит поступать, если есть такая возможность.

@override
Widget build(BuildContext context) {
  return OrientationBuilder(builder: (context, orientation) {
    return Scaffold(
     appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: orientation == Orientation.portrait 
					? EdgeInsets.zero
					: const EdgeInsets.all(8.0),
        child: Column(
          children: _colorBlocks,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.swap_vert),
        onPressed: () {
          setState(() {
            _colorBlocks = _colorBlocks.reversed.toList();
          });
        },
      ),
    );
  });
}

Теперь давайте рассмотрим пример с получением доступа к State. На нашем экране сейчас есть три ColorBlock и FloatingActionButton, который их разворачивает. Давайте воспользуемся GlobalKey и поменяем логику следующим образом: будем по тапу на FloatingActionButton инкрементировать все ColorBlock, то есть вызывать incrementColor у каждого ColorBlock. Для этого нужно внести следующие изменения:

  • в каждый ColorBlock передать GlobalKey<_ColorBlockState>;
  • по тапу на FloatingActionButton пробегаться по всем ключам, получать доступ к State через key.currentState и вызывать incrementColor.
class _MyHomePageState extends State<MyHomePage> {
  final _colorBlockKeys = <GlobalKey<_ColorBlockState>>[];
}
@override
void initState() {
  super.initState();
  final random = Random().nextInt(Colors.primaries.length - 2);
  _colorBlocks = [
    ColorBlock(
      key: GlobalKey<_ColorBlockState>(),
      color: Colors.primaries[random],
    ),
    ColorBlock(
      key: GlobalKey<_ColorBlockState>(),
      color: Colors.primaries[random + 1],
    ),
    ColorBlock(
      key: GlobalKey<_ColorBlockState>(),
      color: Colors.primaries[random + 2],
    ),
  ];
  for (final colorBlock in _colorBlocks) {
    final key = colorBlock.key;
    if (key is GlobalKey<_ColorBlockState>) {
      _colorBlockKeys.add(key);
    }
  }
}
floatingActionButton: FloatingActionButton(
  child: const Icon(Icons.swap_vert),
  onPressed: () {
    for (final key in _colorBlockKeys) {
      key.currentState?.incrementColor();
    }
  },
),

Таким образом GlobalKey позволяет получить доступ к State из любой точки приложения.

Этим также не стоит злоупотреблять — управлять состоянием виджетов лучше с помощью отдельных классов с бизнес-логикой / презентационной логикой. Подробнее о State Management и различных подходах к управлению состояниями мы расскажем в следующих параграфах.

Тем не менее бывают кейсы, когда глобальные ключи удобны. Например, для навигации. Об этом мы расскажем подробнее в параграфе.


Итак, в этом параграфе мы рассмотрели, что такое ключи, какие они бывают, когда, зачем и, главное, как их использовать.

Чтобы закрепить новые знания, советуем посмотреть видео по теме от команды Flutter.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.8. Widgets: standard widgets
Следующий параграф2.10. Widgets: layout