Как известно, любой фреймворк использует дерево отрисовки для представления UI, а также применяет механизм «различий» (diff) для повышения производительности при обновлении UI. В этой статье мы рассмотрим реализацию механизма diff в Flutter.
Рассмотрим сначала Flutter, где существует три дерева: дерево виджетов (Widget Tree), дерево элементов (Element Tree) и дерево объектов рендера (RenderObject Tree). Поскольку в Flutter виджеты являются неизменяемыми (аналогично неизменяемым виджетам в React), изменения виджетов приводят к созданию новых виджетов, что делает дерево виджетов скорее конфигурационным деревом, чем фактическим деревом отрисовки.
Поэтому дерево элементов (Element Tree) является основным деревом UI, которое отвечает за управление жизненным циклом компонентов и логики создания/обновления. Элементы (Element) выступают как «центр управления», связывающий все процессы отрисовки UI.
Основной темой сегодняшнего обсуждения будет Element Tree. Когда виджет загружается, он создает соответствующий элемент (element), который затем решает, можно ли переиспользовать существующий элемент (element) на основе ключа (
key
) и типа времени выполнения (runtimeType
). Переиспользование элемента (element) является ключевой частью механизма diff в Flutter:
Давайте рассмотрим последовательность действий, происходящих при вызове метода setState()
, который используется для обновления состояния:
setState()
состоит в том, чтобы пометить все грязные элементы (Element) как такие, выполняя внутри каждого элемента _dirty = true
._dirtyElements
объекта BuildOwner
.buildScope
выполняет сортировку _dirtyElements
с помощью метода Element._sort
и запускает процесс перестроения (rebuild
):Поэтому весь процесс обновления пропускает чистые Element'ы, обрабатывая только грязные Element'ы, в то же время на этапе сборки информация течёт однозначно по дереву Element'ов, каждый Element максимум один раз посещается, а как только он "очищается", этот Element больше не становится грязным, так как можно заключить, что все его родительские элементы также являются чистыми. Затем следует классический линейный синтез в Flutter, в Flutter вместо использования деревьев разностей используется алгоритм O(N) для независимой проверки каждого списка потомков Element для определения возможности повторного использования Element.> Когда фреймворк может повторно использовать Element, логическое состояние пользовательского интерфейса сохраняется, а ранее вычисленные данные о расположении также могут быть переиспользованы, что позволяет избежать полной рекурсивной проверки всего дерева.Проверка возможности повторного использования Element осуществляется главным образом в методе
updateChildren
. В реализации этого метода первым делом стоит понять следующий фрагмент кода:
if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget)) {
break;
}
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType &&
oldWidget.key == newWidget.key;
}
Логика данного условия легко воспринимается:
oldChild
) означает, что нет необходимости продолжать обновление, поэтому создание нового элемента становится единственным вариантом.canUpdate
предназначен для сравнения типов и ключей виджетов; если они не совпадают, то это указывает на необходимость создания нового виджета, так как текущий не может быть повторно использован.Оставшаяся часть реализуется путём детального поиска на основе этих условий. В методе updateChildren
основное внимание уделяется двум спискам — List<Element> oldChildren
и List<Widget> newWidgets
, хотя фактически это представляет собой обработку двух списков элементов. Общая последовательность действий включает:
Быстрое сканирование и синхронизация с вершины и снизу:
oldChild
, либо условие canUpdate
не выполняется. В этом процессе все подходящие элементы обновляются через вызов updateChild
. - От нижней части до вершины, быстрое сканирование до точки, где либо отсутствует oldChild
, либо условие canUpdate
не выполнено. Этот этап не требует обновления, но служит для быстрой локализации центральной области.Обработка центральной области:
Все еще существующие oldChild
помещаются в объект <Ключ, Элемент>
oldKeyedChildren
для быстрого соответствия, поскольку одинаковый ключ позволяет повторно использовать соответствующий Элемент
, даже при изменении его положения.
Центральная область просматривается, используя oldKeyedChildren
для извлечения oldChild
. Если oldChild
отсутствует, создается новый элемент, если же он существует и удовлетворяет условию canUpdate
, происходит обновление элемента. - Прямое обновление оставшихся незаполненных нижних областей, так как эти области точно можно обновить.
Очистка остатков старых oldKeyedChildren
.
Возврат нового списка элементов.
Если вы считаете, что это звучит слишком абстрактно, давайте рассмотрим простой пример. У нас есть два списка — новый (new
) и старый (old
). По указанному выше процессу мы начинаем обновление сверху вниз. Когда указатель top
нового списка доходит до элемента a
, он останавливается, так как условие не выполняется. В результате получаем список newChildren
, содержащий уже обновленные элементы 1
и 2
.Затем следует второй этап — сканирование снизу вверх. Здесь никаких обновлений не происходит. После встречи с элементом c
, который также не удовлетворяет условиям, указатель bottom
останавливается на элементе c
.
Далее обрабатывается средняя часть. Указатель top
старого списка перемещается от верха до нижней части, создавая объект {<Ключ, Элемент>}
, который используется для быстрого сравнения. При этом, если ключ равен null
, соответствующий старому элементу может быть освобожден заранее:
При обработке средней части, используя oldKeyedChildren
, при продолжении обновления указателя top
нового списка, если старый элемент (oldChild
) отсутствует, создаются новые элементы. Если же старый элемент существует и удовлетворяет условию canUpdate
, то происходит его обновление.
Наконец, обновляются оставшиеся нижние элементы, а затем очищаются oldKeyedChildren
и освобождаются старые элементы.
Почему нижняя область не обновляется сразу при первом проходе? Потому что необходимая информация о слотах ещё недоступна.
Слот представляет собой логическое положение подэлемента внутри родительского элемента, которое гарантирует правильность порядка рендера относительно логического порядка. Он особенно важен при изменении последовательности подэлементов, поскольку позволяет перераспределить слоты и обновить позицию RenderObject
.```dart
Object? slotFor(int newChildIndex, Element? previousChild) {
return slots != null
? slots[newChildIndex]
: IndexedSlot<Element?>(newChildIndex, previousChild);
}
Эта функция вызывается каждый раз при обновлении `updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))`. 
А когда начинается сканирование снизу, сразу не происходит обновления через `updateChild`, потому что в этот момент сканирование идет снизу вверх, а слот требует ссылку на «previousChild», то есть на предыдущий узел. **Он нуждается в последовательной вертикальной итерации**, поэтому при первоначальном сканировании снизу нет предшествующего узла слота, и тогда можно было только просканировать, но не произвести обновление.
С этого момента весь процесс обновления завершен. Кроме того, если это обновление `InheritedWidget`, фреймворк использует хэш-таблицу `_inheritedElements`, которую он поддерживает на каждом элементе, чтобы передать общую информацию об элементах вниз, тем самым избегая повторного прохождения родительской цепочки.
Как видно, Flutter основывается на линейном учете ключей и типов времени выполнения.
# Составление
Переходим к составлению, поскольку логика обновления отображения в Compose более сложна, так как она затрагивает множество модульных систем. Здесь мы быстро пройдемся по самому простому пониманию, подробнее см. ссылки ниже.Мы знаем, что аннотация `@Composable` является базовым построительным блоком Compose:

Конечно, при компиляции функций Composable компилятор Compose изменяет все функции Composable, добавляя параметр `Composer` во время этапа компиляции IR, как мы обсуждали ранее в контексте генерации `Continuation` для `suspend` в Kotlin:
```kotlin
// Наш код
@Composable
fun MyComposable(name: String) {
}
// После компиляции добавлены composer и changed
@Composable
fun MyComposable(name: String, $composer: Composer<*>, $changed: Int) {
Таким образом, аналогично
suspend
, обычные функции не могут вызывать функции Composable, но функции Composable могут вызывать обычные функции.
Здесь появляются два параметра:
Composer
: создаёт узлы, управляет таблицами слотов для создания и обновления состава;changed
: определяет, следует ли пропустить обновление на основе состояния целого числа, например, для статических узлов или изменения состояния.Мы знаем, что функции @Composable
не возвращают значения, как в Flutter, поэтому в реальной работе компилятор добавляет параметры к функциям @Composable
. Создание фактического дерева узлов UI и других операций начинается с Composer
:
Простое объяснение:- Часть changed выполняет предварительную проверку на возможность пропуска операции, основываясь на различных битовых операциях типа and
. Основной акцент делается на то, что это влияет на последующую проверку состояния $dirty
, которая определяет участие в процессе реконфигурации.
Состояние State может принимать различные побочные эффекты.
Методы Composer.startxxxxGroup и Composer.endxxxxGroup создают узел Node и таблицу слотов SlotTable.
Часть updateScope отвечает за запись снимков данных для сравнения различий.Внутри композера также существуют две дерева:
Виртуальное дерево Virtual tree, представленное таблицей слотов SlotTable, используется для записи состояний композиции.
Дерево пользовательского интерфейса UI tree, представленное узлами отображения LayoutNode, отвечает за измерение и рисование.
На самом деле, между методами startxxxxGroup и endxxxxGroup создаются два дерева, где таблица слотов SlotTable является ключевой частью для выявления изменений при диффе.
Все данные, созданные внутри Composable, хранятся в таблице слотов SlotTable, включая состояние State, ключи и значения. Таблица слотов поддерживает реконфигурацию между кадрами, а новые данные после реконфигурации обновляют таблицу слотов.
Фактически, таблица слотов представляет собой дерево с помощью линейного массива, так как она сама не является деревом:```kotlin внутренний класс SlotTable implements CompositionData, Iterable {
/**
* Массив для хранения информации о группах, где каждая группа представлена набором элементов размером [Group_Fields_Size].
* Этот массив можно рассматривать как массив встроенной структуры.
*/
var группы = IntArray(0)
private set
/**
* Массив для хранения слотов групп. Элементы слотов для каждой группы начинаются с смещения, указанного [dataAnchor], и продолжаются до следующих слотов группы или до конца массива [slotsSize]. При работе в режиме записи [dataAnchor] служит указателем вместо индекса, поскольку [слоты] могут содержать пустые места.
*/
var слоты = Array<Any?>(0) { null }
private set
}
```В таблице слотов есть два массива: группы
для хранения информации о группах и `слоты` для хранения данных. Информация о группах моделируется как дерево через родительское смещение, а данные хранятся через смещение данных. Поскольку `группы` и `слоты` не являются связанными списками, они могут расширяться при необходимости:
Ключ здесь очень важен. Когда вставляется код startXXXGroup
, композер генерирует уникальный ключ $key
на основе позиции кода. Этот ключ вместе со всей группой сохраняется в таблице слотов при первом использовании. При последующих операциях реконфигурации композер использует этот ключ для определения изменений, таких как добавление, удаление или перемещение групп. Переформатирование и перегруппировка начинаются с минимальной единицы — группы. Например, в таблице слотов (SlotTable
) различные функции Compose
или лямбды могут быть собраны в группу перезапуска (RestartGroup
).
Существует множество видов групп.
Снимок (Snapshot
) представляет собой реализацию наблюдения за изменениями состояния. Изменение состояния (state
) приводит к переформатированию. Например, при использовании mutableStateOf
для создания изменяемого состояния (MutableState
), создается снимок, который регистрируется как наблюдатель во время выполнения Compose
:
Имея снимок, мы получаем информацию обо всех состояниях, что позволяет сравнивать два состояния и выявлять различия для обновления, тем самым получая новый SlotTable
:
При переформатировании можно использовать изменённые состояния и данные SlotTable
(ключ, данные и т.д.) для определения списка изменений:
Наконец, метод applyChanges
проходит по списку изменений и применяет их, создавая новый SlotTable
. Изменения структуры SlotTable
затем обновляют соответствующие узлы макета через Applier
:
Важно отметить следующее:
Compose
был прерван, то операции внутри компонента (Composable
) не будут отражены в SlotTable
, так как applyChanges
должно выполняться после успешного завершения композиции.startXXXGroup
работают с группами в SlotTable
, выполняя сравнение (diff). Процесс внесения изменений ("вставка/удаление/перемещение") основан на буфере с зазором (Gap Buffer
). Это можно представить как незанятую область в группе, которая может перемещаться внутри группы, повышая эффективность обновлений при изменениях SlotTable
:Буфер с зазором минимизирует необходимость перемещения существующих узлов при каждом изменении.Если рассматривать только часть, связанную со сравнением, это будет метод
startXXXGroup
, который выполняет проход поSlotTable
и производит сравнение на основе положения группы, ключей и данных. Обновление происходит благодаря системе снимков при изменении состояния, а конечный дифференцированныйSlotTable
обновляет реальные узлы макета:
Можно заметить, что процесс «перезапроса» diff в Compose затрагивает множество модулей и деталей; например, метка состояния
$changed
может стать предметом отдельной статьи. Однако все это внутреннее устройство остаётся незамеченным для внешнего разработчика. Ядро реализации состоит из внутренней структуры slotTable, основанной на массивах пропусков, внутри компилированного Composer, что в целом напоминает концепцию Virtual DOM в React.
Если вас интересует подробная реализация рендера и обновления Compose, рекомендуется обратиться к ссылкам в разделе "Дополнительные материалы".
Краткий итог:
Flutter использует собственный алгоритм линейной синхронизации вместо деревянной структуры сравнения, минимизируя поиск для обновления конфигурационной информации виджета в элементе.- Jetpack Compose использует структуру данных gap buffer для обновления таблицы слотов SlotTable, которая применяется после завершения перестроения, а затем различия передаются через Applier для обновления соответствующих LayoutNode.# Дополнительные материалы
https://github.com/takahirom/inside-jetpack-compose-diagram/blob/main/diagram.png
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )