Всем привет! Меня зовут Михаил Зотьев, я работаю Flutter-разработчиком в Surf. Мне, как, наверное, большинству других разработчиков, которые работают с Flutter, больше всего нравится то, как просто создавать с его помощью красивые и удобные приложения. Чтобы войти во Flutter разработку, нужно совсем немного времени. Недавно я работал в геймдеве, а теперь полностью переключился на кроссплатформенную мобильную разработку на Flutter.

В чём простота? С помощью десятка базовых виджетов можно собирать вполне приличные пользовательские интерфейсы. А со временем, когда багаж используемого скопится вполне приличный, вряд ли какая-то задача поставит вас в тупик: будь то необычный дизайн или изощренная анимация. А самое интересное — скорее всего вы сможете этим пользоваться, даже не задумываясь над вопросом: «А как оно вообще работает?».

Поскольку у Flutter открытые исходники, я решил разобраться с тем, что же там под капотом (on the Dart side of the Force), и поделиться этим с вами.



Widget


Все мы не раз слышали фразу от команды разработчиков фреймворка: «Всё во Flutter — это виджеты». Давайте посмотрим, так ли это на самом деле. Для этого обратимся к классу Widget (далее — виджет) и начнем постепенно знакомиться с содержимым.

Первое, что мы прочитаем в документации к классу:
Describes the configuration for an [Element].

Оказывается, сам виджет — это лишь описание некоторого Element (далее — элемент).
Widgets are the central class hierarchy in the Flutter framework. A widget is an immutable description of part of a user interface. Widgets can be inflated into elements, which manage the underlying render tree.
Если подытожить, то фраза «Всё во Flutter — это виджеты» — минимальный уровень понимания как всё устроено, чтобы пользоваться Flutter. Виджет является центральным классом в иерархии Flutter. В то же время вокруг него множество дополнительных механизмов, которые помогают фреймворку справляться со своей задачей.

Итак, мы узнали ещё несколько фактов:

  • виджет — неизменяемое описание части пользовательского интерфейса;
  • виджет связан с некоторым расширенным представлением, которое называется элемент;
  • элемент управляет некоторой сущностью дерева рендеринга.

Вы наверняка заметили странность. Пользовательский интерфейс и неизменяемость очень плохо вяжутся между собой, я бы даже сказал, что это совсем несовместимые понятия. Но к этому мы ещё вернёмся, когда будет вырисовываться более полная картина устройства мира Flutter, а пока продолжим знакомиться с документацией виджета.
Widgets themselves have no mutable state (all their fields must be final).
If you wish to associate mutable state with a widget, consider using a [StatefulWidget], which creates a [State] object (via [StatefulWidget.createState]) whenever it is inflated into an element and incorporated into the tree.
Этот абзац немного дополняет первый пункт: если нам нужна изменяемая конфигурация, для этого используется специальная сущность State (далее — состояние), которая как раз и описывает текущее состояние этого виджета. Однако, состояние связано не с виджетом, а с его элементным представлением.
A given widget can be included in the tree zero or more times. In particular a given widget can be placed in the tree multiple times. Each time a widget is placed in the tree, it is inflated into an [Element], which means a widget that is incorporated into the tree multiple times will be inflated multiple times.
Один и тот же виджет может быть включен в дерево виджетов множество раз, или вовсе не быть включенным. Но каждый раз, когда виджет включается в дерево виджетов, ему сопоставляется элемент.

Итак, на данном этапе с виджетами почти покончено, давайте подведем итоги:

  • виджет — центральный класс иерархии;
  • виджет — это некоторая конфигурация;
  • виджет — неизменяемое описание части пользовательского интерфейса;
  • виджет связан с элементом, который некоторым образом управляет рендерингом;
  • изменяемое состояние виджета может быть описано некоторой сущностью, но связано оно не с виджетом, а с элементом, который представляет этот виджет.

Element


