В прошлой статье я рассказывал, как мы делаем онлайн IDE для .NET — дизайн, компиляция и запуск приложений происходят прямо в браузере, без сервера. Проект живёт по адресу xaml.io. С тех пор мы много чего добавили, и в какой-то момент у нас стало получаться загружать в IDE довольно крупные .NET проекты — десятки XAML, сотни C# файлов, несколько референсных проектов и пакетов NuGet.

Одна из главных возможностей — это визуальный UI-дизайнер пользовательских приложений. И у него есть особенность: чтобы дизайнер вообще что-то показывал, приложение должно быть скомпилировано, причём в актуальном состоянии. Не «когда-нибудь», а после каждой правки.

Открыли как-то в IDE один реальный проект (FamilyShow — классическое демонстрационное WPF-приложение), поправили одну строку в .cs и 84 секунды наблюдали за анимацией прогресса. С таким UX никакой интерактивный дизайн невозможен.

Сейчас та же правка занимает 5 секунд, а правка XAML-разметки на типичной странице — меньше секунды. В этой статье — как мы к этому пришли, и какие сюрпризы нам приготовили по дороге Roslyn, OpenSilver, WASM и наш собственный код.

Демонстрация xaml.io
Демонстрация xaml.io

Что вообще компилируется

Кратко напомню, как устроена сборка приложения в нашей IDE. Под капотом — OpenSilver, наш open-source наследник WPF и Silverlight на Blazor WebAssembly. У OpenSilver-приложения две группы исходников:

  • Файлы .xaml — UI-разметка. Их сначала транспилирует XAML-компилятор OpenSilver в довольно объёмный C#.

  • Файлы .cs — обычный пользовательский C#.

Дальше всё это летит в Roslyn, который живёт в web worker’е — чтобы не морозить UI IDE. Воркер собирает DLL и отдаёт байты назад в IDE. А та либо рисует элементы в дизайнере, либо запускает всё приложение в App Previewer — отдельном iframe, в котором крутится скомпилированное приложение.

Собирается всё в два прохода: первый нужен, чтобы у Roslyn появились метаданные пользовательских типов, и XAML-транспайлер на втором проходе смог разрешить ссылки на пользовательские контролы. На объёмный XAML генерируется в среднем 1.5–3 тысячи строк C#, и всё это компилируется монолитно: сменил одну строку в MainWindow.xaml.cs — пересобирается весь проект целиком.

Идея 1: а давайте использовать инкрементальную компиляцию Roslyn

Сразу предупрежу: эта идея не выжила. Но выкинули мы её не сразу, а успев по дороге выяснить про Mono под WebAssembly такое, что я до сих пор считаю самой интересной находкой за всю историю. Так что дальше — честный рассказ про тупик.

С самого начала проекта у нас был план: в один прекрасный день порешать проблемы производительности с помощью инкрементальной компиляции Roslyn. Это та самая механика Edit-and-Continue (в современной обёртке — Hot Reload), которой пользуется Visual Studio: вместо новой сборки рантайму отдаётся дельта — «вот этот метод теперь выглядит так».

В общих чертах идея такая:

  1. На UI-потоке держим уже загруженную main-сборку пользовательского приложения — её получаем один раз через Assembly.Load(byte[]) при первой полной сборке.

  2. В web worker’е держим CSharpCompilation и EmitBaseline.

  3. Пользователь что-то поменял — UI шлёт в воркер новый текст файла.

  4. Воркер делает compilation.ReplaceSyntaxTree(old, new) и вызывает EmitDifference. Получает три блоба: MetadataDelta, ILDelta, PdbDelta.

  5. Воркер шлёт их обратно. UI-поток вызывает:

    MetadataUpdater.ApplyUpdate(loadedMain, metaDelta, ilDelta, pdbDelta);
  6. Через рефлексию вытаскиваем фабрику превью и инстанцируем заново.

Реализовали. Изменили текст в TextBlock в xaml файле. Получили компиляцию 4579 мс. Не совсем то, чего мы ожидали.

