3.1. Elements: подробный разбор

В предыдущих параграфах мы познакомились с основными виджетами 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, который сохраняет информацию, необходимую при работе приложения конкретно для этого виджета в конкретном месте приложения.

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

flutter

И если мы углубимся в реализацию класса 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 мы поговорим в конце параграфа).

flutter

Но что вызывает создание элемента? Хоть метод createElement и находится у виджета, но вызывает этот метод не виджет, а вызывают его другие элементы у своих детей.

Чтобы понять процесс создания, нам достаточно заглянуть вглубь Flutter, найти класс Element и посмотреть, что делает его метод inflateWidget.

Мы видим, что в этот виджет передаётся конфигурация (параметр newWidget), и это конфигурация для дочернего элемента, у неё мы вызываем метод createElement. В качестве результата вызова мы получим дочерний элемент. И так происходит, пока мы не дойдём до последнего виджета.

Element inflateWidget(Widget newWidget, Object? newSlot) {
	...
  final Element newChild = newWidget.createElement();
  ...
}

flt

Если вы заглянете в устройство любого виджета, то увидите, какой элемент создаётся для виджета. Например, для 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.

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

flutter

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;
  }

Как видите, метод принимает три параметра:

  1. Element? child — дочерний элемент.
  2. Widget? newWidget — новую конфигурацию для элемента.
  3. Object? newSlot — новый слот (место в дереве элементов), где будет располагаться элемент.

Этот метод также может быть вызван другим методом — inflateWidget.

У этого метода существует несколько сценариев выполнения. Они представлены на диаграмме ниже.

flutter

Теперь разберём их чуть подробнее.

  1. Если newWidget == null (это значит, что виджета больше нет), то child деактивируется, и вернётся null.

  2. Если child == null && newWidget ≠ null (означает, что появился новый виджет), то происходит вызов метода inflateWidget, в результате чего создаётся новый элемент, который будет использоваться в качестве дочернего.

  3. Если child ≠ null && newWidget ≠ null, то происходит следующее:

    • Происходит проверка на то, что нам была передана конфигурация типа, который соответствует типу элемента (под типом имеется в виду StatelessWidgetStatefulWidget или другие названия виджетов/элементов), результат сравнения лежит в переменной 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

Этот метод создает дочерний элемент для заданного виджета.
У него есть есть два параметра:

  1. Widget? newWidget — конфигурация для дочернего элемента.
  2. 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 {
    ...
  }
}

Вышеописанное можно представить в виде диаграммы:

flutter

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 у виджета и обновляет или создаёт элемент для него. Когда мы создаём виджет и определяем у него метод, этот метод возвращает нам дочерние виджеты. И именно под дочерние виджеты мы создаём элементы.

Создание первого и последующих элементов можно представить в виде диаграммы:

flt

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

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

У каждого элемента есть жизненный цикл. С помощью него можно понять, что виджет удалился с экрана, переместился по дереву, добавился на экран. Всего есть 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 новой конфигурации;
  • и тип конфигурации (то есть тип виджета) не изменился.

В остальных случаях создаётся новый элемент.

Если мы можем попробовать переиспользовать элемент, то происходит следующее:

flt

Неактивный элемент помещается в список к остальным таким же элементам. Этот список находится в классе _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?

flutter

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

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

updated вызывает определённый у нашего LikesInheritedWidget метод updateShouldNotify. Если число лайков обновилось, то вызывает родительский updated. Он в свою очередь вызывает метод notifyClients, который сообщает всем зависимостям из списка _dependencies, что обновилось количество лайков. Делается это с помощью вызова метода notifyDependent, который вызывает у зависимого элемента метод didChangeDependencies. А далее didChangeDependencies вызывает метод markNeedsBuild, который помечает зависимые элементы как требующие перерисовки.

Для наглядности продемонстрируем процесс вызовов диаграммой:

flt

Теперь, когда мы рассмотрели взаимодействие 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.

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

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.17. Flutter: архитектура фреймворка. Виды сборки
Следующий параграф3.2. Bindings