Из того, что мы узнали, напрашивается вопрос «Что это за элементы такие, которые всем управляют?» Поступим аналогичным образом — откроем документацию класса Element.
An instantiation of a [Widget] at a particular location in the tree.
Элемент — некоторое представление виджета в определенном месте дерева.
Widgets describe how to configure a subtree but the same widget can be used to configure multiple subtrees simultaneously because widgets are immutable. An [Element] represents the use of a widget to configure a specific location in the tree. Over time, the widget associated with a given element can change, for example, if the parent widget rebuilds and creates a new widget for this location.
Виджет описывает конфигурацию некоторой части пользовательского интерфейса, но как мы уже знаем, один и тот же виджет может использоваться в разных местах дерева. Каждое такое место будет представлено соответствующим элементом. Но со временем, виджет, который связан с элементом может поменяться. Это означает, что элементы более живучие и продолжают использоваться, лишь обновляя свои связи.

Это довольно рациональное решение. Как мы уже определили выше, виджеты — неизменяемая конфигурация, которая просто описывает определенную часть интерфейса, а значит они должны быть очень легковесными. А элементы, в зоне которых управление, являются намного более тяжеловесными, но они не пересоздаются без надобности.

Чтобы понять каким образом это осуществляется, рассмотрим жизненный цикл элемента:

  • Элемент создаётся посредством вызова метода Widget.createElement и конфигурируется экземпляром виджета, у которого был вызван метод.
  • С помощью метода mount созданный элемент добавляется в заданную позицию родительского элемента. При вызове данного метода также ассоциируются дочерние виджеты и элементам сопоставляются объекты дерева рендеринга.
  • Виджет становится активным и должен появиться на экране.
  • В случае изменения виджета, связанного с элементом (например, если родительский элемент изменился), есть несколько вариантов развития событий. Если новый виджет имеет такой же runtimeType и key, то элемент связывается с ним. В противном случае, текущий элемент удаляется из дерева, а для нового виджета создаётся и ассоциируется с ним новый элемент.
  • В случае, если родительский элемент решит удалить дочерний элемент, или промежуточный между ними, это приведет к удалению объекта рендеринга и переместит данный элемент в список неактивных, что приведет к деактивации элемента (вызов метода deactivate).
  • Когда элемент считается неактивным, он не находится на экране. Элемент может находиться в неактивном состоянии только до конца текущего фрейма, если за это время он остается неактивным, он демонтируется (unmount), после этого считается несуществующим и больше не будет включен в дерево.
  • При повторном включении в дерево элементов, например, если элемент или его предки имеют глобальный ключ, он будет удален из списка неактивных элементов, будет вызван метод activate, и рендер объект, сопоставленный данному элементу, снова будет встроен в дерево рендеринга. Это означает, что элемент должен снова появиться на экране.

В объявлении класса мы видим, что элемент имплементирует BuildContext интерфейс. BuildContext — это нечто, что управляет позицией виджета в дереве виджетов, как следует из его документации. Почти в точности совпадает с описанием элемента. Данный интерфейс используется, чтобы избежать прямого манипулирования элементом, но при этом дать доступ к необходимым методам контекста. Например, findRenderObject, который позволит найти объект дерева рендера, соответствующий данному элементу.

RenderObject


Осталось разобраться с последним звеном данной триады — RenderObject. Как следует из названия — это объект дерева визуализации. У него есть родительский объект, а также поле с данными, которое родительский объект использует для хранения специфичной информации, касающейся самого этого объекта, например, его позицию. Данный объект отвечает за реализацию базовых протоколов отрисовки и расположения.

RenderObject не ограничивает модель использования дочерних объектов: их может не быть, может быть один или множество. Также не ограничивается система позиционирования: картезианская система, полярные координаты, всё это и многое другое доступно для использования. Нет ограничений и на использование протоколов расположения: подстройка по ширине или высоте, ограничение размера, задание размеров и расположения родителем или при необходимости использование данных родительского объекта.

Картина мира Flutter


Попытаемся построить общую картину, как всё работает вместе.

Мы уже отметили выше, виджет — это неизменяемое описание, но пользовательский интерфейс совсем не статичен. Убирается данное несоответствие разделением на 3 уровня объектов и разделение зон ответственности.

  • Дерево виджетов, в зону ответственности которого относится декларирование конфигураций и хранение свойств.
  • Дерево элементов, которые управляют жизненным циклом и связывают виджеты в некоторую иерархию и объекты рендеринга с ними.
  • Дерево объектов визуализации, ответственность которых — отрисовка, учет размеров положения и ограничений.

image

Рассмотрим, как выглядят данные деревья на простом примере:

image

В данном случае мы имеем некоторый StatelessWidget, обёрнутый в виджет Padding и содержащий внутри текст.

Давайте поставим себя на место Flutter — нам дали данное дерево виджетов.

Flutter: «Эй, Padding, мне нужен твой элемент»
Padding: «Конечно, держи SingleChildRenderObjectElement»

image

Flutter: «Элемент, вот твое место, располагайся»
SingleChildRenderObjectElement: «Ребят, все ок, но мне нужен RenderObject»
Flutter: «Padding, как тебя вообще отрисовывать?»
Padding: «Держи, RenderPadding»
SingleChildRenderObjectElement: «Отлично, приступаю к работе»

image

Flutter: «Так, кто там следующий? StatelessWidget, теперь ты давай элемент»
StatelessWidget: «Вот StatelessElement»
Flutter: «StatelessElement, ты будешь в подчинении у SingleChildRenderObjectElement, вот место, приступай»
StatelessElement: «Ок»

image

Flutter: «RichText, элементик предъявите, пожалуйста»
RichText отдает MultiChildRenderObjectElement
Flutter: «MultiChildRenderObjectElement, вот твоё место, приступай»
MultiChildRenderObjectElement: «Мне для работы нужен рендер»
Flutter: «RichText, нам нужен рендер объект»
RichText: «Вот RenderParagraph»
Flutter: «RenderParagraph будешь получать указания RenderPadding, а контролировать тебя будет MultiChildRenderObjectElement»
MultiChildRenderObjectElement: «Теперь всё ок, я готов»

image

Наверняка вы зададите закономерный вопрос: «А где объект рендеринга для StatelessWidget, почему его нет, мы же выше определились, что элементы связывают конфигурации с отображением?» Обратим внимание на базовую реализацию метода mount, про который шла речь в этом пункте описания жизненного цикла.

void mount(Element parent, dynamic newSlot) {
    assert(_debugLifecycleState == _ElementLifecycle.initial);
    assert(widget != null);
    assert(_parent == null);
    assert(parent == null || parent._debugLifecycleState == _ElementLifecycle.active);
    assert(slot == null);
    assert(depth == null);
    assert(!_active);
    _parent = parent;
    _slot = newSlot;
    _depth = _parent != null ? _parent.depth + 1 : 1;
    _active = true;
    if (parent != null)
        _owner = parent.owner;
    if (widget.key is GlobalKey) {
        final GlobalKey key = widget.key;
        key._register(this);
    }
    _updateInheritance();
    assert(() {
        _debugLifecycleState = _ElementLifecycle.active;
        return true;
    }());
}

Мы не увидим в нём создания объекта рендеринга. Но элемент имплементирует BuildContext, в котором есть метод поиска объекта визуализации findRenderObject, который приведёт нас к следующему геттеру:

RenderObject get renderObject {
    RenderObject result;
    void visit(Element element) {
        assert(result == null); 
        if (element is RenderObjectElement)
            result = element.renderObject;
        else
            element.visitChildren(visit);
    }
    visit(this);
    return result;
}

В базовом случае элемент может не создавать объект рендеринга, это обязаны делать только RenderObjectElement и его наследники, однако в таком случае у элемента на каком-то из уровней вложенности обязательно должен быть дочерний элемент, имеющий объект рендеринга.