Где время

Накидали Stopwatch’ы по фазам:

[Incremental] phases: diff(85ms) bind=296ms collect=3632ms emitDiff=370ms

3.6 секунды на collect. Внутри — вот это:

private static Dictionary<string, INamedTypeSymbol> CollectTopLevelTypesInFiles(
    CSharpCompilation compilation, HashSet<string> filePaths)
{
    Walk(compilation.GlobalNamespace);
}

compilation.GlobalNamespace — это объединённый namespace этой сборки и всех её metadata references. То есть Roslyn честно ходит по System.*, Microsoft.*, OpenSilver и десятку других сборок, для каждого типа поднимая из метаданных ленивые данные. На больших проектах это десятки тысяч типов. Поменяли на:

Walk(compilation.Assembly.GlobalNamespace);

Стало 30 мс.

Дальше выяснилось, что nextCompilation.GetDiagnostics() на следующей итерации проводит семантический анализ всех тел методов во всех файлах проекта (4–5 секунд). Это нам не нужно — мы знаем, какой файл поменялся, и ошибки нас интересуют только в нём. Перешли на:

foreach (var changedTree in changedTrees.Values)
{
    diagnostics.AddRange(changedTree.GetDiagnostics(ct));
    var semanticModel = nextCompilation.GetSemanticModel(changedTree);
    diagnostics.AddRange(semanticModel.GetDiagnostics(cancellationToken: ct));
}

200 мс. Итог:

[Incremental] Delta produced for 'App1' in 821ms (gen=1, changed files=1);
outer: prep=1ms xamlTranspile=88ms engine=731ms advance=0ms

С 4.5 секунд до 0.8. Считаем, что задача решена.

Следующая проблема

Тыкаешь мышкой в дизайнере — NullReferenceException:

at GridLinesControl.DrawLines() in GridLinesControl.cs:line 65
at GridLinesControl.OnGridLayoutUpdated(Object sender, EventArgs e)
at System.Windows.LayoutManager.FireLayoutUpdateEvent()
at System.Windows.LayoutManager.UpdateLayout()

Контекст: GridLinesControl рисует линии сетки поверх выбранного Grid. Живёт он в статическом поле — один экземпляр на «выделенный Grid», второй на «родительский». Подписывается на grid.LayoutUpdated, чтобы перерисовываться при изменении размеров. В DrawLines обращается к grid.ActualWidth. grid равен null. Падает.

Самое подозрительное — это порядок. По стеку видно: LayoutManager.FireLayoutUpdateEvent → наш handler. То есть глобальный layout-проход перебирает подписки и вызывает наш handler. Логично, что если бы мы успели отписаться, его бы не вызвали. А его вызвали. Значит, наша отписка не сработала.

_grid.LayoutUpdated -= OnGridLayoutUpdated — что тут может не сработать?

Лезем в исходники

UIElement.LayoutUpdated в OpenSilver — не обычный CLR-event, у него руками написаны add/remove. Сама подписка лежит не на элементе, а в глобальном LayoutManager.Current.LayoutEvents — связном списке, где каждый узел это WeakReference на handler-делегат. Каждый layout-проход копирует список, перебирает его и вызывает те делегаты, которые ещё живы; мёртвые выкидывает.

Чтобы при -= быстро найти нужный узел списка по делегату, OpenSilver держит на каждом UIElement mapping EventHandler → ListItem в UncommonField<T> (внутренний механизм «редких» полей, чтобы не раздувать каждый элемент дерева). Когда handler на элементе один — там лежит прямо ListItem. Когда появляется второй — код переключается на Dictionary<EventHandler, ListItem>. Кстати, всё это почти дословно перенесено из WPF: там ровно та же схема, только вместо Dictionary стоит Hashtable.

И вот тут начинается интересное. На одном Grid у нас висят подписки от двух разных контролов дизайнера, и оба слушают LayoutUpdated. Значит, включается Dictionary-путь. А Dictionary ищет ключ через EqualityComparer<EventHandler>.Default — то есть через handler.GetHashCode() и handler.Equals(...).

