Всем привет! Мы продолжаем делиться полезными советами для мобильных разработчиков. С разрешения автора переводим свежий гайд по ключам Flutter, который может быть интересен как начинающим, так и опытным специалистам.

Ключи в Flutter обычно используют для однозначной идентификации виджетов в коллекции и для того, чтобы сохранять состояние StatefulWidgets во время их замены или перемещения в дереве виджетов. Ключи в качестве необязательных параметров в своих конструкторах принимают почти все виджеты Flutter.

А вы задумывались, когда нужно использовать ключ и что происходит “под капотом”? В этом руководстве мы найдем ответ, создав простое приложение для управления списком задач и отображения заголовков новостей. Вы узнаете:

  • Какие бывают ключи и как они работают.

  • Когда использовать ключ.

  • Как работать с разными типами ключей

Примечание: этот туториал требует некоторого опыта с Flutter и виджетами. Если у вас мало опыта, вы можете изучить инструкцию “Как начать работать с Flutter”, видеокурс Flutter UI WIdgets или книгу Flutter Apprentice.

Загрузите стартовый проект, нажав кнопку «Загрузить материалы» вверху или внизу руководства.

В этом руководстве используется Android Studio 4.1. Некоторые из скриншотов относятся к нему, но вы также можете использовать Visual Studio Code или IntelliJ.

Вы будете работать над The Morning App, одностраничным приложением, которое отображает список задач на одной вкладке и список новостных статей на другой. Вот что нужно сделать с каждой страницей:

  • Задачи: добавьте новую задачу, отметьте ее как выполненную или удалите задачу.

  • Новости: показать последние новостные статьи из HackerNews и по тапу на статью показать некоторые ее метаданные.

Вот так будут выглядеть страницы, когда вы их закончите:

Создание стартового проекта 

Стартовый проект уже содержит логику для получения статей из HackerNews, сохранения задач в кэш и чтения задач из кэша.

Откройте Android Studio и выберите «Открыть существующий проект». Затем выберите стартовую папку из загруженных материалов.

Подтяните зависимости, объявленные в pubspec.yaml, кликом на Pub get вверху панели, когда будет открыт файл pubspec.yaml.

Для этого руководства наиболее важными файлами в проекте являются:

  • lib/ui/ home/home_page.dart – главная страница приложения, содержит две вкладки для отображения задач и новостных статей.

  • lib / ui / todos / todos_page.dart – класс виджета страницы, связанной с вкладкой задач.

  • lib / ui / news / news_page.dart – класс виджета страницы, связанной с вкладкой новостей.

  • lib / ui / todos / add_todo_widget.dart – класс виджета, представляющий нижний лист, где пользователь может добавить новую задачу на страницу задач.

Собираем и запускаем. Приложение запускается с выбранной вкладкой задач.

Теперь, зная содержание начального проекта, рассмотрим глубже, что такое ключи и почему вы их используете.

Понимание ключей

У каждого виджета Flutter может быть ключ, но их добавление не всегда полезно. Попробуем понять ключи:

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

  • Явное указание ключа для виджета помогает Flutter понять, какой виджет необходимо обновлять при изменении состояния.

  • Помимо прочего, ключи также сохраняют и восстанавливают текущую позицию прокрутки в списке виджетов.

Рассмотрим пример, чтобы лучше понять это.

Когда Flutter раскладывает дерево виджетов, он строит соответствующее дерево элементов. Внутренне он сопоставляет каждый виджет в дереве виджетов с элементом в дереве элементов. Дерево виджетов содержит информацию о пользовательском интерфейсе, а дерево элементов – о структуре приложения. Это означает, что у каждого элемента есть подробные данные:

  • RuntimeType соответствующего виджета в дереве виджетов.

  • Ссылка на соответствующий виджет в дереве виджетов.

  • Ссылка на его дочерний элемент.

Вы можете извлечь остальную информацию из элемента в древе виджетов через ссылку, которую хранит каждый элемент.

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

У StatelessWidgets нет ключей. Следовательно, если элемент имеет тот же тип, что и соответствующий новый виджет, элемент обновляет свою ссылку, чтобы указывать на новый виджет, и удаляет ссылку на старый виджет.

Обработка перемещений в StatefulWidgets

Однако в случае StatefulWidget элемент также хранит ссылку на состояние виджета, например State.

