В прошлой статье я рассказывал, как мы делаем онлайн IDE для .NET — дизайн, компиляция и запуск приложений происходят прямо в браузере, без сервера. Проект живёт по адресу xaml.io. С тех пор мы много чего добавили, и в какой-то момент у нас стало получаться загружать в IDE довольно крупные .NET проекты — десятки XAML, сотни C# файлов, несколько референсных проектов и пакетов NuGet.
Одна из главных возможностей — это визуальный UI-дизайнер пользовательских приложений. И у него есть особенность: чтобы дизайнер вообще что-то показывал, приложение должно быть скомпилировано, причём в актуальном состоянии. Не «когда-нибудь», а после каждой правки.
Открыли как-то в IDE один реальный проект (FamilyShow — классическое демонстрационное WPF-приложение), поправили одну строку в .cs и 84 секунды наблюдали за анимацией прогресса. С таким UX никакой интерактивный дизайн невозможен.
Сейчас та же правка занимает 5 секунд, а правка XAML-разметки на типичной странице — меньше секунды. В этой статье — как мы к этому пришли, и какие сюрпризы нам приготовили по дороге Roslyn, OpenSilver, WASM и наш собственный код.

Что вообще компилируется
Кратко напомню, как устроена сборка приложения в нашей 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: вместо новой сборки рантайму отдаётся дельта — «вот этот метод теперь выглядит так».
В общих чертах идея такая:
На UI-потоке держим уже загруженную main-сборку пользовательского приложения — её получаем один раз через Assembly.Load(byte[]) при первой полной сборке.
В web worker’е держим
CSharpCompilationиEmitBaseline.Пользователь что-то поменял — UI шлёт в воркер новый текст файла.
Воркер делает
compilation.ReplaceSyntaxTree(old, new)и вызываетEmitDifference. Получает три блоба:MetadataDelta,ILDelta,PdbDelta.-
Воркер шлёт их обратно. UI-поток вызывает:
MetadataUpdater.ApplyUpdate(loadedMain, metaDelta, ilDelta, pdbDelta); Через рефлексию вытаскиваем фабрику превью и инстанцируем заново.
Реализовали. Изменили текст в 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 сидят две принципиально разные части:
Оболочка (shell) — небольшая. Всё, что должно остаться видимым извне: partial-класс пользователя с полями под x:Name, метод InitializeComponent, реализация
IComponentConnector.Connectи заголовок фабричного класса.Тело (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, пользовательскую сборку не трогаем вообще |
Тело метода в |
~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.