То есть -= под капотом делает dict.Remove(handler), а тот — handler.GetHashCode().

Будем дебажить

Залогировали в Attach/Detach четыре вещи:

  • ссылку на грид (через RuntimeHelpers.GetHashCode),

  • handler.GetHashCode(),

  • handler.Target через тот же RuntimeHelpers,

  • размер LayoutManager.Current.LayoutEvents через рефлексию.

И вот что получилось до и после инкрементальной компиляции:

[GridLines] Attach BEGIN: newGrid=Grid@2B17077 handler.hash=122B4D43 handler.target=2385110F
                          layoutEvents=11
[GridLines] Attach END:   layoutEvents=12
...
Applying delta (gen advance)
...
[GridLines] Detach BEGIN: _grid=Grid@2B17077   handler.hash=3B261975 handler.target=2385110F
                          layoutEvents=13
[GridLines] Detach END:   layoutEvents=13   ← !

handler.target — тот же (наш статический экземпляр). Метод — тот же (OnGridLayoutUpdated на том же типе). А хеш другой: 122B4D43 при подписке, 3B261975 при отписке.

Тут важно не обмануться: это не «у делегата поменялся хеш», свой хеш каждый экземпляр держит намертво. Голое имя метода в += OnGridLayoutUpdated — это группа методов, и компилятор в каждом таком месте создаёт отдельный делегат. В -= тоже. То есть в словарь мы кладём один объект, а искать приходим с другим — всегда, и задолго до всякого hot reload. Работает это только потому, что Equals и GetHashCode у делегатов значимые: считаются от пары «таргет плюс метод», а не от идентичности объекта. На этот контракт опирается любая отписка от события в .NET.

Dictionary.Remove ищет по хешу 3B261975, а в словаре лежит ключ с хешем 122B4D43. Не находит. Молча возвращает false — и возвращаемое значение никто не проверяет. Узел в LayoutEvents остаётся, счётчик не уменьшается, подписка живёт.

А раз подписка жива, следующий layout-тик её вызовет. Она ссылается на OnGridLayoutUpdated нашего статического экземпляра, тот читает this._grid, а он уже null.

Кто виноват

Полез в исходники Mono смотреть, как вообще считается Delegate.GetHashCode(). Там ровно две строки:

public override int GetHashCode()
{
    MethodInfo? m = Method;

    return (m != null ? m.GetHashCode() : GetType().GetHashCode()) ^ RuntimeHelpers.GetHashCode(_target);
}

Половина хеша — идентичность объекта-таргета, она в нашем случае не менялась (в логах handler.target один и тот же). Значит, меняется первая половина: Method.GetHashCode(). А Method — это MethodInfo, который рантайм отдаёт для метода, и для не-generic методов MethodInfo.GetHashCode() — это дефолтный хеш по ссылке, то есть идентичность самого reflection-объекта.

Другими словами, «одинаковость» делегата на Mono держится не на чём-то стабильном вроде metadata-токена, а на том, вернёт ли рантайм тот же самый экземпляр MethodInfo. Более того, Delegate.Equals сравнивает методы через d.Method == Method, а MethodInfo.operator == для рантаймовых методов — это тоже сравнение по ссылке. То есть после смены MethodInfo ломается не только хеш, но и равенство делегатов.

И вот это уже прямое расхождение с документацией, а не деталь реализации, на которую мы неудачно понадеялись. Delegate.Equals описан как «share the same targets, methods, and invocation list», причём в примечаниях отдельно проговорён именно наш случай: если сравниваются два instance-метода и это один и тот же метод на одном и том же объекте, они обязаны считаться равными. На Mono после ApplyUpdate получается false.

Что именно ApplyUpdate делает с этим кэшем внутри Mono, я до конца не докопал — сишная часть hot reload лезет в довольно много служебных структур. Зато собрал минимальное воспроизведение на пару сотен строк, и оно говорит достаточно.

