Files
Glint-Runtime/docs/ru/architecture/01-data-flow.md

15 KiB
Raw Permalink Blame History

Поток данных в 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 последовательно читает байткоп:

  1. Проверяет magic-байты (это точно .glbc?)
  2. Читает пул строк
  3. Исполняет опкоды, на лету собирая дерево 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() делает так:

  1. Меняет переменную. Например, SliderChanged("volume", 75)variables["volume"] = 75.0.
  2. Сообщает трекеру: tracker.on_variable_changed("volume"). Трекер смотрит: «от этой переменной зависят элементы с ID = 5, 12, 18». Он помечает их как dirty.
  3. Забирает dirty_set: tracker.take_dirty_set().
  4. Пересчитывает VDOM: evaluate_vdom_incr(roots, &dirty_set). Она проходит по дереву. Если элемент в dirty_set — пересчитывает его (подстановка переменных, вычисление стилей). Если нет — оставляет как есть. Дети dirty-элемента тоже пересчитываются (каскад).

4.2 По таймеру: view()

Даже если ничего не произошло, Iced вызывает view() каждый кадр (60 раз в секунду). Нужно вернуть Iced-виджеты, которые он нарисует.

render_element() — рекурсивная функция, которая превращает Element в Iced-виджет:

  • Buttoniced::button(...).on_press(...)
  • Texticed::text("...").size(16).color(...)
  • Paneliced::column[...].spacing(10), обёрнутый в контейнер с фоном и рамкой
  • Inputiced::text_input("placeholder", "value").on_input(...)
  • Imageiced::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 из них, но время на их создание уже потрачено.