2.10. Виджеты: StatefulWidget

В предыдущем параграфе мы начали рассматривать виджеты во Flutter: поговорили о том, что это такое, зачем нужно и обсудили два класса виджетов — StatelessWidget и InheritedWidget.

Первый — виджет без состояния, позволяющий выделить часть UI в отдельный класс. Второй — виджет, позволяющий передать данные вниз по дереву виджетов.

В этом параграфе мы разберём ещё один виджет — StatefulWidget.

StatefulWidget

StatelessWidget — хороший инструмент для декомпозиции UI. Однако приложения содержат большое количество изменяющихся данных, которые образуют состояние.

StatefulWidget — виджет, имеющий изменяемое состояние.

Создать StatefulWidget можно, используя сниппет stful. Либо, если у вас уже есть StatelessWidget и вы хотите превратить его в Stateful, это можно быстро сделать при помощи хоткея:

  1. установите курсор на имени класса вашего StatelessWidget;
  2. нажмите сочетание клавиш:
    1. DartPad/AndroidStudio/IDEA: Alt + Enter на Windows, Option + Enter на macOS;
    2. VS Code: Ctrl + . на Windows и Cmd + . на macOS;
  3. выберите в появившейся подсказке Convert to StatefulWidget.

Можете попробовать здесь:

Посмотрим, что получилось:

1import 'package:flutter/widgets.dart';
2
3class DummyWidget extends StatefulWidget {
4  final String text;
5  
6  const DummyWidget({
7    required this.text,
8    super.key,
9  });
10
11  @override
12  State<DummyWidget> createState() => _DummyWidgetState();
13}
14
15class _DummyWidgetState extends State<DummyWidget> {
16  @override
17  Widget build(BuildContext context) {
18    return Text(widget.text); 
19  }
20}

Теперь виджет унаследован от StatefulWidget, и в нём появилась реализация метода createState. Flutter вызывает этот метод для создания объекта State.

Ниже в коде располагается сам класс состояния. Реализация build-метода перемещается в него.

Иммутабельные свойства, которые мы заполняем через конструктор, так и остались в самом виджете. Но теперь для доступа к ним из build-метода нужно использовать геттер widget.

Изменяем состояние

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

Разберём код состояния _BulbState. В зависимости от значения поля _isLightOn выбираем нужный цвет фона, цвет иконки и текст на кнопке. Изменяется состояние при помощи обработчика нажатия onPressed у виджета ElevatedButton.

При нажатии на кнопку происходит следующее:

  1. Вызываем метод setState для изменения состояния.
  2. Меняем значение поля _isLightOn на противоположное.
  3. В _BulbState вызывается build-метод. Возвращаем новые виджеты, используя новое значение _isLightOn. Фреймворк встраивает новые виджеты на экран, и на экране виден актуальный UI.

setState — метод, определённый у State, который уведомляет фреймворк о том, что State изменился и его нужно перерисовать.

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

fluttern

Advanced setState

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

Мы реализовали два StatefulWidget — ThemedApp и _PinCode.

Виджет ThemedApp похож на виджет Bulb из предыдущего примера, но теперь мы используем виджет Switch для изменения состояния. А состояние влияет на то, какой режим темы (светлый или тёмный) будет использоваться виджетом MaterialApp.

Второй StatefulWidget _PinCode отвечает за ввод пин-кода. Его State содержит поле _enteredPin, где в виде строки хранится введённый на данный момент пин-код, и _status, в котором в виде enum находится то, в каком состоянии сейчас должен быть экран (ввод, правильный/неправильный пин).

Когда пользователь нажимает на кнопки c цифрами, значения полей в State меняются и при помощи вызова setState виджет перерисовывается.

Обратите внимание, что setState инициирует перерисовки именно у того виджета, у которого он был вызван. То есть при setState у _PinCodeState не будет вызван build-метод у _ThemedApp. Вы можете сами проверить это, добавив логирование функцией print в build-методы. За счёт разделения виджетов можно оптимизировать количество и глубину перерисовок.

Также посмотрим на метод _resetStateAfterDelay. Он изменяет состояние спустя пять секунд после ввода пин-кода. За это время виджет может удалиться из дерева. Такое бывает, например, если в приложении произойдет навигация на предыдущий экран или состояние родительского изменится так, что он не вернёт ваш виджет из build-метода. В таком случае вызов setState выбросит ошибку. Чтобы этого не произошло, нужно выполнить при помощи геттера mounted проверку, что виджет по-прежнему прикреплен к дереву.

Изменение полей состояния через функцию, передаваемую в setState, может дать ощущение, что Flutter что-то знает о полях нашего состояния. На самом деле это не так. Независимо от того, обновите вы что-то внутри setState или нет, на следующем кадре будет вызван build-метод. Можно найти подтверждение этому в исходном коде метода setState. Два следующих варианта кода будут работать идентично:

1void _updateState() {
2  setState(() {
3    _someField = 'Some value';
4  });
5}
1void _updateState() {
2  _someField = 'Some value';
3  setState(() {});
4}

Но всё же во Flutter принят подход с обновлением полей внутри setState, так что не следует пренебрегать этим.

Иммутабельность виджетов

Во Flutter любой виджет обязан быть иммутабельным, то есть у него не должно быть изменяемых полей.

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

Именно поэтому StatefulWidget разделен на две части — сам виджет, содержащий только иммутабельные поля, и состояние, которое может изменяться.

Немного об Element

На самом деле Flutter практически не работает с деревом виджетов. Это абстракция для удобства разработчиков. На основе дерева виджетов Flutter строит дерево элементов (Element Tree), и уже с ним идёт основная работа. Элемент — представление виджета в дереве элементов, они отвечают за жизненный цикл UI и его эффективное обновление.

Когда обновляется состояние, и мы отдаём новые виджеты, Flutter сопоставляет их с имеющимся деревом элементов и добавляет/удаляет/обновляет элементы в нём.

Выше мы говорили, что BuildContext — интерфейс для доступа к Element. Рассмотрим подробнее, как это работает:

  1. мы создаём виджет;
  2. Flutter вызывает у этого виджета метод createElement;
  3. полученный элемент помещается в дерево элементов;
  4. у виджета (или у State в случае StatefulWidget) вызывается метод build, куда передаётся Element в качестве параметра.

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

Помимо дерева элементов, существует RenderObject Tree. Оно создаётся на основе дерева элементов и отвечает непосредственно за отрисовку и позиционирование элементов UI.

fluttern

Element и RenderObject — довольно сложная тема, её мы рассмотрим в одном из следующих параграфов.

👉 На данном этапе важно понять, что благодаря такому подходу достигается эффективная работа UI — пересоздаются только легковесные Widget, а тяжелые Element и RenderObject переиспользуются, если это возможно.

Кстати, метод setState работает как раз с Element. State вызывает метод markNeedsBuild у Element, с которым они связаны. Для простоты чтения из кода убраны assert, так как они выполняются только в debug-режиме:

1@protected
2void setState(VoidCallback fn) {
3  // ... asserts ...
4  final Object? result = fn() as dynamic;
5  // ... asserts ...
6  _element!.markNeedsBuild();
7}

А Element уже меняет у себя значение свойства _dirty на true и добавляет себя в список «грязных» элементов. Под «грязными» подразумеваются элементы, которые нужно перерисовать на следующем кадре.

1void markNeedsBuild() {
2  if (_lifecycleState != _ElementLifecycle.active) {
3    return;
4  }
5  // ... asserts ...
6  if (dirty) {
7    return;
8  }
9  _dirty = true;
10  owner!.scheduleBuildFor(this);
11}

Жизненный цикл StatefulWidget

А теперь вернёмся к StatefulWidget, а точнее к его жизненному циклу.

StatelessWidget обладает совсем простым жизненным циклом. По сути у него есть только конструктор и build-метод. Его нельзя изменить, можно только создать новый виджет.

Со StatefulWidget ситуация сложнее. У State есть множество методов, которые можно реализовать для реакции на изменение состояния жизненного цикла.

Давайте посмотрим на общую схему работы StatefulWidget, а затем подробно разберём каждое состояние.

fluttern

createState

createState — метод, определённый в StatefulWidget.

Когда вызывается

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

Использование

createState обязательно нужно реализовать, и он всегда выглядит одинаково:

1class SomeWidget extends StatefulWidget {
2  @override
3  State<SomeWidget> createState() => _SomeWidgetState();
4}

initState

Когда вызывается

initState вызывается фреймворком после того, как State привязывается к Element. Гарантируется, что он вызывается только один раз для экземпляра State.

Использование

В initState обычно выполняется инициализация контроллеров либо полей, которые зависят от данных из widget или context.

Необходимо вызывать родительскую реализацию super.initState первой операцией.

1late int _someField;
2late TextEditingController _messageController;
3
4@override
5void initState() {
6  super.initState();
7  _someField = widget.someField;
8  _loginController = TextEditingController();
9  // ...
10}

Что нельзя делать

Несмотря на то, что из initState уже доступен context, нельзя использовать context.dependOnInheritedWidgetOfExactType, а соответственно, и методы SomeInheritedWidget.of(context).

didChangeDependencies

Когда вызывается

didChangeDependencies вызывается фреймворком при изменении InheritedWidget, от которых зависит State (через context.dependOnInheritedWidgetOfExactType). Метод InheritedWidget.updateShouldNotify как раз нужен, чтобы определить, в каких случаях будут уведомлены зависимые виджеты.