Компилируем на ходу крошечный класс с методом Handler, загружаем, берём делегат, патчим тело метода через EmitDifference и ApplyUpdate, строим второй делегат на ту же пару «таргет плюс MethodInfo». После патча Delegate.Equals для этих двоих возвращает false — при том, что таргет буквально один и тот же объект, а MetadataToken у методов совпадает. То есть дело действительно не в токене. Тот же скрипт на CoreCLR проходит чисто: хеши сходятся, Equals возвращает true, Remove из словаря срабатывает. Так что это не «особенность .NET», а именно Mono.

А самое неприятное вылезло, когда я полез проверять область действия. Патчим одну подопытную сборку, а следим за делегатами на методы из двух других: одна загружена динамически, вторая вкомпилирована в само приложение. Ни ту, ни другую ApplyUpdate не трогает. Ломаются все три. Одна дельта по одному файлу обнуляет идентичность делегатов по всему процессу.

Что и снимает исходную загадку: OnGridLayoutUpdated мы вообще не меняли, он лежит в нашей DLL, которая никак не патчится — патчится только пользовательский App1.dll. Для Mono это, как выяснилось, не имеет ни малейшего значения.

Что делать

Понимание — половина дела. Раз -= ломается после ApplyUpdate, попробуем звать -= до ApplyUpdate — внутри одного «поколения» модуля хеши делегатов стабильны, это мы проверили.

Накинули событие на компилятор:

public event Action BeforeApplyUpdate;
...
if (delta.MetadataDelta != null && delta.MetadataDelta.Length > 0)
{
    BeforeApplyUpdate?.Invoke();
    MetadataUpdater.ApplyUpdate(loadedMain, metaDelta, ilDelta, pdbDelta);
}

Дизайнер подписывается и в обработчике хирургически отписывает все свои delegate-based подписки на текущем дереве превью. Важно, что это не «закрыть всё и перерисовать»: попапы не прячутся, выделение не сбрасывается, визуально ничего не дёргается. Только -=:

public void SuspendLayoutSubscriptionForHotReload()
{
    if (_grid == null) return;
    _grid.LayoutUpdated -= OnGridLayoutUpdated;
    _grid = null;
}

Зомби-подписки ушли. Уверенности, что мы нашли все места, где хеш делегата может подвести, не было никакой — но работало.

А теперь давайте внесём изменения в пользовательский код и поменяем, например, сигнатуру метода.

И вот тут нас ждало главное разочарование. На Mono под WebAssembly MetadataUpdater.ApplyUpdate умеет ровно одно: обновлять тела существующих методов. Новое поле, новый метод, новое свойство, новый тип — и всё. Причём падает это не исключением, которое можно поймать и откатиться на полную сборку, а g_assert где-то в глубине hot_reload_apply_changes, который просто убивает WASM-рантайм.

Пришлось поставить в наш сборщик правок жёсткое правило: любое структурное изменение — это rude edit, откатываемся на полную пересборку. То есть на любую правку чуть значительнее, чем «поменять строку внутри метода», мы возвращались к тем же самым 84 секундам. Победить это нам не удалось.

Поэтому возникла новая идея! А давайте сделаем свою собственную инкрементальную компиляцию — такую, где единицей пересборки будет не метод, а отдельная DLL.

Идея 2: разрезать XAML на «оболочку» и «тело»

В транспилированном C# для одного XAML сидят две принципиально разные части:

  1. Оболочка (shell) — небольшая. Всё, что должно остаться видимым извне: partial-класс пользователя с полями под x:Name, метод InitializeComponent, реализация IComponentConnector.Connect и заголовок фабричного класса.

  2. Тело (body) — толстое. Сам метод LoadComponentImpl плюс десятки helper-методов вида New_xxx, которые ставят свойства, привязки, ресурсы и т.п. Именно тут лежит почти весь объём генерируемого кода.