Следовательно, новый виджет может иметь тот же runtime type, что и старый виджет, но в другом состоянии. Основываясь на приведенной выше логике, Flutter обновит ссылку на виджет в элементе, чтобы указать на новый виджет, но элемент все равно будет содержать ссылку на состояние из старого виджета. Это проблема.

Использование ключей во избежание неожиданных результатов

Добавление ключа к виджету, содержащему ссылку на состояние, позволяет Flutter проводить дополнительное сравнение помимо типа виджета. Это гарантирует, что когда типы совпадают, а ключи нет, Flutter заставляет элементы переходить к их ссылке на виджет и удерживать ссылки на виджеты, у которых совпадают и тип, и ключ. Это гарантирует, что ссылки на виджет и состояние обновляются правильно.

Чуть позже мы рассмотрим это в действии.

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

Изменение порядка задач

На этом этапе стартовый код отображает задачи. Ваша первая цель – дать пользователям возможность сортировать элементы задач, перетаскивая их на новые позиции в списке.

В lib/ui/todos/todos_page.dart замените // TODO: Reorder To-dos на:

// 1
void reorderTodos(int oldIndex, int newIndex) {
  // 2
  if (oldIndex < newIndex) {
    newIndex -= 1;
  }
  
  // 3
  final item = todos.removeAt(oldIndex);
  setState(() {
    todos.insert(newIndex, item);
  });
}

Вот что вы сделали:

  • Вы добавили функцию для изменения порядка задач. Эта функция принимает в качестве параметров два индекса: oldIndex – это индекс задачи, позиция которой изменится, а newIndex – это новый индекс, в который вы поместите задачу.

  • Поскольку вы собираетесь удалить задачу из старого индекса, а затем вставить ее в новый, вы вычли 1 из newIndex, если он находится после oldIndex.

  • Вы удалили элемент в oldIndex и вставили его в newIndex. Затем вы вызвали setState, чтобы пользовательский интерфейс отразил изменения.

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

Включение Drag and drop

Начните с перехода к _TodosPageState и измените buildTodoList следующим образом:

ReorderableListView buildTodoList() {
  // 1
  return ReorderableListView(
    padding: const EdgeInsets.only(bottom: 90),
    children: todos
        .map(
          (todo) => TodoItemWidget(
            todo: todo,
            isLast: todo == todos.last,
            todosRepository: widget.todosRepository,
          ),
        )
        .toList(),
    // 3
    onReorder: reorderTodos,
  );
}

Вот что происходит выше:

  • Вы заменили ListView на Flutter ReorderableListView, чтобы вы могли перетаскивать элементы задач и изменять их положение в списке.

  • Эта функция использует reorderTodos для onReorder. Эта функция будет вызываться всякий раз, когда перетаскиваете задачу в списке.

Добавление ключей

TodoItemWidget – это StatefulWidget, который хранит состояние задачи. Поскольку вы работаете с коллекцией StatefulWidget’ов, вы добавите ключ, который идентифицирует каждый элемент задачи.

В Flutter есть два типа ключей: GlobalKey и LocalKey.

Различные типы LocalKey:

  • ValueKey: ключ, использующий простое значение, например String.

  • ObjectKey: ключ, который использует более сложный формат данных, а не примитивный тип данных, такой как String.

  • UniqueKey: ключ, который уникален только сам по себе. Применяйте этот тип ключа, когда у вас нет других данных, которые делают виджет уникальным при использовании ValueKey или ObjectKey.

Добавьте ключ из следующего фрагмента в строку, в которой создается экземпляр TodoItemWidget в buildTodoList в todos_page.dart:

(todo) => TodoItemWidget(
  key: ObjectKey(todo),
  ...
),

В этом случае нет уникальных идентификаторов для задачи. Что делает задачи уникальным, так это данные: текст, приоритет и срок выполнения. Таким образом, использование ObjectKey является наиболее подходящим вариантом, если предположить, что пользователь никогда не добавит две задачи, содержащих точно такую же информацию.

Примечание. Прежде чем использовать объект как ObjectKey, убедитесь, что он сравнивается корректно (сопоставим). Если нет, то необходимо переопределить оператор == и геттер hashCode, чтобы гарантировать, что любые два объекта этого класса, содержащие одни и те же данные, эквиваленты друг другу (оператор == вернет true)

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

Использование глобальных ключей

