15 KiB
Поток данных в Glint Runtime
Как исходный код превращается в пиксели на экране.
flowchart TD
subgraph "Компиляция"
A1[".gltm markup"] --> P["Parser: glt crate"]
A2[".glts style"] --> P
P --> M["ModuleSoA — плоские массивы"]
M --> AST
AST --> C["Compiler: glt crate"]
C --> BC[".glbc bytecode"]
end
subgraph "Загрузка"
BC --> IR["Interpreter::run"]
IR --> R["Reader: парсит бинарник"]
R --> DOC["Document: дерево + стили + переменные"]
end
subgraph "Инициализация"
DOC --> BOOT["iced::application boot"]
BOOT --> RC["RheiContext: компилирует Rhai-скрипты"]
BOOT --> EV0["evaluate_vdom: собирает VDOM целиком"]
EV0 --> APP["GlintApp: готов к работе"]
end
subgraph "Круг жизни: каждый кадр"
APP --> LOOP{"iced event loop"}
LOOP -->|пришло событие| MSG[Message]
MSG --> UPD["GlintApp::update"]
UPD --> SET["меняет переменную"]
SET --> TV["tracker помечает зависимые элементы как dirty"]
TV --> DIRTY["забрать dirty_set"]
DIRTY --> VDOM["evaluate_vdom_incr: пересчитать только dirty"]
VDOM --> STYLE["StyleSheet: применить стили (с кэшем)"]
STYLE --> NEW_VDOM["новый VDOM"]
LOOP -->|по таймеру| VIEW["GlintApp::view"]
VIEW --> REND["render_element: Element → Iced-виджет"]
REND --> ICED["iced::Element дерево"]
ICED --> DIFF["Iced: сравнивает с предыдущим кадром"]
DIFF --> LAYOUT[Layout]
LAYOUT --> DRAW["GPU рисует"]
end
subgraph "Стили — отдельно"
STYLE --> SI["StyleIndex: ищет правила за O(1)\u2013O(K)"]
SI --> SC["StyleCache: не парсит одно и то же дважды"]
end
Этап 1: Компиляция — из текста в байткод
Всё начинается с двух типов файлов:
.gltm— разметка: кнопки, панели, тексты, слайдеры и так далее..glts— стили: CSS-подобные правила, селекторы, цвета, отступы.
Их компилирует внешний crate glt (не часть этого репозитория). Он делает три вещи:
1.1 Парсинг
Parser читает .gltm и .glts и складывает всё в ModuleSoA.
Что такое ModuleSoA? SoA = Structure of Arrays (структура массивов). Вместо того чтобы хранить элементы как список структур:
// Array of Structures (AoS) — как мы привыкли
Element { name: "Button", props: [...], children: [...] }
Element { name: "Text", props: [...], children: [...] }
компилятор хранит их как структуру с параллельными массивами:
// Structure of Arrays (SoA) — эффективнее для компилятора
ModuleSoA {
type_names: ["Button", "Text", ...],
properties_vec: [ [...], [...], ...],
hierarchy: [parent_id, parent_id, ...],
}
Так компилятор проходит по всем именам разом (кэш процессора не простаивает), быстрее ищет родительские связи и легче применяет оптимизации.
1.2 Построение AST
Из ModuleSoA строится AST-дерево. Здесь раскрываются компоненты, if/each ветки,
подставляются параметры.
1.3 Генерация байткода
Compiler обходит AST и превращает его в бинарный формат .glbc:
- заголовок с magic-байтами (
"glBc") - пул строк (все имена, классы, тексты — одним блоком)
- байт-кодированные опкоды (см.
opcodes.rs:OP_ELEM_PUSH,OP_PROP,OP_IF,OP_EACHи т.д.)
Результат — компактный бинарник, который можно быстро загрузить и скормить рантайму.
Этап 2: Загрузка — из байткода в Document
Рантайм берёт .glbc и превращает его в структуры данных, с которыми можно работать.
Interpreter::run(bytecode) → Document
Внутри Reader последовательно читает байткоп:
- Проверяет magic-байты (это точно
.glbc?) - Читает пул строк
- Исполняет опкоды, на лету собирая дерево
Element
Параллельно происходят две важные вещи:
Стили: каждая встреченная стилевая директива парсится в StyleRule,
потом из всех правил строится StyleIndex — каталог: «вот все правила для тэга Button,
вот для класса primary, вот для элемента с id=submit». Так поиск стиля
потом будет занимать не O(все правила), а O(пара штук).
Зависимости: каждое свойство вида "text": "Hello $name" — это подсказка:
элемент зависит от переменной name. ReactiveTracker сканирует все свойства,
находит $var и запоминает: «элемент ElementId(5) зависит от переменной "name"».
В итоге получается Document:
Document {
roots: Vec<Element>, // корневые элементы
components: HashMap<String, ComponentDef>, // компоненты
variables: HashMap<String, Value>, // начальные значения
stylesheet: StyleSheet, // таблица стилей
rhei_scripts: Vec<String>, // init-скрипты
tracker: ReactiveTracker, // кто от чего зависит
interner: Interner, // пул уникальных строк
}
Этап 3: Инициализация — подготовка к жизни
Document готов, но его надо «завести». Это делает boot-функция Iced.
3.1 Клонирование
doc.clone() — все строки внутри Element имеют тип &'a str с исходным
временем жизни. После клонирования они становятся &'static str (рантайм
делает Box::leak, чтобы строки жили вечно — приложение работает до закрытия окна).
3.2 Компиляция Rhai
RheiContext::new(scripts):
- Создаёт Rhai-движок (
Engine) - Компилирует все init-скрипты в AST и сохраняет их
- Собирает все функции из скриптов в глобальный модуль
- Потом
precompile_all_from_doc()проходит по всему дереву Element и компилирует каждый__on:click { ... }и каждое!rhei:exprв кэш. Теперь при клике не надо компилировать заново — достаточно взять AST из кэша.
3.3 Запуск init-скриптов
initialize(): синхронизирует переменные с Rhai-скопом, выполняет init-скрипты,
забирает из скопа всё, что изменилось.
3.4 Первый VDOM
evaluate_vdom() — полный проход по дереву:
- Подставляет переменные в строки (
$name→ реальное значение) - Вычисляет условия
@if - Раскрывает
@eachв реальное количество элементов - Для каждого элемента находит подходящие стили и вычисляет
ComputedStyle - Присваивает
content_hash
Результат: GlintApp { doc, rhei, vdom_roots }. Первый кадр готов к показу.
Этап 4: Круг жизни — каждый кадр
Iced работает в цикле: событие → update() → view() → отрисовка.
4.1 Пришло событие: update()
Пользователь нажал кнопку, подвигал слайдер, ввёл текст — Iced присылает Message.
enum Message {
SliderChanged(Option<String>, f64), // слайдер: (привязанная переменная, новое значение)
InputChanged(Option<String>, String), // текстовое поле
ToggleChanged(Option<String>, bool), // чекбокс
EventTriggered(String), // клик по кнопке: запустить Rhai-скрипт
WindowScrolled(f32), // скролл окна
ScrollableScrolled(u64, f32), // скролл внутри контейнера
}
GlintApp::update() делает так:
- Меняет переменную. Например,
SliderChanged("volume", 75)→variables["volume"] = 75.0. - Сообщает трекеру:
tracker.on_variable_changed("volume"). Трекер смотрит: «от этой переменной зависят элементы с ID = 5, 12, 18». Он помечает их как dirty. - Забирает dirty_set:
tracker.take_dirty_set(). - Пересчитывает VDOM:
evaluate_vdom_incr(roots, &dirty_set). Она проходит по дереву. Если элемент в dirty_set — пересчитывает его (подстановка переменных, вычисление стилей). Если нет — оставляет как есть. Дети dirty-элемента тоже пересчитываются (каскад).
4.2 По таймеру: view()
Даже если ничего не произошло, Iced вызывает view() каждый кадр (60 раз в секунду).
Нужно вернуть Iced-виджеты, которые он нарисует.
render_element() — рекурсивная функция, которая превращает Element в Iced-виджет:
Button→iced::button(...).on_press(...)Text→iced::text("...").size(16).color(...)Panel→iced::column[...].spacing(10), обёрнутый в контейнер с фоном и рамкойInput→iced::text_input("placeholder", "value").on_input(...)Image→iced::image(path)илиiced::svg(path)- Неизвестный тип → просто колонка с детьми
Каждый виджет оборачивается в apply_universal_box_model:
контейнер [margin]
контейнер [padding, border, background]
scrollable (если overflow: scroll/auto)
контейнер [padding]
сам виджет
Проблема: render_element создаёт все виджеты с нуля каждый кадр, даже если
Element не изменился. Iced потом диффит новое дерево со старым — но само построение
дерева стоит ~7ms. Это главный резерв оптимизации.
4.3 Iced делает своё дело
Iced получает дерево iced::Element, сравнивает с предыдущим (diff), вычисляет
раскладку (layout) и рисует через GPU (wgpu). Всё это без участия нашего кода.
Анатомия Element
Element {
type_name: "Button", // что это за элемент
properties: [("label", "Click"), ("color", "red"), ...], // его свойства
computed_style: ComputedStyle { color: Some(Red), padding: Some(8px), ... }, // вычисленный стиль
element_id: ElementId(42), // уникальный ID в дереве
content_hash: 0xABCD1234, // хэш содержимого (для кэша виджетов)
children: [Element, ...], // дочерние элементы
}
Анатомия стилей
Стили хранятся в StyleSheet и работают в три этапа:
1. Индекс (StyleIndex): при загрузке все CSS-правила раскладываются по полочкам:
Правило: "Button.primary#submit { color: red; padding: 10px }"
→ by_tag["Button"] = { RuleId(1) }
→ by_class["primary"] = { RuleId(1) }
→ by_id["submit"] = { RuleId(1) }
2. Поиск: когда нужно найти стили для элемента Button.primary#submit,
мы берём пересечение множеств из всех трёх полок. Вместо проверки 500 правил — 3 lookup'а.
3. Кэш: даже если стили найдены, ComputedStyle::compute() парсит все свойства
(цвет, отступы, шрифты — около 40 полей). Это дорого. StyleCache запоминает
результат: hash(type_name, properties, эпоха) → ComputedStyle. Если элемент
не менялся — берём готовый стиль из кэша, не парсим.
Событийный цикл на примере слайдера
1. Пользователь двигает слайдер громкости
2. Iced: SliderChanged(Some("volume"), 75.0)
3. GlintApp::update:
a. variables["volume"] = Float(75.0)
b. tracker.on_variable_changed("volume")
→ грязные: ElementId(5) — текст с "$volume", ElementId(12) — ширина от "$volume"
c. evaluate_vdom_incr(roots, &{5, 12})
→ Element 5: пересчитать текст (новая громкость)
→ Element 12: пересчитать ширину
→ остальные 48 элементов: не трогать
4. GlintApp::view:
→ render_element для всех 50 root-элементов
→ рекурсивно для всех детей (даже для тех 48, что не менялись)
→ Iced получает полностью новое дерево из 200+ виджетов
5. Iced: диффит → находит 2 изменения → перерисовывает 2 области
Узкое место: шаг 4. VDOM пересчитал только 2 элемента из 50 (спасибо ReactiveTracker). Но render_element создаёт виджеты для всех 200+ узлов. Iced потом всё равно диффит и ничего не делает с 198 из них, но время на их создание уже потрачено.