Если оболочку оставить в общей пользовательской сборке, а тело каждого XAML вынести в отдельную DLL, то:

  • Изменили XAML-разметку в дизайнере — пересобираем только её body DLL, а общую сборку трогаем только если поменялась оболочка.

  • Изменили одну строчку C#, которая ни на одно XAML не влияет — пересобираем только общую сборку, а body DLL’и переиспользуем как есть.

  • Изменили C#-тип, на который ссылается ровно одна XAML — пересобираем общую сборку и одну body DLL.

Звучит просто. На практике пришлось решить несколько неочевидных задач.

Как разрезать xaml компоненты

Идея в том, чтобы не генерировать один большой C#-класс на каждый пользовательский xaml, а разложить его члены по двум корзинам:

  • В оболочку — только то, что вызывают извне. Плюс заглушка для LoadComponentImpl, которая теперь содержит ровно одну строчку:

    XamlBodyRegistry.Invoke("ǀǀApp1ǀǀComponentǀǀMainwindowǀǀXamlǀǀFactory", component);

    Ключ реестра — это имя фабричного класса, которое OpenSilver собрал из component-URI вида /App1;component/MainWindow.xaml. Выглядит немного непривычно, но зато гарантированно уникально в рамках проекта, стабильно между сборками и уже посчитано за нас. Разделитель, кстати, не «пайп», а U+01C0 — он проходит как буква, поэтому годится в идентификатор.

  • В тело — все приватные helper-методы плюс оригинальное содержимое LoadComponentImpl, которое мы кладём в новый internal static class XamlBody_<hash> в отдельном namespace. Хеш здесь — от того же ключа, так что имя класса тоже стабильно.

В body DLL дописывается module initializer, который сам себя регистрирует в общем реестре:

[ModuleInitializer]
internal static void Register()
{
    XamlBodyRegistry.Register("ǀǀApp1ǀǀComponentǀǀMainwindowǀǀXamlǀǀFactory", Load);
}

XamlBodyRegistry — простенький Dictionary<string, Action<object>> в рантайм-сборке, на которую ссылается любое пользовательское приложение. Регистрация по существующему ключу перетирает предыдущую — именно на этом и держится hot-swap: загрузили новые байты body DLL, и заглушка в неизменной пользовательской сборке уже вызывает новый код.

Целиком картина выглядит так:

Схема работы
Схема работы

Пересобираем только то, что реально изменилось

После расщепления нам нужен механизм решать, какие body DLL пересобирать на warm-билде. Если изменился XAML — это очевидно: пересобираем его body. А если изменился .cs?

Сделали так. После каждого успешного emit’а body DLL проходим её Mono.Cecil’ом и выписываем все её TypeRef’ы, которые указывают на пользовательские сборки. Это «зависимости от пользовательских типов» для конкретного body.

С другой стороны, после emit’а пользовательской сборки мы тем же Cecil’ом считаем сигнатурный хеш каждого public/internal типа: имя, доступность, базовый тип, интерфейсы и сигнатуры всех членов — но не тела методов. Сравниваем с предыдущим снапшотом и получаем список «изменившихся типов». Ключевое здесь именно то, что тел в хеше нет: правка внутри метода не меняет ни один хеш и не инвалидирует ни один body.

На warm-билде:

для каждого XAML body:
    если XAML изменился => пересобрать
    если deps ∩ changedTypes != пустое => пересобрать
    иначе => пропустить, отдать кэшированные байты

Получается, что добавление публичного свойства в один class Person не вызывает пересборку всех body DLL подряд — пересобираются только те, которые этот класс действительно знают. Причём это работает и через границы проектов: правка в референсной библиотеке инвалидирует ровно те body в главном проекте, которые ссылаются на изменившийся тип.

Отдельный момент: пересобираем один body, а отдавать надо все

Это контринтуитивно, и здесь мы споткнулись.

Допустим, изменились только .cs. Анализ зависимостей решает, что body для MainWindow.xaml пересобирать не нужно — и правильно решает, его байты действительно те же. Но IDE всё равно перезагружает пользовательскую сборку, а Assembly.Load(byte[]) создаёт новый объект Assembly, даже если байты идентичны. И старая, уже загруженная body DLL держит TypeRef’ы, привязанные к прошлому объекту сборки. В момент, когда body в своём Connect отдаёт созданные элементы обратно в код, который ждёт типы из новой сборки, получаем InvalidCastException в стиле «DiagramViewer не является DiagramViewer».

