В предыдущем параграфе мы начали рассматривать виджеты во Flutter: поговорили о том, что это такое, зачем нужно и обсудили два класса виджетов — StatelessWidget
и InheritedWidget
.
Первый — виджет без состояния, позволяющий выделить часть UI в отдельный класс. Второй — виджет, позволяющий передать данные вниз по дереву виджетов.
В этом параграфе мы разберём ещё один виджет — StatefulWidget
.
StatefulWidget
StatelessWidget
— хороший инструмент для декомпозиции UI. Однако приложения содержат большое количество изменяющихся данных, которые образуют состояние.
StatefulWidget
— виджет, имеющий изменяемое состояние.
Создать StatefulWidget
можно, используя сниппет stful
. Либо, если у вас уже есть StatelessWidget
и вы хотите превратить его в Stateful, это можно быстро сделать при помощи хоткея:
- установите курсор на имени класса вашего
StatelessWidget
; - нажмите сочетание клавиш:
- DartPad/AndroidStudio/IDEA:
Alt + Enter
на Windows,Option + Enter
на macOS; - VS Code:
Ctrl + .
на Windows иCmd + .
на macOS;
- DartPad/AndroidStudio/IDEA:
- выберите в появившейся подсказке
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
.
При нажатии на кнопку происходит следующее:
- Вызываем метод
setState
для изменения состояния. - Меняем значение поля
_isLightOn
на противоположное. - В
_BulbState
вызывается build-метод. Возвращаем новые виджеты, используя новое значение_isLightOn
. Фреймворк встраивает новые виджеты на экран, и на экране виден актуальный UI.
setState
— метод, определённый у State
, который уведомляет фреймворк о том, что State
изменился и его нужно перерисовать.
Так, основная идея декларативного UI во Flutter заключается в том, что для изменения UI нужно создавать новые экземпляры виджетов, а не изменять имеющиеся.
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
. Рассмотрим подробнее, как это работает:
- мы создаём виджет;
- Flutter вызывает у этого виджета метод
createElement
; - полученный элемент помещается в дерево элементов;
- у виджета (или у
State
в случаеStatefulWidget
) вызывается методbuild
, куда передаётсяElement
в качестве параметра.
Таким образом, мы имеем доступ к дереву элементов. Благодаря интерфейсу BuildContext
нам доступны только определённые методы и поля. В этом параграфе мы ещё рассмотрим, как именно можно использовать возможности, которые даёт BuildContext
.
Помимо дерева элементов, существует RenderObject Tree
. Оно создаётся на основе дерева элементов и отвечает непосредственно за отрисовку и позиционирование элементов UI.
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, а затем подробно разберём каждое состояние.
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
и дочерний StatefulWidgetChildWidget
. - У родительского
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.