Введение
Год назад у меня была простая мечта: сделать так, чтобы мой плагин для Revit выглядел и ощущался как современное приложение. Я хотел красивые окна, работающие шкалы прогресса и возможность отменить операцию, если что-то пошло не так.
Но я тогда столкнулся с непостижимой для меня проблемой: я нажимаю кнопку — и интерфейс замирает. Окно «застывает», курсор начинает крутиться, и пользователь сидит в неведении: «Оно работает или зависло?».
Тогда я еще плохо разбирался в тонкостях C#, WPF и программирования в принципе, для меня это казалось таким сложным, и была прям мысль «вот бы кто-то умный рассказал, как это исправить».
По итогу, мне пришлось разбираться в этом всем самому. И в этой статье я поделюсь с вами результатом: как же все-таки сделать свои окошки «отзывчивыми». Поговорим о том, причем тут потоки, как с ними работать в контексте Revit и как реализовать всё это по принципу TAP (асинхронный паттерн задач).
Revit API, UI и главный поток
Как работает главный поток
Чтобы понять, почему всё зависало, нужно заглянуть «под капот». И там мы обнаружим главного виновника — Главный Поток Revit (Revit Main Thread).
Представьте, что поток — это однополосная дорога. По ней могут ехать машины.
- Revit API очень требовательный водитель. Он разрешает себе ехать по этой дороге только одному. Никаких обгонов, никаких встречных полос. Все вызовы к документу (создание стен, получение параметров) должны происходить строго в этом потоке.
- WPF (наш интерфейс) по умолчанию тоже ездит по этой дороге. То есть, чтобы отрисовать кнопку, обновить текст или показать прогресс-бар, ему нужно проехать по ней.
Где возникает затор?
Когда вы нажимаете кнопку «запуск» в плагине, вы запускаете код. Если этот код обращается к Revit API (например, собирает элементы), то Revit-водитель выезжает на эту дорогу и едет. И тут интерфейс пытается перерисовать кнопку: он выезжает на эту же дорогу, и начинает ехать за Revit-водителем. Но обогнать он его не может — дорога однополосная, и Revit-водитель не хочет сместиться, чтобы пропустить.
В результате:
- Окно не реагирует на клики.
- Прогресс-бар не обновляется (ему просто не дают «проехать» и сменить кадр).
- Windows помечает окно как «Не отвечает».
Создаем «дополнительную дорогу»
Когда я осознал, что интерфейс и Revit дерутся за один поток, решение пришло само: нужно дать интерфейсу свою «полосу движения».
В мире WPF это означает создание отдельного STA-потока.
STA (Single Threaded Apartment) — это режим, в котором потоки работают с визуальными элементами WPF. Если коротко: без него окна просто не создадутся.
Я написал небольшой сервис-хост, который берет на себя всю рутину: создает поток, запускает диспетчер сообщений и корректно закрывает окна. Вот как это примерно выглядит:
Разберем ключевые моменты:
1. ApartmentState.STA — обязательно. Если забыть эту строку, при создании первого окна вы получите исключение. WPF требует, чтобы все визуальные элементы создавались и использовались только в потоке, который пометил себя как STA.
2. Dispatcher.Run() — «мотор» интерфейса. Этот метод запускает цикл обработки сообщений: клики мыши, нажатия клавиш, перерисовка. Без него окно откроется, но будет «мертвым» — не будет реагировать на действия пользователя.
3. IsBackground = true — чтобы не «висел» процесс. Эта настройка говорит системе: «если основное приложение (Revit) закроется, этот поток можно завершить принудительно». Иначе после закрытия Revit ваш плагин может остаться висеть в диспетчере задач.
4. Корректная очистка. В методе Shutdown мы не просто «убиваем» поток, а аккуратно просим диспетчер завершить работу. Это важно, чтобы сработали все события Closed, отписались подписки и освободились ресурсы.
Как это выглядит в использовании:
Что это дало нам:
- Прогресс-бары обновляются плавно, даже когда Revit отрабатывает код.
- Кнопка «Отмена» реагирует мгновенно — пользователь не чувствует себя заложником.
- Окна можно перетаскивать, сворачивать, переключаться между ними — интерфейс полностью отзывчив.
Но появилась новая задача: теперь окна живут отдельно от Revit. Более того, я не могу вызывать методы Revit API из этого окна, потому что Revit четко требует, чтобы вызовы к его API выполнялись из главного потока. Как заставить их «общаться»?
«Мост» между потоками, или налаживаем «общение»
Когда я вынес UI в отдельный поток, я получил отзывчивый интерфейс. Но тут же возникла новая проблема: теперь у меня два изолированных мира. Если из потока UI попробовать вызвать doc.GetElement(), Revit выбросит исключение о нарушении потока. Мне нужен был «мост» между этими мирами.
Единственный легальный способ: IExternalEvent
В Revit API есть только один официальный механизм для вызова кода извне — IExternalEvent.
Как это работает:
- Вы создаёте класс, который реализует интерфейс IExternalEvent.
- Через ExternalEvent.Create() получаете объект-«кнопку».
- Вызываете .Raise() — это как позвонить в дверь.
- Revit в свободное время вызывает ваш метод Execute() — уже в своём главном потоке.
Проблема: стандартный IExternalEvent работает по принципу «выстрелил и забыл». Он не возвращает результат. А мне нужно было получить данные обратно в UI.
Решение: Очередь задач + TaskCompletionSource
Я написал класс RevitTask, который работает как «диспетчерская» между потоками. Вот как он устроен:
Код выглядит объёмным, но на самом деле здесь всего четыре ключевых идеи. Разберём каждую.
1. ConcurrentQueue<Action> — потокобезопасная очередь
Это самое важное место. Обратите внимание: в очередь мы кладём не просто «работу», а работу плюс инструкцию, что делать с результатом:
Почему это важно? Многие думают, что await где-то снаружи опрашивает: «Готово? Готово?».
Нет. Мы заранее упаковываем в очередь команду: «Когда сделаешь — нажми эту кнопку (SetResult)».
Таким образом, весь цикл жизни задачи (выполнение + возврат) происходит внутри Revit. UI просто сидит и ждёт сигнала.
Почему очередь?
- ExternalEvent может сработать не мгновенно — задачи накапливаются.
- Очередь гарантирует порядок: первая задача пришла → первая выполнилась.
- Concurrent означает, что класть задачи можно из любого потока без блокировок.
2. TaskCompletionSource — «чек с номером»
Это объект, который позволяет создать Task вручную.
UI получает Task мгновенно. Для вызывающего кода это выглядит как обычная асинхронная операция. Но внутри «чека» пусто, пока Revit не вызовет SetResult.
Почему TrySetResult, а не SetResult? Если задача была отменена или уже завершена, SetResult выбросит исключение. TrySetResult безопасно проверяет состояние и ничего не ломает.
3. ExternalEvent.Raise() — звонок в дверь
Важный нюанс: Raise() не гарантирует мгновенное выполнение. Revit выполнит задачу, когда освободится.
4. RevitEventHandler.
Когда Revit принимает событие, он вызывает Execute(). Здесь мы:
- Забираем все задачи из очереди (while цикл).
- Выполняем их в главном потоке Revit.
- Внутри каждой задачи срабатывает tcs.SetResult — это разблокирует await в UI.
Почему while, а не if? Если пользователь быстро нажал несколько кнопок, в очереди может скопиться несколько задач. while гарантирует, что все они будут обработаны за один вызов Execute().
Как это выглядит в использовании
Обратите внимание: код в ViewModel остаётся линейным и читаемым. Вся сложность с потоками скрыта внутри RevitTask
Итоги и пара слов об ограничениях
Нужно уточнить еще раз: это не сделает Revit быстрее. Это не позволит ему одновременно собирать списки элементов в модели и расставлять семейства. Revit всё так же будет выполнять задачи последовательно.
Тогда зачем это всё? Только ради пользователя.
Потому что людей раздражают программы, которые не оповещают о своем состоянии. Когда мы запускаем плагин работать, а он не может даже сказать, на сколько процентов выполнил задачу, мы невольно злимся. В голове появляются мысли:
- «А вдруг он завис?»
- «Сколько ему еще это делать?»
- «Может, перезагрузить, быстрее пойдет?»
Всё, что мы сделали в этой статье, нужно только для того, чтобы ваши окна не блокировались и могли ответить пользователю на эти вопросы. Успокоить его. Показать прогресс. Дать возможность отменить задачу, если он что-то напутал.
UX — не менее важная часть при разработке. И нужно стараться делать свои решения User-Friendly.
--
Понравилась статья? Подписывайтесь, чтобы не пропустить новые материалы по автоматизации и Revit API.
Все анонсы и дополнительные инсайты из разработки я публикую в своём Telegram-канале. Там всегда можно задать вопрос или обсудить решение в комментариях.