В предыдущих параграфах мы познакомились с основными виджетами Flutter. Начиная с этой темы мы будем углубляться в работу самого фреймворка.
Для начала разберём, что такое Element
:
- какие бывают элементы;
- как создаются;
- какой у них жизненный цикл.
А также поговорим о том, как связаны элементы и виджеты, RenderObject
и BuildContext
.
BuildContext
Вы уже сталкивались с этой сущностью, когда работали с методом build
.
@override
Widget build(BuildContext context){
return SomeWidget();
}
Или, например, когда получали текущую тему.
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // Тема
return SomeWidget(theme: theme);
}
Но как мы получаем нужную тему? Как виджет понимает, кто его родитель и где в дереве виджетов он находится? И почему для получения темы мы используем контекст, почему при работе с навигатором нам тоже нужен контекст?
Давайте разберёмся!
Предположим, мы создали свой виджет, чтобы отобразить круг.
class CircleWidget extends StatelessWidget {
const HelloWorldText();
@override
Widget build(BuildContext context){
final radius = 100;
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red,
),
width: radius,
height: radius,
);
}
}
Мы видим, что нам ничего не пришлось указывать для того, чтобы виджет знал, кто его родитель или откуда брать тему.
А теперь представьте, что этот виджет — это реальный маленький шар, который имеет какие-то параметры, скажем цвет, радиус и наполнен воздухом.
Всё это мы указываем, передавая такие параметры, как color: Colors.red
, shape: BoxShape.circle
.
Именно таким образом мы задаём то, что хотим. Но как всё это преобразуется в то, что мы видим?
Если мы посмотрим, как реализован класс StatelessWidget
или StatefulWidget
, то увидим, что тот и другой являются реализацией класса Widget
.
abstract class StatelessWidget extends Widget {
const StatelessWidget({super.key});
@override
StatelessElement createElement() => StatelessElement(this);
@protected
Widget build(BuildContext context);
}
// --------
abstract class StatefulWidget extends Widget {
const StatefulWidget({super.key});
@override
StatefulElement createElement() => StatefulElement(this);
@protected
@factory
State createState();
}
Если мы углубимся в реализацию класса Widget
и посмотрим список его методов, то увидим очень интересный метод — createElement
.
@immutable
abstract class Widget extends DiagnosticableTree {
const Widget({this.key});
final Key? key;
@protected
@factory
Element createElement(); /// ОН ТУТ :)
@override
String toStringShort() {...}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {...}
@override
@nonVirtual
bool operator ==(Object other) => ...;
@override
@nonVirtual
int get hashCode => super.hashCode;
static bool canUpdate(Widget oldWidget, Widget newWidget) {...}
static int _debugConcreteSubtype(Widget widget) {...}
}
Если мы прочтём документацию Flutter по этому методу, то увидим там такую строчку:
Inflates this configuration to a concrete instance.
(пер.)
«Раздувает эту конфигурацию до конкретного экземпляра».
Её можно понять так: метод createElement
превращает заданную нами конфигурацию (то есть Widget) во что-то более конкретное.
И это «что-то более конкретное» как раз и есть экземпляр класса Element
.
То есть когда вы создаёте какой-либо виджет, фреймворк вызывает его метод createElement
и возвращает Element
, который сохраняет информацию, необходимую при работе приложения конкретно для этого виджета в конкретном месте приложения.
Именно он знает, где находится виджет, кто его родитель, какие у него потомки и многие другие данные.
И если мы углубимся в реализацию класса Element
, то увидим, что он реализует интерфейс BuildContext
.
abstract class Element extends DiagnosticableTree implements BuildContext {
/// Тут находится куча методов и полей, которые мы рассмотрим ниже
...
}
И теперь всё становится чуть яснее. Если Element
— это BuildContext
и Element
знает, где он находится, кто его родитель, то получается, когда мы передаём куда-либо context
, то передаём и всю эту информацию, а фреймворк уже сам понимает, что и откуда брать, и отдаёт нам тем самым тему при вызове Theme.of(context)
.
Но почему же когда мы вызываем BuildContext
, то не видим никаких данных о дочерних и родительских элементах? Всё потому, что BuildContext
— это просто интерфейс. Он служит для того, чтобы мы никак напрямую не взаимодействовали с элементами. Об этом сказано даже в официальной документации:
BuildContext objects are actually Element objects. The BuildContext interface is used to discourage direct manipulation of Element objects.
И в действительности единственным классом, который реализует BuildContext
, является Element
. Поэтому, когда вы работаете с BuildContext
, помните, что это просто Element
.
А теперь давайте подробнее изучим сам Element
.
Element — это...
Для начала нам стоит определиться с понятием виджета, элемента и RenderObject
.
Widget
— это неизменяемая конфигурация. Почему конфигурация? Потому что виджет хранит данные, по которым будет строится UI. Это, например, цвет, радиус, дочерние виджеты. То есть конфигурация — это набор данных. Из этого следует, что понятия виджета и конфигурации взаимозаменяемы. Поэтому когда вы будете думать о виджете, то просто представляйте, что это набор данных, по которому определяется поведение и отрисовка объекта на экране.
Element
— это реализация конфигурации. Он служит связующим звеном между Widget
и тем, что появится на экране. Элементы образуют своё дерево, которое называется дерево элементов.
RenderObject
— это объект в дереве рендеринга, то есть в дереве тех объектов, которые отрисовались на нашем экране. RenderObject
как раз рисует, что нам надо, задаёт внутренние и внешние отступы, цвет и прочее, а также размещает и подсказывает, как дочерние объекты должны располагаться относительно друг друга.
Как мы уже сказали ранее, элемент знает, какой виджет (конфигурация) был задан для него, какой элемент является родительским, какие у него есть дочерние элементы и какой у него RenderObject
(подробнее про его связь с Element
мы поговорим в конце параграфа).
Но что вызывает создание элемента? Хоть метод createElement
и находится у виджета, но вызывает этот метод не виджет, а вызывают его другие элементы у своих детей.
Чтобы понять процесс создания, нам достаточно заглянуть вглубь Flutter, найти класс Element
и посмотреть, что делает его метод inflateWidget
.
Мы видим, что в этот виджет передаётся конфигурация (параметр newWidget
), и это конфигурация для дочернего элемента, у неё мы вызываем метод createElement
. В качестве результата вызова мы получим дочерний элемент. И так происходит, пока мы не дойдём до последнего виджета.
Element inflateWidget(Widget newWidget, Object? newSlot) {
...
final Element newChild = newWidget.createElement();
...
}
Если вы заглянете в устройство любого виджета, то увидите, какой элемент создаётся для виджета. Например, для StatelessWidget
создаётся StatelessElement
.
abstract class StatelessWidget extends Widget {
const StatelessWidget({super.key});
@override
StatelessElement createElement() => StatelessElement(this);
@protected
Widget build(BuildContext context);
}
Существует ещё много других элементов, давайте их рассмотрим.
Классификация элементов
Все элементы условно можно разделить на две группы:
- те, которые не имеют
RenderObject
; - те, которые имеют
RenderObject
.
Первая группа (без RenderObject
) — это все наследники ComponentElement
.
Поскольку RenderObject у них нет, они принимают виджеты и передают их для отрисовки другим элементам, у которых RenderObject есть.
Плюс они хранят данные.
Вторая группа (с RenderObject
) — это RenderObjectElement
и все его наследники. Данный элемент содержит свой собственный RenderObject
.
Диаграмма ниже показывает, какие элементы от какого наследуются.
1-я группа
StatelessElement
— принадлежит StatelessWidget
.
StatefulElement
— принадлежит StatefulWidget
.
ProxyElement
— это элемент, от которого наследуются InheritedElement
и ParentDataElement
. И, грубо говоря, ProxyElement
служит для того, чтобы хранить промежуточные данные между родительским и дочерним элементом.
InheritedElement
— создаётся InheritedWidget
. Про то, как они связаны, мы поговорим ниже.
ParentDataElement
— создаётся ParentDataWidget
. ParentDataWidget
используется, чтобы сообщить какие-то данные, которые могут быть использованы родительским виджетом при отрисовке.
📖 Для примера можно взять виджеты Stack
и Positioned
. Когда мы используем виджет Positioned
, мы задаём ему такие параметры, как left
, right
, top
, bottom
и прочие.
Он хранит эти данные до тех пор, пока MultiChildRenderObjectElement
не попросит применить их вызовом метода applyParentData
. И в соответствии с этими данными Stack
сам расположит дочерние элементы.
2-я группа
FooElement
— различные классы, унаследованные от RenderObjectElement
, где Foo
— название класса RenderObject
. К примеру, LeafRenderObjectElement
, SingleChildRenderObjectElement
, MultiChildRenderObjectElement
и прочие.
После того как мы классифицировали элементы, можно рассмотреть, как же они создаются.
Как создаются элементы
Чтобы рассмотреть создание элементов, нужно выделить методы и поля класса Element
, которые участвуют во всём процессе от создания до обновления и удаления.
Поля:
Widget
— конфигурация, которая отвечала за создание элемента.Owner
— сущность, которая управляет билдом, жизненным циклом элемента.RenderObject
— ближайшийRenderObject
. ЭтоRenderObject
текущего элемента либоRenderObject
одного из дочерних элементов. ЕслиRenderObject
не найден, то будетnull
.
Методы же разберём подробнее.
reassemble
Этот метод вызывается всякий раз, когда приложение собирается повторно в режиме дебага — например, во время Hot Reload. Позволяет выполнить какую-либо инициализирующую логику, например загрузку изображений из ассетов.
visitChildren
Метод, который переопределяют классы-наследники для прохода по дочерним элементам.
Принимает в качестве аргумента колбэк, который вызывается для каждого ребёнка.
Используется, например, для того, чтобы рекурсивно убрать RenderObject
для каждого ребёнка:
void detachRenderObject() {
visitChildren((Element child) {
child.detachRenderObject();
});
...
}
updateChild
Это метод, который служит для обновления дочернего элемента — и возвращает этот самый дочерний элемент. Вызывается, когда происходит добавление/удаление/обновление элемента.
Благодаря этому методу Flutter получился достаточно производительным — за счёт переиспользования готовых элементов. Поэтому расскажем о нём подробнее.
Для начала — вот как он устроен:
Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
/// Виджета больше нет, и происходит деактивация элемента
if (newWidget == null) {
if (child != null) {
deactivateChild(child);
}
return null;
}
final Element newChild;
/// Дочерний элемент уже существовал, а значит, далее может быть
/// либо его обновление, либо удаление
if (child != null) {
bool hasSameSuperclass = true;
assert(() {
final int oldElementClass = Element._debugConcreteSubtype(child);
final int newWidgetClass = Widget._debugConcreteSubtype(newWidget);
hasSameSuperclass = oldElementClass == newWidgetClass;
return true;
}());
/// Элемент совпадает с виджетом, и конфигурации одинаковы, а значит,
/// просто отрисовываем, не меняя ничего
if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
newChild = child;
} else if (hasSameSuperclass &&
Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
...
/// Если конфигурации различны, то обновляем наш элемент
child.update(newWidget);
newChild = child;
} else {
/// Если тип элемента не совпадает с типом виджета, то
/// удаляем элемент и создаём новый, под переданный [newWidget]
deactivateChild(child);
assert(child._parent == null);
newChild = inflateWidget(newWidget, newSlot);
}
} else {
/// Создаём элемент, так как до этого его не существовало
newChild = inflateWidget(newWidget, newSlot);
}
return newChild;
}
Как видите, метод принимает три параметра:
Element? child
— дочерний элемент.Widget? newWidget
— новую конфигурацию для элемента.Object? newSlot
— новый слот (место в дереве элементов), где будет располагаться элемент.
Этот метод также может быть вызван другим методом — inflateWidget
.
У этого метода существует несколько сценариев выполнения. Они представлены на диаграмме ниже.
Теперь разберём их чуть подробнее.
-
Если
newWidget == null
(это значит, что виджета больше нет), тоchild
деактивируется, и вернётсяnull
. -
Если
child == null && newWidget ≠ null
(означает, что появился новый виджет), то происходит вызов методаinflateWidget
, в результате чего создаётся новый элемент, который будет использоваться в качестве дочернего. -
Если
child ≠ null && newWidget ≠ null
, то происходит следующее:- Происходит проверка на то, что нам была передана конфигурация типа, который соответствует типу элемента (под типом имеется в виду
StatelessWidget
,StatefulWidget
или другие названия виджетов/элементов), результат сравнения лежит в переменнойhasSameSuperclass
. Но эта проверка действительна только в режиме дебага. Можем взглянуть на код:
assert(() { //Здесь просто определяется тип элемента: 1 — StatefulElement, 2 — StatelessElement, 0 — другой final int oldElementClass = Element._debugConcreteSubtype(child); //Здесь просто определяется тип виджета: 1 — StatefulWidget, 2 — StatelessWidget, 0 — другой final int newWidgetClass = Widget._debugConcreteSubtype(newWidget); // Здесь происходит сравнение, что нужный элемент соотносится с соответствующей ему конфигурацией hasSameSuperclass = oldElementClass == newWidgetClass; return true; }());
Если вышло так, что конфигурация и элемент не совпадают, то происходит деактивация дочернего элемента и создание нового дочернего элемента в методе
inflateWidget
.После проверки происходит сравнение новой и старой конфигураций.
- Если конфигурации равны, то происходит переиспользование дочернего элемента.
- Если конфигурации разные, но одного типа, то происходит вызов метода
canUpdate
у виджета дочернего элемента, и если мы можем обновить конфигурацию, то элемент помещается в новый слот, если таковой задан.
А уchild
обновляется конфигурация с помощью вызова методаupdate
дочернего элемента и передачи в этот метод новой конфигурации. На этом этот метод завершает свою работу. - Если различаются типы конфигураций, то
child
деактивируется и на его месте создаётся новый, как в сценарии номер 2.
- Происходит проверка на то, что нам была передана конфигурация типа, который соответствует типу элемента (под типом имеется в виду
inflateWidget
Этот метод создает дочерний элемент для заданного виджета.
У него есть есть два параметра:
Widget? newWidget
— конфигурация для дочернего элемента.Object? newSlot
— слот для элемента.
Если у виджета есть глобальный ключ, то фреймворк попытается найти и взять уже существующий элемент, который был связан с текущим ключом, вызывая метод _retakeInactiveElement
.
Если элемент будет найден для заданного виджета, то он будет заново переиспользован (вызов методов _activateWithParent
и updateChild
с новой конфигурацией).
Может показаться, что методы updateChild
и inflateWidget
используют друг друга циклично, но на самом деле updateChild
просит inflateWidget
достать уже существующий элемент и вернуть его с новой конфигурацией либо создать новый элемент.
Если же элемент не будет найден или ключ не будет задан, то будет создан новый элемент для newWidget
. И после этого будет вызван метод mount
для дочернего элемента.
Element inflateWidget(Widget newWidget, Object? newSlot) {
...
try {
final Key? key = newWidget.key;
if (key is GlobalKey) {
final Element? newChild = _retakeInactiveElement(key, newWidget);
if (newChild != null) {
...
newChild._activateWithParent(this, newSlot);
final Element? updatedChild = updateChild(newChild, newWidget, newSlot);
...
return updatedChild!;
}
}
/// А вот тут и сам элемент — создаёт другой элемент
final Element newChild = newWidget.createElement();
...
newChild.mount(this, newSlot);
...
return newChild;
} finally {
...
}
}
Вышеописанное можно представить в виде диаграммы:
mount
Метод, в котором происходит встраивание текущего элемента в дерево.
Он принимает родительский элемент и слот для размещения. Если виджет имел GlobalKey
, то происходит привязка текущего элемента с глобальным ключом. Данные об этом заносятся в BuildOwner
(пока не будем рассматривать что это за сущность, просто знайте, что она хранит все глобальные ключи).
Также происходит обновление списка InheritedWidget
с помощью вызова метода _updateInheritance
, его мы рассмотрим чуть позже.
_retakeInactiveElement
Этот метод вызывается родителем для дочернего элемента и возвращает неактивный элемент по переданному ключу. Он принимает два параметра: глобальный ключ и новую конфигурацию.
Вот как он устроен внутри:
Element? _retakeInactiveElement(GlobalKey key, Widget newWidget) {
final Element? element = key._currentElement;
if (element == null) {
return null;
}
if (!Widget.canUpdate(element.widget, newWidget)) {
return null;
}
final Element? parent = element._parent;
if (parent != null) {
assert(() {
if (parent == this) {
/// Тут происходит выбрасывание всем известной ошибки при работе с
/// глобальными ключами:
/// "A GlobalKey was used multiple times inside
/// one widget's child list."
}
return true;
}());
parent.forgetChild(element);
parent.deactivateChild(element);
}
assert(element._parent == null);
owner!._inactiveElements.remove(element);
return element;
}
В начале метода мы пытаемся достать элемент из ключа. Если ключ не содержит элемента или виджет не может быть обновлён, то данный метод вернет null
.
Далее мы достаём родительский элемент из элемента в ключе. Если родитель существует, то это означает, что какой-то другой виджет уже использует этот глобальный ключ, и происходит выброс ошибки A GlobalKey was used multiple times inside one widget's child list
.
Если же родителя нет, то элемент удаляется из списка неактивных (owner!._inactiveElements.remove(element)
) и возвращается.
update
Метод, который обновляет конфигурацию текущего элемента, если конфигурация того же типа (см. метод updateChild
). Здесь нет ничего особенного, просто текущему элементу присваивается в поле _widget
новая конфигурация.
void update(covariant Widget newWidget) {
_widget = newWidget;
}
_activateWithParent
Данный метод активирует текущий элемент и вызывается родителем. Здесь происходит активация дочерних элементов друг за другом с помощью вызова метода activate
, а также вставляется в дерево рендеринга объект RenderObject
— вызовом метода attachRenderObject
.
deactivateChild
Этот метод деактивирует дочерний элемент и помещает его в список неактивных элементов. В этом же методе происходит отсоединение элемента от его родителя и вызывается метод detachRenderObject
.
attachRenderObject
В этом методе происходит встраивание текущего RenderObject
в дерево рендеринга.
detachRenderObject
В данном методе происходит удаление RenderObject
из дерева рендеринга
activate
Здесь элемент меняет свое состояние на active
. Теперь рассмотрим его код и узнаем, что там происходит.
void activate() {
// Смена состояния элемента, о жизненном цикле мы поговорим чуть позже
_lifecycleState = _ElementLifecycle.active;
// Тут происходит чистка зависимостей.
// Под зависимостями понимается связь между Inherited-элементами и текущим
_dependencies?.clear();
_hadUnsatisfiedDependencies = false;
_updateInheritance();
// Если элемент помечен как «грязный», то есть у него обновилась конфигурация
// или, к примеру, мы вызвали метод setState(),
// то мы говорим, что требуется сбилдить заново этот элемент (для упрощения — виджет)
if (_dirty) {
owner!.scheduleBuildFor(this);
}
// А вот тут происходит вызов метода didChangeDependencies у State,
// который связан со StatefulWidget.
if (hadDependencies) {
didChangeDependencies();
}
}
deactivate
Вызов этого метода сделает элемент неактивным, то есть он просто не сможет быть использован для построения, а соответственно, отрисован. Обычно вызывается, когда активный элемент поместился в список деактивированных и потащил за собой все дочерние элементы. Если посмотреть на его код, то мы там просто увидим отвязку от всех Inherited
и смену состояния:
void deactivate() {
if (_dependencies != null && _dependencies!.isNotEmpty) {
for (final InheritedElement dependency in _dependencies!) {
dependency._dependents.remove(this);
}
}
_inheritedWidgets = null;
_lifecycleState = _ElementLifecycle.inactive;
}
unmount
Метод вызывается, когда элемент больше нигде не нужен. Здесь происходит отвязка конфигурации и зависимостей, чтобы не было утечек в памяти, если элемент вдруг сохранился. А также его состояние меняется на defunct
.
markNeedsBuild
Метод помечает текущий элемент как «грязный», который следует построить заново. И говорит, что ему нужно обновиться в следующем кадре. Является ли элемент «грязным», можно узнать, посмотрев на поле _dirty
у этого элемента.
rebuild
Данный метод вызывается такой сущностью, как BuildOwner
, когда элемент впервые создаётся, а также когда вызывается метод update
. При этом этот метод вызывает другой performRebuild
.
performRebuild
Вызывает метод build
у виджета и обновляет или создаёт элемент для него. Когда мы создаём виджет и определяем у него метод, этот метод возвращает нам дочерние виджеты. И именно под дочерние виджеты мы создаём элементы.
Создание первого и последующих элементов можно представить в виде диаграммы:
Теперь, когда мы рассмотрели каждый метод и узнали, за что они отвечают, можно приступить к знакомству с циклом создания/обновления элементов и виджетов.
Жизненный цикл
У каждого элемента есть жизненный цикл. С помощью него можно понять, что виджет удалился с экрана, переместился по дереву, добавился на экран. Всего есть 4 состояния, в которых может находиться элемент: initial
, active
, inactive
, defunct
.
Состояние 1: initial
При создании элемента сразу же происходит инициализация его состояния.
abstract class Element extends DiagnosticableTree implements BuildContext {
...
_ElementLifecycle _lifecycleState = _ElementLifecycle.initial
...
}
Первое состояние — initial
— означает, что он не встроен в дерево: он не знает ни о своих родительских элементах, ни о дочерних, ни где он находится.
Состояние 2: active
У элемента вызывается метод mount
. Этот метод вызывается только у нового элемента, который ещё не был в дереве элементов. На этом этапе элемент узнаёт о своём родительском элементе, о слоте, в котором будет находиться, о глубине на которой будет находится. Также происходит получение списка зависимостей, то есть всех InheritedWidget
.
И тут состояние элемента становится равным active
. Также, если у виджета был глобальный ключ, происходит его регистрация, то есть процесс сохранения.
После этого этапа нам становится доступна подписка на InheritedWidget
.
Состояние 3: inactive
Состояние inactive
наступает, когда элемент становится недоступным для использования.
Это происходит при:
- удалении виджета из дерева — например, закрыли страничку в приложении;
- перемещении виджета по дереву — например, когда в приложении списка дел поменяли местами две задачи;
- поступлении нового типа конфигурации элемента — например, был
StatelessWidget
, а сталStatefulWidget
.
На этой стадии очищаются все зависимости. При всём этом мы можем попробовать переиспользовать элемент, если:
- у конфигурации есть
GlobalKey
и он совпадает сGlobalKey
новой конфигурации; - и тип конфигурации (то есть тип виджета) не изменился.
В остальных случаях создаётся новый элемент.
Если мы можем попробовать переиспользовать элемент, то происходит следующее:
Неактивный элемент помещается в список к остальным таким же элементам. Этот список находится в классе _InactiveElements
. Когда элемент добавляется в этот список, все его дочерние элементы деактивируются рекурсивно.
Состояние 4: defunct
Это последняя стадия элемента. Она наступает, только когда происходит удаление элемента из дерева и только после того, как элемент был деактивирован (т. е. находился в состоянии inactive
).
Здесь уже происходит удаление глобального ключа, который был связан с текущим элементом, и очищение всех переменных, дабы избежать утечек памяти, когда элемент случайным образом где-то сохранился.
Чтобы показать, что элемент больше не нужен и должен быть удалён, происходит вызов метода unmount
у этого самого элемента. Данный вызов происходит в классе _InactiveElements
, когда завершается фаза построения. Именно в этот момент фреймворк проходится по списку неактивных элементов и удаляет их навсегда без повторного использования.
Теперь, когда мы рассмотрели базу элементов, можно перейти к тому, как происходит взаимодействие InheritedWidget
и Element
.
InheritedWidget и Element
InheritedWidget
— это конфигурация для InheritedElement
, а сам InheritedElement
— это ProxyElement
. Можно посмотреть диаграмму наследования выше. И всё это означает, что InheritedWidget
позволяет хранить данные, которые будут доступны для всех дочерних виджетов.
Например, определим InhertiedWidget
. Он будет хранить количество лайков:
class LikesInheritedWidget extends InheritedWidget {
/// Количество лайков
final int _amountLikes;
const LikesInheritedWidget({
super.key,
int initialLikes = 0, // начальное количество лайков
required super.child,
}) : _amountLikes = initialLikes;
int get amountLikes => _amountLikes;
/// Говорим, что конфигурация изменилась,
/// только если изменилось количество лайков
@override
bool updateShouldNotify(covariant LikesInheritedWidget oldWidget) {
return amountLikes != oldWidget.amountLikes;
}
/// **Подписываемся** на изменения данного виджета
/// Если поменяется количество лайков, то виджет, который вызвал этот метод,
/// перестроится с новыми данными (количеством лайков)
static LikesInheritedWidget? of(BuildContext context) {
/// [dependOnInheritedWidgetOfExactType] ищет ближайший вверх по дереву
/// виджет с указанным типом, в нашем случае это [LikesInheritedWidget],
/// и говорит виджету из `context`, что нужно перестроиться, если поменяется
/// количество лайков
final widget =
context.dependOnInheritedWidgetOfExactType<LikesInheritedWidget>();
return widget;
}
}
Теперь создадим виджет, который будет отображать количество лайков:
class LikesWidget extends StatelessWidget {
const LikesWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
/// NOTIFIER
final likes = LikesInheritedWidget.of(context)?.likes ?? 0;
return Text('Likes $likes');
}
}
Теперь, когда мы определили нужные виджеты, можно рассмотреть более детально, что же тут происходит.
В методе build
вызывается статический метод of
у LikesInheritedWidget
, который в свою очередь вызывает dependOnInheritedWidgetOfExactType
у переданного контекста (элемента).
Но что происходит при вызове метода dependOnInheritedWidgetOfExactType
?
@override
T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object? aspect}) {
/// Смотрим список InheritedWidget у текущего элемента
/// Если их нет, то и элемента для виджета T нет
final InheritedElement? ancestor = _inheritedWidgets == null ? null : _inheritedWidgets![T];
if (ancestor != null) {
/// Если искомый элемент нашего виджета T найден, то вызывается этот метод
/// Туда мы передаём найденный элемент и [aspect].
/// [aspect] — это свойство, которое необходимо InheritedModel.
/// Благодаря ему мы можем понимать, что нужно перестроить те виджеты,
/// которые сослались на это свойство, если оно изменилось у InheritedModel
return dependOnInheritedElement(ancestor, aspect: aspect) as T;
}
/// Если же искомого элемента для виджета T нет, а мы пытаемся на него сослаться,
/// то фреймворк помечает, что есть зависимость,
/// для которой не нашлось искомого виджета
_hadUnsatisfiedDependencies = true;
return null;
}
Теперь заглянем внутрь dependOnInheritedElement
и ещё чуть позже рассмотрим, откуда берётся список, в котором мы ищем нужный нам InheritedWidget
.
@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, {Object? aspect}) {
/// Если списка зависимостей нет, то создаём его
/// Этот список содержит элементы, на которые мы подписались
_dependencies ??= HashSet<InheritedElement>();
/// Добавляем в список зависимостей найденный нами элемент для виджета T,
/// который наследуется от InheritedWidget, в нашем случае LikesInheritedWidget
_dependencies!.add(ancestor);
/// Теперь обновляем список зависимых элементов у InheritedElement,
/// добавляя туда наш текущий элемент
ancestor.updateDependencies(this, aspect);
/// И возвращаем наш LikesInheritedWidget
return ancestor.widget as InheritedWidget;
}
Метод updateDependencies
просто вызывает другой метод setDependencies
. А тот в свою очередь просто добавляет наш элемент виджета LikesWidget
в список зависимых элементов. value
— это aspect
, который упомянут выше.
void setDependencies(Element dependent, Object? value) {
_dependents[dependent] = value;
}
Всё это лишь говорит, что дочерние виджеты добавили себя в список зависимых виджетов InheritedWidget
, а также пометили для себя, что они зависят от указанного InheritedWidget
.
А что насчёт списка зависимостей (InheritedWidget
)? Давайте изучим _inheritedWidgets
. Это Map
, ключи которой — тип виджета, а значение — элемент виджета. Она просто хранит все родительские InheritedElements
.
Обычные элементы просто сохраняют ссылку на эту мапу к себе.
/// Именно ссылаются на мапу, а не копируют её
_inheritedWidgets = _parent?._inheritedWidgets;
А вот InheritedElement
создаёт эту мапу либо копирует её и добавляет туда себя, когда вызывается метод _updateInheritance
.
@override
void _updateInheritance() {
final Map<Type, InheritedElement>? incomingWidgets = _parent?._inheritedWidgets;
if (incomingWidgets != null) {
/// копирует мапу родителя
_inheritedWidgets = HashMap<Type, InheritedElement>.of(incomingWidgets);
} else {
/// Создаёт новую, если выше по дереву это первый InheritedElement
_inheritedWidgets = HashMap<Type, InheritedElement>();
}
/// Добавляет себя в эту мапу
_inheritedWidgets![widget.runtimeType] = this;
}
Из этого можно сделать вывод, что все зависимости можно получить быстро за O(1), так как они копируются/ссылаются для каждого элемента.
Мы рассмотрели, как происходит подписка на изменение InheritedWidget
, а что же происходит при обновлении данных InheritedWidget
?
Допустим, по какой-то причине обновились данные у InheritedWidget
. Например, мы поставили лайк и где-то вызвалась смена состояния, в итоге в InheritedWidget
попало другое количество лайков.
Происходит обновление дочернего элемента, т. е. элемента для виджета LikesInheritedWidget
. Для этого элемента происходит вызов метода update
, который вызывает updated
.
updated
вызывает определённый у нашего LikesInheritedWidget
метод updateShouldNotify
. Если число лайков обновилось, то вызывает родительский updated
. Он в свою очередь вызывает метод notifyClients
, который сообщает всем зависимостям из списка _dependencies
, что обновилось количество лайков. Делается это с помощью вызова метода notifyDependent
, который вызывает у зависимого элемента метод didChangeDependencies
. А далее didChangeDependencies
вызывает метод markNeedsBuild
, который помечает зависимые элементы как требующие перерисовки.
Для наглядности продемонстрируем процесс вызовов диаграммой:
Теперь, когда мы рассмотрели взаимодействие InheritedWidget
и Element
, можем перейти к не менее важной паре — RenderObject
и Element
.
RenderObject и Element
Не все виджеты могут рисовать что-то. Некоторые виджеты просто содержат другие виджеты, вторые компонуют эти виджеты, третьи рисуют что-либо — например, красный овал.
Виджеты, которые могут компоновать (например, Column
, Row
и пр.) и отрисовывать, содержат метод createRenderObject
. Он создаёт уже знакомый нам RenderObject
.
Такие виджеты наследуются от класса RenderObjectWidget
. И также содержат метод, который создаёт особый элемент RenderObjectElement
.
RenderObjectElement
содержит новые методы, отличные от других элементов: insertRenderObjectChild
, moveRenderObjectChild
, removeRenderObjectChild
; а также переопределяют методы: attachRenderObject
, detachRenderObject
.
Давайте рассмотрим подробнее работу attachRenderObject
, который добавляет RenderObject
в дерево рендеринга при mount
элемента:
Мы тут видим, что происходит поиск родительского RenderObjectElement
. У родительского элемента вызывается метод insertRenderObjectChild
, который вставляет RenderObject
текущего элемента в родительский RenderObject
.
@override
void attachRenderObject(...) {
...
_ancestorRenderObjectElement = _findAncestorRenderObjectElement();
_ancestorRenderObjectElement?.insertRenderObjectChild(renderObject, ...);
...
}
Метод detachRenderObject
вызывается при деактивации элемента. Одновременно с этим метод удаляет RenderObject
из дерева рендеринга (при условии, что он уже находится там). Удаление RenderObject
происходит с помощью вызова removeRenderObjectChild
.
@override
void detachRenderObject() {
if (_ancestorRenderObjectElement != null) {
_ancestorRenderObjectElement!.removeRenderObjectChild(renderObject, ...);
_ancestorRenderObjectElement = null;
}
...
}
Метод moveRenderObjectChild
вызывается у виджетов, содержащих множество дочерних, например виджет Column
или Row
, при перемещении внутри списка children
. И служит для того, чтобы поместить RenderObject
в новый слот.
Заключение
Давай подведём небольшой итог.
Мы узнали что Widget
— это лишь конфигурация для Element
, а сам Element
— это реализация этого виджета (конфигурации). Мы рассмотрели, какие бывают типы элементов, и изучили процесс создания.
Теперь вы сможете ответить на сложные вопросы об устройстве виджетов на собеседованиях 😃
Что ж, а далее перейдём к более подробному рассмотрению RenderObject
.