Также этот метод будет вызван сразу после initState. И в отличие от предыдущего метода в didChangeDependencies уже можно использовать InheritedWidget.

Использование

В didChangeDependencies нужно выполнять работу, которая должна происходить при изменении InheritedWidget. Например, вы можете увидеть, что значение в InheritedWidget изменилось, и поменять какие-то значения в State или вызвать какой-то метод.

1@override
2void didChangeDependencies() {
3  // число будет выведено в консоль при добавлении виджета в дерево и каждый раз при обновлении InheritedNumber.
4  print(InheritedNumber.of(context).number); 
5}

build

Когда вызывается

build вызывается для получения конфигурации виджетов. Гарантируется, что build вызывается уже после того, как были вызваны setState и didChangeDependencies. Этот метод может вызываться фреймворком множество раз — например:

  • после вызова setState;
  • при изменении InheritedWidget, от которых зависит State;
  • при hot reload;
  • при изменении конфигурации StatefulWidget.

Использование

Необходимо вернуть виджет, как мы уже делали в предыдущих примерах этого параграфа.

1@override
2Widget build(BuildContext context) {
3  return Placeholder();
4}

Что нельзя делать

Из build нельзя вызывать setState. Если вы попытаетесь так делать, то получите следующую ошибку:

1setState() or markNeedsBuild() called during build.

Вообще лучше избегать ситуаций, когда виджет обновляется из build-метода, но иногда это необходимо.

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

1@override
2Widget build(BuildContext context) {
3  if (someCondition) {
4  WidgetsBinding.instance.addPostFrameCallback((_) {
5      setState(() {
6        // ...
7      });
8    });
9  }
10  return Placeholder();
11}

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

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

didUpdateWidget

Когда вызывается

didUpdateWidget вызывается фреймворком, когда изменяется конфигурация виджета. Как это происходит:

  • Имеется родительский виджет ParentWidget и дочерний StatefulWidget ChildWidget.
  • У родительского ParentWidget происходит rebuild, например из-за вызова setState.
  • Из build-метода ParentWidget снова возвращается ChildWidget.
  • Flutter понимает, что виджет того же типа находится в том же месте и переиспользует имеющиеся Element и State.
  • У _ChildWidgetState вызывается didUpdateWidget, куда передаётся старый экземпляр ChildWidget.
  • Внутри реализации можно выполнить какую-то работу. К примеру, можно сравнить значения полей в текущей конфигурации виджета через геттер widget и в старой конфигурации через параметр oldWidget и выполнить какие-то действия. Как пример — запустить анимацию.
  • После у _ChildWidgetState будет вызван build-метод.

didUpdateWidget будет вызван каждый раз, когда изменяется экземпляр виджета, привязанный к State, независимо от того, меняются ли в нём какие-то параметры.

Использование

Можно выполнить работу, зависящую от конфигурации виджета. Например, вы можете анимированно изменить состояние виджета.

1@override
2void didUpdateWidget(covariant ChildWidget oldWidget) { 
3 super.didUpdateWidget(oldWidget);
4  if (widget.enabled != oldWidget.enabled) {
5    _animationController.animateTo(widget.enabled ? 1 : 0);
6  }
7}

dispose

Когда вызывается

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

Использование

Можно воспринимать dispose как обратное действие к initState и использовать этот метод для освобождения ресурсов: для отмены подписок, закрытия различных Controller и так далее.

1@override
2void dispose() {
3  _someTextController.dispose();
4  _someSubscription?.cancel();
5  // Прочие действия по освобождению ресурсов
6  super.dispose();
7}

Что нельзя делать

Так как виджет уже отсоединён от дерева, в dispose нельзя выполнять работу с BuildContext.

mounted

После создания State связывается с Element. У State есть геттер mounted, который позволяет определить, связан ли State в данный момент с Element. Когда виджет убирается из дерева виджетов, State отсоединяется от Element.

Геттер mounted бывает полезен, если мы хотим асинхронно вызватьsetState. Дело в том, что вызов setState, когда State уже отсоединен, ведёт к ошибке. В коде это будет выглядеть следующим образом:

1// ... Some Stateful Widget ...
2Button(
3  onTap: () {
4    Future.delayed(const Duration(seconds: 5), () {
5      if (mounted) { 
6       setState(() {
7         // ...
8       });
9      }
10    });
11  },
12),

Вот мы и разобрали все основные виджеты, а StatefulWidget — с пристрастием. Советуем пройти квиз, чтобы закрепить знания. А в следующем параграфе мы рассмотрим виджеты, которые входят в состав стандартных UI библиотек Flutter.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

Предыдущий параграф2.9. Виджеты: основы
Следующий параграф2.11. Виджеты: стандартные библиотеки