Ключи, которые вы видели выше – это LocalKeys, которые не уникальны в глобальном масштабе для всей иерархии виджетов приложения. Вы также можете использовать некоторые ключи в качестве уникальных ссылок во всем приложении. Это работа GlobalKeys.

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

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

Один из наиболее распространенных способов использования GlobalKey – это Form, как вы увидите в следующем разделе.

Примечание. GlobalKeys подобны глобальным переменным: старайтесь не злоупотреблять ими. Почти всегда есть лучшие способы сохранить состояние в глобальном масштабе, используя правильное решение для управления состоянием.

Добавление задач

Несмотря на то, что вы можете просматривать задачи в приложении на этом этапе, вы не можете их добавить. Пора это исправить.

Перейдите в lib/ui/todos/add_todo_widget.dart и замените TODO: Добавление GlobalKey для FormState на:

// 1
final formKey = GlobalKey<FormState>();

// 2
void addTodo() {
  // 3
  if (formKey.currentState!.validate()) {
    // 4
    formKey.currentState!.save();
    // 5
    return widget.onSubmitTap(todo);
  }
}

Вот подробный разбор кода выше:

  1. Вы добавили новый GlobalKey для использования в создаваемой форме. Это гарантирует, что фокус элементов ввода уникален для всего приложения.

  2. addTodo срабатывает, когда вы нажимаете кнопку «Отправить», чтобы добавить новую задачу.

  3. Здесь вы проверяете, валидно ли currentState для всех полей ввода. Это запускает валидатор в каждом TextFormField. Если все валидаторы в TextFormFields возвращают null вместо строки с ошибкой, это означает, что все поля имеют допустимый ввод. Следовательно, formKey.currentState!.validate () вернет true. Обратите внимание, что вы используете здесь глобальный formKey, чтобы получить состояние формы.

  4. Вы вызываете сохранение формы currentState. Это вызывает onSaved в каждом TextFormField в форме.

  5. Поскольку родительский виджет содержит список задач, вам необходимо сделать вновь созданную задачу доступным для списка задач. Для этого вы передаете новую задачу в onSubmitTap, чтобы родительский виджет добавил ее в список.

На этом этапе вы уже создали formKey в _AddTodoWidgetState. Однако вы еще не назначили этот ключ форме.

Затем вы будете использовать следующий фрагмент в lib / ui / todos / add_todo_widget.dart, чтобы обернуть Padding в функции build внутри _AddTodoWidgetState:

@override
Widget build(BuildContext context) {
  ...
  // 1
  return Form(
    // 2
    key: formKey,
    child: Padding(
      padding: const EdgeInsets.all(15),
      child: ...
    ),
  );
}

В приведенном выше коде:

  1. Оберните все текстовые поля в форме, чтобы связать их друг с другом как единое целое.

  2. Установите уже созданный formKey в качестве ключа для этой формы.

Добавление задач в список

Затем, чтобы добавить задачу, вам нужно вызвать addTodo, когда пользователь нажимает кнопку «Отправить» внизу формы.

Чтобы реализовать это, перейдите в buildSubmitButton и добавьте addTodo в качестве колбека onPressed:

return ElevatedButton(
  style: ...,
  onPressed: addTodo,
  child: ...
);

Hot reload. Теперь вы можете добавлять новые задачи.

Сохранение положения скрола

В приложении есть два списка, расположенных рядом друг с другом на разных вкладках: список задач и список новостей. Было бы неплохо сохранить позицию прокрутки в обоих списках при перемещении между ними.

Чтобы сохранить состояние виджета даже после его уничтожения, Flutter предлагает использовать PageStorageKeys. Эти ключи в сочетании с PageStorageBucket позволяют использовать ключ для сохранения и восстановления состояния.

Перейдите в lib / ui / home / home_page.dart и замените // TODO: Preserve Scroll Position on tab change на:

final PageStorageBucket _bucket = PageStorageBucket();

PageStorageBucket содержит состояние каждой страницы и сохраняет его, когда пользователь перемещается между страницами.

Теперь, когда вы создали bucket, добавьте уникальный PageStorageKey на каждую страницу в _HomePageState следующим образом:

final pages = <Widget>[
  TodosPage(
    key: const PageStorageKey('todos'),
    ...
  ),
  NewsPage(
    key: const PageStorageKey('news'),
    ...
  ),
];