Решение: пересобирать — только грязные body, а вот отдавать в IDE и перезагружать — все, даже те, чьи байты не изменились. Загрузка сборки из массива байт в WASM дешёвая, зато привязка типов после этого корректная.

Так что итоговое правило получилось такое: Roslyn вызываем для минимума, а Assembly.Load — для всего.

Что получилось

Раньше все сценарии были одинаковыми, потому что сценариев не было: любая правка означала полную пересборку проекта. Те самые 84 секунды.

Стало вот так. Тот же FamilyShow, замеры на не самом свежем ноутбуке — ему лет семь, так что абсолютные числа у вас будут другие, интереснее пропорции:

Что поменяли

Стало

Что пересобирается

Разметку в XAML — текст, цвет, размер

от меньше секунды до 5 с

одна body DLL, пользовательскую сборку не трогаем вообще

Тело метода в .cs

~5 с

пользовательская сборка, ноль body DLL

Публичный член типа, который используется в одном XAML

~5-10 с

пользовательская сборка плюс одна body DLL

Первая сборка при открытии проекта

~30 с

всё

И тут надо оговориться про каждую строчку таблицы, иначе получится хвастовство.

Правка XAML — разброс большой. На большинстве страниц это действительно доли секунды, на некоторых — две, а на самой крупной вышло пять. Причина в том, что body DLL пересобирается целиком, а объём генерируемого кода растёт вместе с разметкой. Пять секунд в FamilyShow даёт Details.xaml: 2248 строк разметки, из которых транспайлер делает 16 тысяч строк C#. У обычной страницы того же проекта выходит полторы-три тысячи — то есть важно не то, что именно вы поменяли, а насколько велика страница, в которой вы это поменяли.

Первая сборка ускорилась с 84 до 30 секунд, но записывать это на счёт инкрементальной компиляции нечестно. Холодной сборке нечего переиспользовать по определению, и всё, что она умеет — честно собрать всё. Тридцать секунд — это сумма многих других оптимизаций, которые накопились по дороге и на отдельную статью не тянут.

Правка .cs стоит 5 секунд против доли секунды на XAML не случайно. Ноль пересобранных body DLL не отменяет того, что саму пользовательскую сборку надо собрать заново и загрузить. То есть здесь мы сэкономили все body DLL, но не главную сборку — и вот это следующая большая цель.

Ради верхней же строчки таблицы всё и делалось. Правка разметки — самое частое, что вообще происходит за сессию работы с дизайнером. Раньше она стоила 84 секунды, теперь в большинстве случаев её просто не замечаешь.

Что мы понимаем про xaml.io

Если сейчас вы зайдёте на xaml.io и попробуете загрузить туда что-то реально большое, то что-нибудь где-нибудь может не сработать. Мы это знаем. У нас есть список, мы аккуратно его разгребаем — но прямо сейчас приоритет у нас на фичах, без которых разработка невозможна в принципе. Производительность компиляции — одна из таких. Стабилизация будет, просто чуть позже.

В то же время команда у нас маленькая, и движение видно: за последний год прибавились поддержка WPF, NuGet, авто-фиксы ошибок, публикация приложений (кстати, под разные платформы и целиком в браузере, без сервера), Intellisense, а теперь вот нормальный инкрементальный компилятор. Хочется верить, что направление в целом правильное.

Вместо заключения

Если у вас есть Silverlight/WPF-приложение, которое хочется потрогать в браузере, или просто интересно посмотреть на онлайн-IDE для .NET — приходите на xaml.io. Будем благодарны за фидбек, баги тоже принимаются :)

А ещё можно поиграть в звёздные войны, в сапёра или посмотреть другие примеры на xaml.io.

Комментарии (0)