🧩 Введение
Разработка плагинов для Revit часто начинается с энтузиазма и заканчивается болью от неуправляемого кода. Чем больше инструментов мы добавляем — тем сложнее становится контролировать, когда и как создаются окна, как они взаимодействуют с моделью данных, и что вообще происходит при открытии нескольких документов.
Самая частая проблема — пользователь может по ошибке открыть одно и то же окно несколько раз подряд. В результате появляется куча дубликатов, которые не синхронизированы и не понимают, к какому документу они относятся. Ещё хуже, когда разработчик вручную прокидывает Document и UIApplication в каждое окно и ViewModel, что быстро превращает код в лапшу.
Но выход есть — это внедрение встроенной поддержки Dependency Injection (DI) с привязкой ко встретившемуся в Revit документу. В этой статье я расскажу, как реализовать поддержку жизненного цикла сервисов, привязанного к Document, и как управлять окнами с помощью Transient-подхода и кэша на WeakReference, чтобы не плодить окна по десять раз.
Мы будем использовать Scoped-сервисы для ViewModel-ов и Transient-сервисы для окон. Такой подход позволяет:
- Привязать ViewModel к конкретному Document.
- Создать окно только один раз и повторно использовать его.
- Избавиться от синглтонов и статики в коде.
- Безопасно управлять окнами при закрытии документов.
Если ты хочешь навести порядок в архитектуре своего плагина и забыть о проблемах с окнами — эта статья для тебя.
🧠 Обзор подхода и устройства RevitDocumentScopeLifeTimeService
В основе архитектуры лежит статический класс RevitDocumentScopeLifeTimeService, который помогает реализовать полноценную поддержку жизненных циклов сервисов в Revit с привязкой к конкретному документу. Его задача — решить три основные проблемы:
- Поддержка Scoped-зависимостей в контексте Revit-документов.
- Контроль над созданием окон (Transient), чтобы не плодить дубликаты.
- Хранение UIApplication и текущего Document без статики в каждом окне.
Этот класс можно зарегистрировать один раз в методе OnStartup вашего IExternalApplication, и дальше использовать в любом месте кода через host.GetService<T>(doc).
💡 Рекомендуем вынести RevitDocumentScopeLifeTimeService в корпоративную библиотеку или NuGet-пакет — например, Bim.CompanyName.DI — и переиспользовать в разных проектах. Подход легко адаптируется под вашу архитектуру: вы можете доработать кэширование, добавлять хуки на DocumentOpened, логирование и пр.
Как это работает
- AddDocumentScopeLifeTimeSupport(...) добавляет поддержку жизненного цикла:
Сохраняет services из IHostBuilder;
Подписывается на события DocumentClosing и ViewActivated;
Регистрирует doc и UIApplication в DI-контейнере как Scoped/Singleton.
- Когда вы вызываете host.GetService<T>(doc):
Если это Singleton — возвращается обычный синглтон.
Если Scoped — создается или берётся контейнер, привязанный к документу, и оттуда извлекается сервис.
Если Transient и это окно (Window) — возвращается окно из WeakReference, если оно ещё не закрыто. Иначе создаётся новое окно и кэшируется.
Почему это удобно
- Не нужно вручную управлять окнами или следить, закрыты они или нет.
- Нет статики в ViewModel, всё создаётся через DI.
- Можно использовать полноценную архитектуру с Scoped-сервисами, даже в Revit.
- Окна перестают плодиться при многократных вызовах.
- Легко тестировать бизнес-логику, потому что она изолирована в Scoped-сервисах.
🔍 Как работает GetScopedService<T>
Метод GetScopedService<T>(Document doc) — это ключевой механизм в реализации поддержки Scoped-зависимостей на уровне Revit-документа. Он создаёт (или переиспользует) отдельный DI-контейнер для каждого документа Revit, что позволяет изолировать зависимости между документами.
📌 Что делает метод шаг за шагом:
- Проверяет, есть ли уже контейнер для документа:
Если контейнер для переданного Document ещё не создавался, создаётся новый. - Создаёт копию сервисов (IServiceCollection):
Мы используем те же сервисы, которые были зарегистрированы при запуске плагина, но создаём новый ServiceProvider, чтобы связать его именно с этим документом. - Создаёт ServiceProvider и сохраняет в кэш:
Теперь при следующем вызове для этого doc не будет лишнего пересоздания — просто вернётся уже готовый провайдер. - Возвращает нужный сервис:
🧠 Зачем это нужно?
В обычных WPF или ASP.NET приложениях Scoped-зависимости создаются на каждый запрос или окно. В Revit такого механизма нет — там нет встроенного понятия "запроса", но есть документы. Поэтому мы используем Document как границу области жизни (scope).
Это позволяет, например:
- Хранить отдельные ViewModel, State, Context, UnitOfWork и другие сервисы на каждый открытый документ.
- Изолировать данные и логику для каждого проекта в Revit.
- Не бояться "протекания" зависимостей между документами.
🧪 Описание метода GetOrCreateTransient<T>(IHost host)
Этот метод предназначен для получения экземпляров сервисов с учётом их типа, с особой логикой для окон (Window) и универсальным подходом для всех остальных сервисов.
Как работает метод:
- Метод принимает IHost host — глобальный контейнер зависимостей, который управляет жизненным циклом сервисов в приложении.
- Тип запрашиваемого сервиса определяется с помощью typeof(T).
- Если тип T является производным от Window (то есть это окно пользовательского интерфейса), применяется специальная логика:
Сначала проверяется кеш слабых ссылок TransientWindowsCache — в нём хранятся окна, которые были созданы ранее, но для которых не поддерживается жёсткая ссылка, чтобы не мешать сборщику мусора.
Если окно найдено в кеше и ещё загружено (IsLoaded) и видно (IsVisible), то оно возвращается повторно, чтобы избежать создания нового экземпляра одного и того же окна.
Если окно закрыто или неактивно, ссылка удаляется из кеша, и создаётся новое окно через вызов host.Services.GetRequiredService<T>(). - Для всех остальных сервисов, не являющихся окнами, метод просто запрашивает сервис у глобального контейнера host.Services и возвращает его:
Если сервис зарегистрирован с жизненным циклом Transient, каждый вызов вернёт новый экземпляр.
Важные особенности и последствия:
- Для окон реализован кэш с использованием слабых ссылок, чтобы повторно использовать активные окна и избегать лишнего создания UI-элементов, при этом позволяя сборщику мусора очищать закрытые окна.
- Для всех прочих сервисов не предусмотрена привязка к документу или другому скоупу — используется глобальный контейнер хоста.
- Transient-сервисы всегда создаются заново при каждом вызове, что соответствует их назначению.
- Отсутствие отдельного scope для документа означает, что сервисы не могут хранить состояние, привязанное к конкретному документу, если только сами не управляют таким состоянием.
Таким образом, метод предоставляет баланс между эффективным управлением окнами и простотой получения других сервисов через глобальный контейнер зависимостей.
Пример использования в вашем проекте
регистрируем наши сервисы и подписываемся на событие изменения вида.
В данном коде мы зарегистрировали зависимости в контейнере сервисов — окно CurrentDocumentContextWindow как transient и DocumentInfo как scoped (привязанный к документу). Также добавили триггер — обработчик события ViewActivated, который срабатывает при смене активного документа (вида) и обновляет контекст данных окна, чтобы всегда показывать актуальную информацию по текущему документу.
Использование DI в команде
Теперь наша реализация IExternalCommand стала максимально простой: в методе Execute мы просто вызываем сервис CurrentDocumentContextWindow из общего хоста и отображаем окно с актуальным контекстом документа. Вся логика и управление состоянием окна вынесены в DI-контейнер и обработчики событий, что значительно упрощает код команды.