Добавить в корзинуПозвонить
Найти в Дзене
IT4BIM

Dependency Injection в Revit: документ-ориентированный подход

Разработка плагинов для Revit часто начинается с энтузиазма и заканчивается болью от неуправляемого кода. Чем больше инструментов мы добавляем — тем сложнее становится контролировать, когда и как создаются окна, как они взаимодействуют с моделью данных, и что вообще происходит при открытии нескольких документов. Самая частая проблема — пользователь может по ошибке открыть одно и то же окно несколько раз подряд. В результате появляется куча дубликатов, которые не синхронизированы и не понимают, к какому документу они относятся. Ещё хуже, когда разработчик вручную прокидывает Document и UIApplication в каждое окно и ViewModel, что быстро превращает код в лапшу. Но выход есть — это внедрение встроенной поддержки Dependency Injection (DI) с привязкой ко встретившемуся в Revit документу. В этой статье я расскажу, как реализовать поддержку жизненного цикла сервисов, привязанного к Document, и как управлять окнами с помощью Transient-подхода и кэша на WeakReference, чтобы не плодить окна по д
Оглавление

🧩 Введение

Разработка плагинов для Revit часто начинается с энтузиазма и заканчивается болью от неуправляемого кода. Чем больше инструментов мы добавляем — тем сложнее становится контролировать, когда и как создаются окна, как они взаимодействуют с моделью данных, и что вообще происходит при открытии нескольких документов.

Самая частая проблема — пользователь может по ошибке открыть одно и то же окно несколько раз подряд. В результате появляется куча дубликатов, которые не синхронизированы и не понимают, к какому документу они относятся. Ещё хуже, когда разработчик вручную прокидывает Document и UIApplication в каждое окно и ViewModel, что быстро превращает код в лапшу.

Но выход есть — это внедрение встроенной поддержки Dependency Injection (DI) с привязкой ко встретившемуся в Revit документу. В этой статье я расскажу, как реализовать поддержку жизненного цикла сервисов, привязанного к Document, и как управлять окнами с помощью Transient-подхода и кэша на WeakReference, чтобы не плодить окна по десять раз.

Мы будем использовать Scoped-сервисы для ViewModel-ов и Transient-сервисы для окон. Такой подход позволяет:

  • Привязать ViewModel к конкретному Document.
  • Создать окно только один раз и повторно использовать его.
  • Избавиться от синглтонов и статики в коде.
  • Безопасно управлять окнами при закрытии документов.

Если ты хочешь навести порядок в архитектуре своего плагина и забыть о проблемах с окнами — эта статья для тебя.

🧠 Обзор подхода и устройства RevitDocumentScopeLifeTimeService

В основе архитектуры лежит статический класс RevitDocumentScopeLifeTimeService, который помогает реализовать полноценную поддержку жизненных циклов сервисов в Revit с привязкой к конкретному документу. Его задача — решить три основные проблемы:

  1. Поддержка Scoped-зависимостей в контексте Revit-документов.
  2. Контроль над созданием окон (Transient), чтобы не плодить дубликаты.
  3. Хранение UIApplication и текущего Document без статики в каждом окне.

Этот класс можно зарегистрировать один раз в методе OnStartup вашего IExternalApplication, и дальше использовать в любом месте кода через host.GetService<T>(doc).

💡 Рекомендуем вынести RevitDocumentScopeLifeTimeService в корпоративную библиотеку или NuGet-пакет — например, Bim.CompanyName.DI — и переиспользовать в разных проектах. Подход легко адаптируется под вашу архитектуру: вы можете доработать кэширование, добавлять хуки на DocumentOpened, логирование и пр.

Как это работает

  • AddDocumentScopeLifeTimeSupport(...) добавляет поддержку жизненного цикла:
    Сохраняет services из IHostBuilder;
    Подписывается на события DocumentClosing и ViewActivated;
    Регистрирует doc и UIApplication в DI-контейнере как Scoped/Singleton.
-2
  • Когда вы вызываете host.GetService<T>(doc):
    Если это Singleton — возвращается обычный синглтон.
    Если Scoped — создается или берётся контейнер, привязанный к документу, и оттуда извлекается сервис.
    Если Transient и это окно (Window) — возвращается окно из WeakReference, если оно ещё не закрыто. Иначе создаётся новое окно и кэшируется.
-3

Почему это удобно

  • Не нужно вручную управлять окнами или следить, закрыты они или нет.
  • Нет статики в ViewModel, всё создаётся через DI.
  • Можно использовать полноценную архитектуру с Scoped-сервисами, даже в Revit.
  • Окна перестают плодиться при многократных вызовах.
  • Легко тестировать бизнес-логику, потому что она изолирована в Scoped-сервисах.

🔍 Как работает GetScopedService<T>

Метод GetScopedService<T>(Document doc) — это ключевой механизм в реализации поддержки Scoped-зависимостей на уровне Revit-документа. Он создаёт (или переиспользует) отдельный DI-контейнер для каждого документа Revit, что позволяет изолировать зависимости между документами.

-4

📌 Что делает метод шаг за шагом:

  1. Проверяет, есть ли уже контейнер для документа:
    Если контейнер для переданного Document ещё не создавался, создаётся новый.
  2. Создаёт копию сервисов (IServiceCollection):
    Мы используем те же сервисы, которые были зарегистрированы при запуске плагина, но создаём новый ServiceProvider, чтобы связать его именно с этим документом.
  3. Создаёт ServiceProvider и сохраняет в кэш:
    Теперь при следующем вызове для этого doc не будет лишнего пересоздания — просто вернётся уже готовый провайдер.
  4. Возвращает нужный сервис:

🧠 Зачем это нужно?

В обычных 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 для документа означает, что сервисы не могут хранить состояние, привязанное к конкретному документу, если только сами не управляют таким состоянием.

Таким образом, метод предоставляет баланс между эффективным управлением окнами и простотой получения других сервисов через глобальный контейнер зависимостей.

-5

Пример использования в вашем проекте


регистрируем наши
сервисы и подписываемся на событие изменения вида.

-6

В данном коде мы зарегистрировали зависимости в контейнере сервисов — окно CurrentDocumentContextWindow как transient и DocumentInfo как scoped (привязанный к документу). Также добавили триггер — обработчик события ViewActivated, который срабатывает при смене активного документа (вида) и обновляет контекст данных окна, чтобы всегда показывать актуальную информацию по текущему документу.

Использование DI в команде

-7

Теперь наша реализация IExternalCommand стала максимально простой: в методе Execute мы просто вызываем сервис CurrentDocumentContextWindow из общего хоста и отображаем окно с актуальным контекстом документа. Вся логика и управление состоянием окна вынесены в DI-контейнер и обработчики событий, что значительно упрощает код команды.