Казалось бы зачем все эти сложности. Целых 3 дерева, разные зоны ответственности и т.д. Ответ довольно прост — именно на этом строится производительность Flutter. Виджеты неизменяемые конфигурации, поэтому довольно часто пересоздаются, но при этом они довольно лёгкие, что не сказывается на производительности. А вот тяжелые элементы Flutter пытается максимально переиспользовать.

Рассмотрим на примере.

Текст посередине экрана. Код в данном случае будет выглядеть примерно так:

body: Center(
    child: Text(“Hello world!”)
),

В данном случае дерево виджетов будет выглядеть следующим образом:

image

После того, как Flutter построит все 3 дерева, мы получим следующую картину:

image

Что же произойдет, если у нас изменится текст, который мы собираемся отобразить?

image

У нас теперь есть новое дерево виджетов. Выше мы говорили про максимально возможное переиспользование элементов. Взглянем на метод класса Widget, под говорящим названием canUpdate.

static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key;
}

Мы проверяем тип предыдущего виджета и нового, а также их ключи. Если они одинаковы, то нет необходимости менять элемент.

Итак, до обновления первый элемент это Center, после обновления также Center. У обоих нет ключей, полное совпадение. Можем обновить ссылку элемента на новый виджет.

image

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

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

image

Переходи к RichText. Виджет также не поменял свой тип, по ключам нет расхождений. Элемент обновляет свою связь с новым виджетом.

image

Связь обновлена, осталось лишь актуализировать свойства. В результате RenderParagraph отобразит новое значение текста.

image

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

Благодаря подобной работе и достигается такая высокая производительность Flutter.

Пример выше описывает случай, когда не изменилась сама структура виджетов. Но что же произойдет, если структура будет меняться? Flutter, конечно же, продолжит пытаться максимально использовать уже имеющиеся объекты, как мы поняли из описания жизненного цикла, но для всех новых виджетов будут созданы новые элементы, а старые и более ненужные будут в конце кадра удалены.

Рассмотрим пару примеров. И для того, чтобы удостовериться в вышесказанном используем инструмент Android Studio — Flutter Inspector.

@override
Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: _isFirst ? first() : second(),
        ),
        floatingActionButton: FloatingActionButton(
            child: Text("Switch"),
            onPressed: () {
                setState(() {
                    _isFirst = !_isFirst;
                });
            },
        ),
    );
}

Widget first() => Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
        Text(
            "test",
            style: TextStyle(fontSize: 25),
        ),
        SizedBox(
            width: 5,
        ),
        Icon(
            Icons.error,
        ),
    ],
);

Widget second() => Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
        Text(
            "one more test",
            style: TextStyle(fontSize: 25),
        ),
        Padding(
            padding: EdgeInsets.only(left: 5),
        ),
        Icon(
            Icons.error,
        ),
    ],
);

В данном случае по нажатии на кнопку один из виджетов поменяется. Посмотрим, что покажет нам инспектор.

image

image

Как мы видим, Flutter пересоздал рендер только для Padding, остальные просто переиспользовал.

Рассмотри ещё 1 вариант, в котором структура поменяется более глобальным образом — поменяем уровни вложенности.

Widget second() => Container(child: first(),);

image

image

Несмотря на то, что визуально дерево совершенно не поменялось, были пересозданы элементы и объекты дерева рендеринга. Это произошло, потому что Flutter сравнивает по уровням (в данном случае неважно, что большая часть дерева не изменилась), отсеивание этой части прошло на момент сравнения Container и Row. Однако из этой ситуации можно выкрутиться. В этом нам помогут GlobalKey. Добавим такой ключ для Row.

var _key = GlobalKey(debugLabel: "testLabel");

Widget first() => Row(
    key: _key,
    …
);

image

image

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

Вывод


Мы чуть ближе познакомились с магией Flutter и теперь знаем, что она не только в виджетах.

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

Я надеюсь, что информация из этой статьи полезна для понимания, как Flutter работает внутри и поможет находить изящные и производительные решения во время разработки.

Спасибо за внимание!

Ресурсы


Flutter
«How Flutter renders Widgets» Andrew Fitz Gibbon, Matt Sullivan