Всем привет! Я Женя, Flutter-разработчица в компании Surf. И сегодня я расскажу всё, что знаю, про ключи во Flutter.
Во Flutter много виджетов и в каждом есть свойство key — ключ. Используется оно не так часто и, на первый взгляд, может показаться несущественным. Но если использовать его неправильно, настанет хаос, разведутся баги, а приложение начнет вести себя неподобающе. Приставать к людям, наверное, не станет, но явно усложнит вам жизнь.
У ключей во Flutter есть несколько разновидностей. Выбрать правильный тип ключа для конкретной ситуации — сложная задача даже для опытных разработчиков. Так что идём смотреть, как правильно использовать ключи и избежать потенциальных проблем в работе приложения.
Что такое Key?
Key — это свойство, которое есть в каждом виджете. По умолчанию оно null, его можно задать.
Основное назначение key — идентификация виджета. Оно играет важную роль в обновлении дерева элементов (Element).
Каждому виджету соответствует элемент. Элементы связаны друг с другом и образуют дерево. Таким образом, элемент — это представление виджета в определенном месте дерева. Подробнее об устройстве Flutter под капотом тут.
Если виджет поменялся, а его ключ и тип аналогичны старым, то его Element просто поменяет ссылку на виджет. В противном случае Element может быть перемещен, пересоздан или удален. Это важно, когда виджет — Stateful. При создании StatefulWidget появляется объект State, в котором хранится состояние виджета. State постоянно привязан к одному и тому же Element.
Поскольку State привязан к Element, ключи способны сохранять состояние Stateful-виджетов при перемещении по дереву.
Ключи делятся на GlobalKey и LocalKey, у которого есть три разновидности: UniqueKey, ValueKey и ObjectKey.
GlobalKey дают возможность сохранять состояние Stateful-виджетов при любом перемещении по дереву.
С помощью LocalKey можно сохранять состояние Stateful-виджетов только при перемещении на одном уровне дерева, например, изменение порядка элементов в списке.
LocalKey — локальные ключи
Основное назначение локального ключа — идентифицировать виджеты одинакового типа, которые находятся на одном уровне дерева. Это нужно для того, чтобы State каждого виджета сохранялся при перемещении, удалении или добавлении Stateful-виджетов.
UniqueKey — уникальный локальный ключ, который не принимает значение.
Используйте его осторожно, поскольку один его экземпляр равен только самому себе — из-за этого пересоздание ключа приводит к пересозданию Element и State.
ValueKey и ObjectKey похожи. Оба они принимают любые значения. Разница в том, как Flutter сравнивает их между собой.
Давайте взглянем на оператор равенства у ValueKey:
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ValueKey<T>
&& other.value == value;
}
И у ObjectKey:
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ObjectKey
&& identical(other.value, value);
}
Они почти идентичны (исключение — последняя строка). ValueKey сравнивает value с помощью оператора ==, а ObjectKey — с помощью identical(), то есть по ссылке.
Отличия локальных ключей
Давайте разберем приложение TODO-лист, чтобы понять различия локальных ключей. Допустим, каждому элементу списка нужно задавать случайный цвет из палитры. При этом цвет у одной и той же задачи никогда не должен меняться. И еще в нашем приложении можно будет добавлять и удалять задачи.
Ниже — код сущности задачи. У неё есть название и признак завершенности.
/// Модель задачи
class TodoItem {
final String title;
final bool done;
const TodoItem({required this.title, this.done = false});
TodoItem copyWith({
String? title,
bool? done,
}) {
return TodoItem(
title: title ?? this.title,
done: done ?? this.done,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other is TodoItem && runtimeType == other.runtimeType && title == other.title && done == other.done);
}
@override
int get hashCode => Object.hashAll([title, done]);
}
Дальше — палитра цветов и виджет для одной задачи. Задача содержит состояние, в котором хранится цвет, заданный ей рандомно при инициализации виджета.
/// Палитра цветов для задач
const todoItemColors = [
Colors.red,
Colors.yellow,
Colors.green,
Colors.blue,
Colors.purple,
Colors.orange,
Colors.pink,
];
/// Виджет одной задачи
class TodoItemWidget extends StatefulWidget {
final TodoItem item;
final VoidCallback onDelete;
final ValueChanged<bool?> onChecked;
const TodoItemWidget({
super.key,
required this.item,
required this.onDelete,
required this.onChecked,
});
@override
State<TodoItemWidget> createState() => _TodoItemWidgetState();
}
class _TodoItemWidgetState extends State<TodoItemWidget> {
late final Color color;
@override
void initState() {
super.initState();
// При инициализации виджета выбирается случайный цвет из палитры
final randomIndex = Random().nextInt(todoItemColors.length);
color = todoItemColors[randomIndex];
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: ListTile(
tileColor: color,
leading: Checkbox(value: widget.item.done, onChanged: widget.onChecked),
trailing: IconButton(onPressed: widget.onDelete, icon: const Icon(Icons.delete)),
title: Text(widget.item.title),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
}
А это корневой виджет приложения, в State которого находится список задач (переменная todoList).
/// Виджет списка задач
class TodoList extends StatefulWidget {
const TodoList({super.key});
@override
State<TodoList> createState() => _TodoListState();
}
class _TodoListState extends State<TodoList> {
final List<TodoItem> todoList = [];
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TODO-лист',
home: Scaffold(
appBar: AppBar(
title: const Text('TODO Лист'),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Column(
children: [
// Поле ввода новой задачи
TodoInput(
onAdd: _onAdd,
),
const SizedBox(height: 20),
// Список задач
Expanded(
child: ListView(
children: todoList
.mapIndexed(
(index, item) => TodoItemWidget(
item: item,
onDelete: () => _onDelete(index),
onChecked: (value) => _onChecked(value, index, item),
),
)
.toList(),
),
),
],
),
),
),
);
}
void _onAdd(String value) {
setState(() {
todoList.add(TodoItem(title: value));
});
}
void _onDelete(int index) {
setState(() {
todoList.removeAt(index);
});
}
void _onChecked(bool? value, int index, TodoItem item) {
setState(() {
if (value != null) todoList[index] = item.copyWith(done: value);
});
}
}
/// Виджет поля ввода новой задачи
class TodoInput extends StatefulWidget {
final ValueChanged<String> onAdd;
const TodoInput({super.key, required this.onAdd});
@override
State<TodoInput> createState() => _TodoInputState();
}
class _TodoInputState extends State<TodoInput> {
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Введите задачу',
suffix: IconButton(
onPressed: () => widget.onAdd(_controller.text),
icon: const Icon(Icons.add),
),
),
);
}
}
В итоге приложение будет выглядеть вот так:
Цвет у одной и той же задачи изменяется при удалении задач, а это не то, что нам нужно. Мы хотим, чтобы у каждой задачи был свой постоянный цвет.
Почему так происходит? Давайте заглянем внутрь и разберемся, как проходит обновление списка.
Сейчас значение свойства key у виджетов не определено, то есть null.
В момент удаления задачи происходит вызов setState, из-за чего дерево виджетов обновляется. Создаются два новых виджета на основе обновленного списка задач.Но элементов в дереве все еще три, поскольку они «живут» дольше, чем виджеты. После Flutter начинает обновлять ссылки элементов на виджеты.
Когда дерево виджетов обновляется (если мы, например, удалили задачу из списка), то элементы не всегда создаются заново. Они берут старые и новые виджеты и сравнивают их типы и ключи. Если они равны, то элементы посчитают, что это один и тот же виджет.
Элементы остались на тех же местах в дереве, но теперь ссылаются на другие виджеты. Из-за этого обновленные виджеты имеют то же состояние, что и виджеты до обновления.
Чтобы приложение работало правильно, нужно сделать так, чтобы Flutter при сравнении виджетов не считал их одинаковыми. Вот здесь и понадобятся ключи.
Добавим их в наш код.
Для начала попробуем UniqueKey:
TodoItemWidget(
key: UniqueKey(),
item: item,
onDelete: () => _onDelete(index),
onChecked: (value) => _onChecked(value, index, item),
),
Но проблема не исчезает:
Теперь Flutter не только не перемещает элементы, но и провоцирует их пересоздание. Напомним, один экземпляр UniqueKey равен самому себе. А у нас он меняется при каждом пересоздании TodoItemWidget.
В момент сравнения виджетов их ключи уже не null. А поскольку ключ всегда уникален, при создании нового виджета, он не будет таким же как у старого.
Таким образом, Flutter посчитает виджеты разными и будет искать соседний виджет (то есть на том же уровне дерева) с идентичным ключом. И он его не найдет — ключи у всех виджетов приняли новое уникальное значение. Так Flutter удалит все элементы вместе со стейтами. На их место он поставит новые элементы с новыми цветами. Именно поэтому в предыдущем видео при каждом удалении у элементов меняются цвета.
Попробуем ValueKey:
TodoItemWidget(
key: ValueKey(item),
item: item,
onDelete: () => _onDelete(index),
onChecked: (value) => _onChecked(value, index, item),
),
И это решит нашу проблему.
Теперь Flutter выясняет, что виджеты не равны и, в отличии от предыдущего примера, он находит виджет с тем же ключом среди старых виджетов на том же уровне дерева. Затем он перемещает элементы на нужные места, благодаря чему мы получаем нужное нам поведение.
А теперь попробуем добавить в список задачу с повторяющимся именем.
Приложение сломается, и в консоли мы увидим ошибку.
Почему так вышло? ValueKey сравнивает переданные в него значения с помощью оператора ==. Таким образом, два абсолютно одинаковых TodoItem — это два одинаковых значения, что и приводит к ошибке.
Избежать этих проблем поможет, сюрприз-сюрприз, ObjectKey:
TodoItemWidget(
key: ObjectKey(item),
item: item,
onDelete: () => _onDelete(index),
onChecked: (value) => _onChecked(value, index, item),
),
По ссылке на область памяти он идентифицирует переданные в него значения. Так два ключа с абсолютно одинаковыми объектами в значении будут считаться разными, если эти объекты — не один и тот же экземпляр. С помощью ObjectKey приложение начнет работать корректно.
Но есть нюанс. В нашей реализации сущности неизменяемы и обновляются с помощью метода copyWith(). И если мы попробуем нажать на чекбокс, то цвет задачи будет изменен, поскольку обновленная задача — уже новый экземпляр.
Эта проблема решается, если добавить уникальный id к модели TodoItem и использовать ValueKey(item.id):
TodoItemWidget(
key: ValueKey(item.id),
item: item,
onDelete: () => _onDelete(index),
onChecked: (value) => _onChecked(value, index, item),
),
Когда какой ключ использовать?
UniqueKey генерирует уникальный идентификатор каждый раз, когда создается его новый экземпляр. Поэтому нужно учитывать проблемы, которые были в начале примера про отличие локальных ключей (приложение TODO-лист), и использовать его правильно. Когда какой ключ использовать?
ValueKey использует значение для идентификации виджета. Он полезен, когда есть данные, по которым можно уникально идентифицировать виджет. Например, ID элемента списка.
ObjectKey использует ссылку на объект для идентификации виджета. Он важен, когда экземпляр объекта служит уникальным идентификатором виджета.
А напоследок GlobalKey
GlobalKey уникален во всем приложении, а LocalKey уникален только на одном уровне дерева.
GlobalKey позволяет:
перемещать виджеты с состоянием по всему приложению, а не только на одном уровне;
получать доступ к информации и вызывать методы виджета из любого места приложения.
С помощью GlobalKey мы запускаем механизм переиспользования. Он тяжелый, поэтому здесь нужна осторожность. Однако при разумном использовании в тяжелой вёрстке это даст прирост в производительности.
Например, если есть тяжелый Stateful-виджет, который необходимо обернуть в разные виджеты в зависимости от ситуации, то без GlobalKey этот Stateful-виджет будет пересоздаваться при каждом изменении обертки, что может негативно сказаться на производительности приложения.
GlobalKey можно встретить при использовании Form, Navigator, AnimationList. А еще его часто применяют в виджет-тестах.
Ещё один пример. Добавим к форме глобальный ключ и вызовем метод validate() из стейта формы.
class App extends StatefulWidget {
const App({super.key});
@override
AppState createState() {
return AppState();
}
}
class AppState extends State<App> {
// Создаем глобальный ключ для формы, чтобы получить доступ к её методу validate()
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Поле не должно быть пустым';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: () {
// Вызываем метод validate() из стейта формы через глобальный ключ
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Отправка...')),
);
}
},
child: const Text('Отправить'),
),
),
],
),
);
}
Небольшой итог
Ключи помогают фреймворку отличать виджеты друг от друга. В основном это нужно для перемещения Stateful-виджетов в дереве с сохранением их состояния.
GlobalKey также применяется для доступа к переменным и методам объекта State у Stateful-виджета.
LocalKey имеет несколько разновидностей. Какую из них использовать, зависит от ситуации.
ValueKey можно применять, когда есть данные, по которым можно уникально идентифицировать виджет (например, ID элемента списка).
ObjectKey предназначен для случаев, когда экземпляр объекта можно использовать в качестве уникального идентификатора.
UniqueKey поможет, когда в коллекции есть одинаковые значения и нужно убедиться, что каждый виджет отличается от остальных.
Больше полезного про Flutter — в Telegram-канале Surf Flutter Team. Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!