Здесь вам нужно убедиться, что страница задач и страница новостей имеют уникальные идентификаторы. Для этого укажите два жестко заданных значения для PageStorageKeys: todos и news.

Затем, чтобы связать эти два ключа с PageStorageBucket, оберните body у Scaffold с помощью PageStorage:

return Scaffold(
  appBar: ...,
  body: PageStorage(
    child: pages[currentTab],
    bucket: _bucket,
  ),
  bottomNavigationBar: ...,
);

Здесь PageStorage связывает PageStorageBucket с PageStorageKeys.

Теперь выполните hot restart. Вы можете видеть, что приложение теперь сохраняет положение прокрутки обоих списков.

Сохранение состояния новостей

Теперь вы добавили PageStorage с PageStorageKeys на HomePage. Однако каждый раз, когда вы открываете вкладку новостей, приложение показывает индикатор загрузки при получении новостных статей из HackerNews. Это может изменить порядок новостных статей в списке.

Чтобы исправить это, используйте PageStorage для хранения всего списка новостей в дополнение к текущей позиции прокрутки списка.

Для этого перейдите в lib / ui / news / news_page.dart и замените // TODO: Preserve News State on tab changeследующим фрагментом:

saveToPageStorage(shuffledNews);

Это сохранит список извлеченных новостных статей в PageStorage.

Наконец, в initState _NewsPageState вы замените fetchNews (); с updateNewsState (); поэтому вы больше не будете загружать новостные статьи из сети при каждой инициализации NewsPage.

Сделаем hot restart. Теперь, когда вы переключаетесь на вкладку «Новости», приложение сохраняет состояние прокрутки и извлекает новостные статьи из сети только при первой инициализации NewsPage, как и следовало ожидать.

Но что делать, если вы уже прочитали все интересное? Дальше займемся получением новых статей.

Перезагрузка новостных статей

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

В NewsPage замените у Scaffold, body на следующее:

body: isLoading
    ? const ProgressWidget()
    : 
      // 1
      RefreshIndicator(
        child: buildNewsList(),
        // 2
        onRefresh: fetchNews,
        color: AppColors.primary,
      ),
  1. Используйте Flutter RefreshIndicator в качестве оболочки для ListView, содержащего новостные статьи.

  2. Вызовите fetchNews, когда пользователь тянет вверху списка новостей.

Hot reload. Теперь приложение будет обновлять новостные статьи с помощью индикатора обновления:

Коснитесь новостной статьи, чтобы просмотреть метаданные, включая имя автора, количество голосов и комментариев. Это развернутое состояние новостной статьи в списке.

Исправление ошибки в состоянии

Обратите внимание, что когда вы раскрываете новостную статью, а затем обновляете список, новостная статья в той же позиции расширяется после обновления. Это немного странно – можно ожидать, что развернутое состояние будет привязано к новостной статье, а не к определенной позиции в списке.

Это происходит потому, что NewsItemWidget – это StatefulWidget, который хранит состояние элемента новостей – в данном случае, развернут ли он или свернут.

Как вы читали выше, вам нужно добавить ключ в NewsItemWidget, чтобы решить эту проблему. Поэтому добавьте следующий ключ в buildNewsList:

(newsItem) => NewsItemWidget(
  key: ValueKey<int>(newsItem.id),
  ...
),

Поскольку каждая новость имеет свой уникальный идентификатор, вы можете использовать ValueKey, чтобы помочь Flutter сравнивать различные виджеты NewsItemWidgets.

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

Hot reload. Вы устранили проблему и сохранили развернутое состояние, чтобы оно соответствовало новостной статье, а не ее положению в списке статей.

Добавление разделителей

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

Для этого добавьте следующий код в метод buildNewsList () внутри news_page.dart:

...
.map(
  (newsItem) => Column(
    children: [
      NewsItemWidget(
        key: ValueKey<int>(newsItem.id),
        newsItem: newsItem,
      ),
      const Divider(
        color: Colors.black54,
        height: 0,
      ),
    ],
  ),
)
...

В buildNewsList вы в настоящее время сопоставляете каждый элемент новостей непосредственно с виджетом NewsItemWidget. Чтобы внести изменения, вам нужно сначала обернуть NewsItemWidget в Column. Затем вы добавите Devider (разделитель) после NewsItemWidget в столбце.

Hot reload. Теперь вы увидите разделитель. Тем не менее, при каждом действии обновления по запросу обратите внимание, что приложение теряет развернутое состояние новостных статей в списке.

