В предыдущем параграфе мы начали рассматривать виджеты во 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.