Вы исправите это дальше.

Сохранение развернутого состояния новостей

Это происходит потому, что Flutter проверяет ключи элементов, находящихся на одном уровне. В этом случае Flutter сравнивает оба виджета NewsItemWidgets. Однако они не содержат разделителя.

Ключ всегда должен находиться на самом верхнем уровне поддерева виджета, который Flutter должен сравнивать. Следовательно, вы хотите, чтобы ваше приложение сравнивало столбцы, содержащие как NewsItemWidget, так и Divider.

Для этого в buildNewsList () переместите ключ так, чтобы он принадлежал Column, а не NewsItemWidget, как показано ниже:

.map(
  (newsItem) => Column(
    key: ValueKey<int>(newsItem.id),
    children: [
      NewsItemWidget(    
        newsItem: newsItem,
      ),
     ...
    ],
  ),
)

Flutter теперь сравнивает столбцы, как вы и предполагали.

Выполните hot reload и подтвердите, что вы снова сохраняете развернутое состояние для новостных статей при обновлении. Миссия выполнена!

Что делать дальше?

Загрузите окончательные файлы проекта, нажав кнопку «Загрузить материалы» вверху или внизу руководства.

Теперь у вас есть более глубокое понимание ключей виджетов и, что более важно, того, когда и как их применять. Вы использовали ValueKeys, ObjectKeys и PageStorageKeys, чтобы помочь Flutter сохранить состояние ваших виджетов.

Надеемся, что этот материал был вам полезен, и ждём ваших комментариев. 

Комментарии (6)


  1. paamayim
    02.08.2021 08:35

    Использовал globalKeys для создания двухуровневой навигации в приложении. Имеются табы в каждой из который свой набор роутов.При этом некоторые виджеты(роуты) могут отображаться в нескольких табах. Мне интересно кто как выкручивался, возможно уже есть решения для nested routes во флаттере? Все таки довольно распостраненный кейс.


    1. nikita_dol
      02.08.2021 10:19

      • Можно решать через of(context, rootNavigator: true/false)

      • Можно посмотреть на пакеты, которые работают с Navigator 2.0, например, routemaster (навигация будет описана в корне и потом просто пуш нужного роута)


      1. paamayim
        02.08.2021 14:53

        routemaster вроде подходящее решение. Как нибудь попробую. Надеюсь что багов не будет) Самые проблемные места обычно это навигация вперед назад между табами.


    1. mobileSimbirSoft Автор
      02.08.2021 10:27

      Предположим, что вам необходимо, чтобы на экране с TabBarView каждый таб мог переходить на новый роут. Для этого можно обернуть каждый элемент TabBarView в Navigator, что позволит переходить по роутам каждому табу, не затрагивая глобальный роут.

      TabBarView будет выглядеть следующим образом:

      Переход на другие роуты внутри таба будет происходить так же, как обычно


      1. paamayim
        02.08.2021 15:01

        Ну я примерно так и делал. Вдохновлялся этой статьей https://codewithandrea.com/articles/multiple-navigators-bottom-navigation-bar/


  1. nikita_dol
    02.08.2021 10:11

    Помимо прочего, ключи также сохраняют и восстанавливают текущую позицию прокрутки в списке виджетов.

    Сами ключи этого не делают. Это происходит с их помощью

    - ValueKey: ключ, использующий простое значение, например String.

    - ObjectKey: ключ, который использует более сложный формат данных, а не примитивный тип данных, такой как String.

    Примечание. Прежде чем использовать объект как ObjectKey, убедитесь, что он сравнивается корректно (сопоставим). Если нет, то необходимо переопределить оператор == и геттер hashCode, чтобы гарантировать, что любые два объекта этого класса, содержащие одни и те же данные, эквиваленты друг другу (оператор == вернет true)

    ObjectKey сравнивает ссылки, а ValueKey использует оператор == для сравнения значений

      ReorderableListView(
        children: todos
            .map(
              ...
            )
            .toList(),

    Это плохой пример использования виджета, так как модели мапятся в виджеты, которые могут быть никогда не нужны или если моделей очень много, то маппинг может занять много времени. Лучше использовать builder

      body: PageStorage(
        child: pages[currentTab],
        bucket: _bucket,
      ),

    Каждый ModalRoute создаёт PageStorage. Возможно, этот лишний